Compare commits

18 Commits

Author SHA1 Message Date
daniel be37192fe9 docs: record comment-reduction pass in COMPLETED, add comment conventions to CLAUDE.md 2026-07-29 21:22:37 -04:00
daniel 949dc3af7c Merge comment-reduction: cut source comment volume ~42% tree-wide across 12 tracks 2026-07-29 21:16:52 -04:00
daniel 47ccadf726 Restore review-flagged comment content cut too aggressively in comment reduction 2026-07-29 21:15:00 -04:00
daniel 9d037fb233 Merge 12 comment-reduction tracks: cut source comment volume ~42% tree-wide 2026-07-29 20:49:57 -04:00
daniel 58c6d49261 Cut shell/actions, bank_ops, app comment bloat ~48% (comments only, zero code change) 2026-07-29 20:49:31 -04:00
daniel 8dac5b4a54 Cut core/wire and shell/persist comment bloat ~46% (comments only, zero code change) 2026-07-29 20:49:28 -04:00
daniel 80df142605 Cut core/view and shell/view comment bloat ~65% (comments only, zero code change) 2026-07-29 20:49:26 -04:00
daniel 12ffe377e5 Cut core/capture and core/version comment bloat ~45% (comments only, zero code change) 2026-07-29 20:49:23 -04:00
daniel 65ca1e1f9d Cut core/model, reclaim, json, util comment bloat ~26% (comments only, zero code change) 2026-07-29 20:49:06 -04:00
daniel 3d3415f943 Cut core/ui and core/audio comment bloat ~60% (comments only, zero code change) 2026-07-29 20:49:02 -04:00
daniel 54f5f24506 Cut shell/capture comment bloat ~33% (comments only, zero code change) 2026-07-29 20:48:59 -04:00
daniel cc36dd59c7 Cut shell/panel comment bloat ~47% (comments only, zero code change) 2026-07-29 20:48:56 -04:00
daniel d4d29146c7 Cut shell/instrument comment bloat ~34% (comments only, zero code change) 2026-07-29 20:48:43 -04:00
daniel ccd9968be1 Cut core/instrument/ui comment bloat ~49% (comments only, zero code change) 2026-07-29 20:48:39 -04:00
daniel 354192ae27 Cut core/instrument/map comment bloat ~30% (comments only, zero code change) 2026-07-29 20:48:35 -04:00
daniel 3599d97836 Cut core/instrument/engine comment bloat ~33% (comments only, zero code change) 2026-07-29 20:48:32 -04:00
daniel 85cc340e58 docs: correct FB1 guardrail — r11 did bump ComponentState v7-v8 for masterGainLinear 2026-07-29 15:29:00 -04:00
daniel 1f24c4b095 docs: 1.0 documentation restructure
Split root CLAUDE.md into 19 per-directory files scoped to their source area.
Roll v0 history into docs/ARCHIVE.md; retire CONTEXT.md, CONTEXT-ARCHIVE.md,
PLAN.md, COMPLETED.md. Move plan docs under docs/. Rescue 9 live deferrals
into docs/TODO.md.
2026-07-29 15:09:48 -04:00
250 changed files with 9936 additions and 21764 deletions
+116 -89
View File
File diff suppressed because one or more lines are too long
-3258
View File
File diff suppressed because it is too large Load Diff
-1230
View File
File diff suppressed because it is too large Load Diff
-2779
View File
File diff suppressed because it is too large Load Diff
-619
View File
@@ -1,619 +0,0 @@
# PLAN.md — ReaSampler milestone roadmap
Living milestone roadmap for ReaSampler. Derived from CONTEXT.md's 11-step build
order; CONTEXT.md remains the authoritative spec — this file is the tickable
checklist, not a re-statement of the spec. When a point lands, doc-keeper removes
it here and appends it to `COMPLETED.md`.
**Conventions**
- One checkbox `- [ ]` = one discrete, independently-landable point.
- Each milestone opens with a **Goal** (one line) and a **Verify** criterion
(the acceptance gate; precision invariants pulled in where one applies).
- Verify-in-DAW points require a manual REAPER run; pure points are gated by CTest.
- "See CONTEXT.md §…" points at the authoritative detail — do not duplicate it here.
---
## Open questions to resolve during build
Carried from CONTEXT.md §Open questions — keep visible until each is closed by a
landed milestone.
- **`parseInt` narrowing hardening:** `src/bank_model.cpp` `parseInt` casts
`int64_t → int` via `static_cast` without a range check; integers that fit
in int64 but exceed `INT_MAX` are implementation-defined. Hardening candidate
— add bounds check before the cast when integer-field validation is in scope.
- **Capture send/routing isolation (TODO):** The FX-scope capture neutralizes out-of-scope FX, gain, and pan — but NOT aux **sends**. So a downstream coloring send (e.g. a folder → reverb-track send) still routes and blends the reverb into an item/track capture, past the intended isolation point. A true item-level capture should be taken at the isolated graph point — the target scope's output before out-of-scope track FX/gain/pan **and** before out-of-scope aux/parallel sends. The hard part: distinguish **source routing that must be preserved** (e.g. a MIDI send T1→T2 where T2's synth is where a MIDI item's audio is actually produced — the "item level" for that MIDI item is T2's synth output) from **coloring sends that must be excluded** (folder→reverb). Repro: folder F1; T1 (MIDI) sends MIDI to T2 (synth); T1+T2 → F1; F1 sends to reverb T3; capturing the MIDI item on T1 currently includes the reverb, should be isolated to T2's synth output pre-F1 with the MIDI send preserved and the reverb send excluded. Likely approach: snapshot + mute out-of-scope tracks' aux sends during the render while preserving the main/source signal path — needs a rule for which sends are load-bearing.
---
# Phase D2 — Two-canvas (item-level mode projection; additive to D1)
> **Phase D2 is functionally complete** — D2-W1, D2-W2, D2-W3-A, D2-W3-B all landed; see `COMPLETED.md`.
>
> **Deferred:** panel UI indicator for per-track lane/mode state (a per-track
> lane-split marker). The mode switch already shows the active mode; no natural
> cheap home for a per-track indicator was found in the bank panel. Explicitly
> deferred — not silently dropped. Can be picked up later if wanted.
---
# Phase S — MIDI-playback instrument (native VST3 sampler; a second build artifact)
> **Landed on dev (merged 2026-07-27); DAW verification pending Daniel's smoke test.**
> S1S18 and the product-name/binary-rename work are all on dev. The cross-artifact
> ingest relay (S13 bullet) was explicitly DEGRADED and remains deferred — see below.
> Completed material archived in `COMPLETED.md`. Authoritative spec: **CONTEXT.md
> §MIDI-playback instrument — additive phase spec (Phase S)**. Product framing:
> `docs/product/midi-playback.md`.
>
## S13 — cross-artifact ingest relay (deferred)
> **SPIKE VERDICT (ps-w12, 2026-07-27): DEGRADED — relay deferred.** The instrument's
> REAPER bridge (`reaper_bridge`) is deliberately READ-ONLY; a relay would need a new
> instrument WRITE seam into ext-state and an extension-side timer poller servicing a
> drop-ingest inbox key with a claim/clear nonce — the same cross-process handshake race
> the S17 spec rejected for alternative (A). Both the read-only-instrument boundary and
> the new poller are load-bearing design calls, so the relay is deferred to a future wave.
> The shipped ingest gesture stays drop-onto-docked-panel (S8). The degrade path (editor
> shows a "drop files onto the ReaSampler bank panel to add them" affordance) landed as
> part of Phase S. See `COMPLETED.md` §S13 for full context.
- [ ] Cross-artifact ingest relay: the editor hands the dropped path + this instance's
identity to the extension as a bank-ingest request over an agreed seam. DEFERRED —
relay mechanism proved load-bearing to redesign; drop-onto-panel (S8) is the shipped
ingest path. Requires (a) a new instrument WRITE seam into ext-state and (b) an
extension-side timer poller + claim/clear nonce. A future wave when the design is ready.
## Phase S — product name (ReaSampler 9000)
The MIDI-playback instrument's product name is **ReaSampler 9000** (Daniel, 2026-07-26,
on DAW-testing the S1S6 instrument). The extension remains **ReaSampler**; the instrument
is **ReaSampler 9000**. Framing + propagation surfaces:
`docs/product/midi-playback.md` §Product name.
- [ ] **Compat verification (must-DAW-verify before shipping the rename):** the working
assumption is that REAPER **rebinds a saved instance by its VST3 class UID, not by the
module filename**, so a filename rename with an unchanged UID keeps saved projects working
(existing instances still resolve). **This is not yet confirmed from source** — a web
check surfaced a JUCE/VST3-replace-VST2 case suggesting REAPER's binding is more nuanced
than "UID only" (it can involve an FXID match), so treat UID-rebind as **to-verify, not
asserted fact**. **DAW-verify:** save a project with a ReaSampler 9000 instance under the
old filename, rename the module, reopen — confirm the instance rebinds and restores its
state. If REAPER does key partly on filename, fall back to keeping the current filename
(display-strings-only) and record that as the shipped choice.
## Phase S — held and optional-forever (noted, not specified)
- **Tier 2 — "expressive" (HELD).** Velocity layers, round-robin (anti-machine-gun),
full ADSR, per-sample tuning/gain trim, sustain loops. The next depth increment once
Tier 01 proves the instrument belongs — **its points are not drawn up here.**
- **Tier 3 — "instrument polish" (optional-forever).** Filters, filter/pitch
envelopes, LFOs, per-voice pan, choke groups, a modest FX slot. A direction to leave
room for, never a commitment. **Note:** S16 lands the *pitch* envelope + the Varispeed/
Preserve pitch-engine mode early (Daniel's directive) — the Tier-3 "filter/pitch envelopes"
line now means the *filter* envelope + LFOs remainder.
- **Sinc Varispeed-quality upgrade (HELD — WDL_Resampler).** `WDL_Resampler`'s sinc mode
beats the core's 2-point linear interp for **Varispeed** base-repitch quality (see the S16
WDL finding). An optional per-voice quality toggle (linear default / sinc), RT-suitable but
heavier. Held as a Tier-2/3 quality option — not needed for S15/S16, not scheduled. (A
resampler couples duration, so it is a Varispeed-quality option only, **not** a Preserve
engine.)
- **WDL_SimplePitchShifter swap (HELD — fork S16-F2 route a).** `WDL_SimplePitchShifter`
as a drop-in swap for the `pitch_shift` pure module if the hand-rolled OLA onset latency
or warble proves musically unacceptable. Same `PitchEngine::Preserve` contract behind the
seam. WDL excluded from the shipped build by include-chain (windows.h); held as the
quality/latency alternative.
- **Trigger choke-on-note-off (HELD — fork S15-F1).** A future option for Trigger mode to
*cut* (choke) on note-off or on a same-group re-trigger (hi-hat open/closed). Deliberately
out of S15 scope (Trigger ignores note-off entirely there); a Tier-3 choke-group direction.
## Phase S — editor view-model redesign (three views: Sample / Browse / Zone)
> **Additive Phase S sub-phase (S-VIEW; Daniel, 2026-07-27, r9).** Re-partitions the ReaSampler
> 9000 editor from a two-view toggle into a **three-view model where the loaded sample is the
> home** — Sample (default face), Browse (modal picker over Sample), Zone (dedicated keymap
> surface). Adds three performance parameters (key-tracking, preview velocity, and the r10
> velocity→amp transfer curve) and three visual components (envelope overlay, real piano-key
> pattern, and the r10 velocity-curve editor), and frames two engineering prerequisites
> (drop-to-FX bug, default window size). An **editor** redesign — the S3 voice engine, keymap
> resolution, and read-only-over-bank contract are **unchanged**; component state extends
> additively for key-tracking; VST3 class UID unchanged. Authoritative spec: **CONTEXT.md §Phase
> S — editor view-model redesign (S-VIEW)**. Product framing: `docs/product/midi-playback.md`
> §Addendum r9. When a point lands, doc-keeper moves it to `COMPLETED.md`.
>
> **Depends on Phase S being on dev** (this redesigns Phase S editor shells —
> `reasampler_editor.cpp` + the pure `src/vst/` geometry modules). **Phase L L3 is FINISHED and
> merged** (2026-07-27, commit `c53683e``reasampler_editor.cpp` + `reasampler_embed.cpp`
> restyled through the L1 kit). S-VIEW builds the new three-view layout **directly on the current
> L3 look-and-feel as its baseline — one implementation pass, styled correctly from the start.**
> There is **no two-pass "land S-VIEW then restyle through L3"** — L3 already happened; every new
> S-VIEW surface (Sample face, Browse modal, Zone surface, envelope overlay + node handles,
> piano-key strip, preview cluster) is drawn through the L1 kit at build time, inheriting the L3
> palette and component-draw grammar. Restyle-after is not a concern.
**Goal:** A three-view editor that makes "pick a capture, tune it, play it" fast, easy, and fun —
Sample is home, Browse is an easy-to-summon modal picker, Zone is the deliberate keymap surface.
**Verify (in DAW):** open on a 1080p screen → full Sample face (hero waveform + envelope overlay +
fenced root + preview-trigger + control strip) with no scroll; Browse opens as a modal over
Sample, select+confirm loads a new capture; Zone opens on its own button, key-tracking + the
piano-key pattern work; drop-a-capture-onto-FX instantiates a playing instance.
### Phase S editor Wave B — Sample-face recomposition (r11; Daniel, 2026-07-27)
> Daniel's post-landing DAW pass on the S-VIEW editor: **all linear sliders → small radial
> knobs** grouped into a fenced knob deck (envelope controls grouped as a unit); the **mode
> toggles compact**, not full-width; the **inline velocity-curve box → a miniature curve
> preview button + full-size popup editor** (right-click deletes a node in the popup); the
> **hero waveform full-width**. Authoritative layout spec: **CONTEXT.md §S-VIEW → "The
> Sample-face recomposition (r11)"** (band order, group taxonomy, cell metrics, popup
> geometry, inventory contract — nothing silently dropped). Product framing:
> `docs/product/midi-playback.md` §Addendum r11. All drawing through the L1 kit; all
> layout/hit-test in pure geometry modules.
>
> **S-VIEW-11, S-VIEW-12, and S-VIEW-13 have all landed** — FB1 (merged 2026-07-27, suite
> 55/55) brought the knob deck + master gain + curve popup + full-width hero; FB2 (merged
> 2026-07-28, suite 55/55) brought Zone-panel parity. **Phase S editor Wave B (r11) is
> complete.** See `COMPLETED.md` for both FB entries.
**Open forks** — **R11-F1** (hero height vs. default window) SETTLED at FB1 build: elastic
hero, 840×620 default kept. **R11-F2** (Zone-panel parity) SETTLED at FB2 build: knob deck
+ curve popup adopted on the Zone panel; `param_slider` slider rows retired on that surface.
### Phase S editor redesign — forks (Daniel's to call)
*(r9/r10 forks all settled: S-VIEW-F1 and S-VIEW-F2 SETTLED 2026-07-27 — folded into S-VIEW-4 /
S-VIEW-3. F1: preview velocity persists via envelope-v6 `ComponentState`. F2: envelope nodes are
draggable via the pure `envelope_edit` module. R10-F1 SETTLED 2026-07-27 — Option A flat y=1,
folded into S-VIEW-9. S-VIEW-F3 SETTLED — full-window overlay, implemented as Browse renders as a
full-window modal over Sample (landed in S-VIEW-1/S-VIEW-5). **r11 forks all settled: R11-F1
SETTLED at FB1 build (elastic hero, 840×620 default kept); R11-F2 SETTLED at FB2 build (Zone
panel adopts knob deck + curve popup, `param_slider` slider rows retired on that surface).**)*
---
# Phase Q — Quality (structural reorganization; zero-runtime-cost)
> **New pillar, own lettered namespace, and — uniquely — the LAST structural pillar.** Phase
> Q is a **pure structural refactor**: it reorganizes `src/` into a healthier shape (more
> encapsulation, granular namespaces, `core/`/`shell/`/`app/` subdirectories) against a stated
> quality bar — *"mtytel Vital is my code reference for quality"* — to bring the codebase
> "into the realm of something I can stand to look at." It ships **no feature and changes no
> behavior**: the test suite passing unchanged is the proof of correctness. Namespaced **`Q`
> (Quality)** — M/D/B/R/V/S/L are all taken; `Q` names the *end* (the quality bar), the reorg
> being the *means*. Authoritative spec: **CONTEXT.md §Phase Q — structural reorganization
> (reorg spec)**. Product framing, the Vital-grounded target shape, the grep-verified SOLID
> audit that is the evidence base, and the settled/recommended fork record (Q-1..Q-6):
> `docs/product/code-organization.md`. When a point lands, doc-keeper moves it to
> `COMPLETED.md`.
>
> **THE GATE (load-bearing — state first; reconciled to reality 2026-07-27).** Phase Q is
> **gated on the tree being otherwise quiescent.** Daniel's plain readiness target: **"when
> Phase S and L3 are finished."** **As of 2026-07-27 both gate conditions are satisfied:**
> Phase S merged to dev (2026-07-27); Phase L L3 (the VST restyle) merged to dev
> (2026-07-27, commit `c53683e`) — **Phase L is complete** (L1/L2/L3/L4/L5/L6/L7 all
> landed, see `COMPLETED.md`). **D2** is functionally complete (D2-W1..W3-B landed; the
> lone open item, a per-track lane-split panel indicator, is *explicitly deferred*, not a
> blocking residual). **M9** (slots) is **abandoned** (Daniel, 2026-07-27) — will not be
> built. D2 is named in the gate only so that *reactivating* its deferred panel indicator
> re-arms the quiescence condition; neither D2 nor M9 blocks the gate today. *Why the gate:* Phase Q
> touches **nearly every file in `src/`** (relocate into subdirectories, re-namespace every
> header, split the four largest TUs, plus the §2b renames). Every large in-flight branch
> (Phase S on its worktree, and L3 once it lands) is diffed against the *current flat
> layout*; landing a rename-and-relocate-everything reorg mid-flight forces every open
> branch through the worst conflict class (every hunk moved, every qualified reference
> changed) — a combinatorial re-resolution, not a linear one. Phase Q is *last* precisely
> because it reshapes the ground every other pillar stands on. Landing it early taxes every
> subsequent phase; landing it last taxes nothing. **The gate is now satisfied — Phase Q
> may begin, entering at the Q-W0 audit wave** (the structural waves Q-W1+ do not begin until
> Q-W0's triage closes and Daniel signs off — see the Q-W0 sub-gate above).
>
> **M9 disposition — resolved (Daniel, 2026-07-27): abandoned.** M9 is out; it will not be
> reactivated. The gate remains satisfied; no re-arm condition applies.
>
> **PRE-RESTRUCTURE AUDIT WAVE (Q-W0 — added 2026-07-27, Daniel's ask; runs FIRST).** Before any
> structural point (Q-W1+) begins, Phase Q now opens with a **functional + DSP quality audit**
> (`Q-W0`) — a thorough static analysis of the code from a *functional-correctness* and
> *algorithm-quality* point of view, complementary to (not a repeat of) the grep-verified
> SOLID/naming audit that already grounds Q-W1..Q-W6. Q-W0 hunts DSP smells (close eye on the pitch
> engine), reinvented wheels, duplicate code, poor-quality algorithms, leaky pure/shell boundaries,
> and domain-modeling smells around env-coupled constants; it produces a written, triaged findings
> report. **The gate to begin Q-W1 is that Q-W0's triage is complete and Daniel has signed off on
> each finding's disposition** (fix-now vs. document-and-defer). Q-W0's findings may add or reshape
> 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; **+
> `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
> replacement; a technique replacement is a Daniel decision at triage time, not an automatic Q-W0
> action).
> **Recommended, Daniel's to call (Q-2..Q-9, see
> `docs/product/code-organization.md` §6):** Q-2 JSON extraction in scope + first (rec: yes);
> Q-3 directory shape `core/`/`shell/`/`app/` top-split with subsystem dirs beneath (rec: this
> over pure-Vital subsystem-first — it makes the pure/shell invariant *structural*); Q-4
> sub-namespace to match sub-directory (rec: both); Q-5 split god-modules to the audit's named
> seams, no finer (rec: yes); Q-6 OCP registration-table as the final wave (rec: in, last);
> **Q-7 naming rides the relocation waves, no dedicated naming wave (rec: yes — forced once
> Q-3/Q-4 settle); Q-8 class/module renames — fix the two that actively mislead (`BankIndex`
> `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
> header→TU indirection, no changed call/inline or branch shape.** `computeEnvelope` stays a
> free function on `const std::vector<float>&`; audition split stays a direct call-through;
> the realtime idle tick stays a single pointer test; `FxBypassGuard` stays stack RAII. This is
> 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
> found: four `class Parser` copies collapsing to one `json::Parser` (Q-W1); shared pure-UI rect
> types (`FooterRect`/`ButtonRect`) that the codebase already hand-checks for collision
> (`footer_bar.h`'s "NAME NOTE") — resolved by the Q-4 sub-namespaces for free; the
> `bank_model`/`BankIndex` file↔class word-mismatch (Q-8); and the `capture_realtime`/
> `realtime_record` shell↔core word-order inversion (Q-9). **Renames ride the wave that already
> relocates/splits the file — no dedicated naming wave (Q-7);** the geometry-mirror
> `compute*`/`hitTest*` verb vocabulary and the `_tests` suffix are already consistent and are
> preserved verbatim. Naming changes are zero-behavior-change like the rest of Phase Q, and the
> FOREVER-STABLE contract strings (command ids, action names, ext-state namespace/keys, VST3 UID)
> are **not** C++ symbols and are never renamed.
>
> **Every point is independently landable and CTest-green at EVERY step.** The CMake
> 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/W2vW5 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
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
Q-W1..Q-W6 (§2/§2b of `docs/product/code-organization.md`) — it goes deeper on *does the code do the
right thing well*, not *where does the responsibility live*. It hunts, across the whole `src/` tree:
duplicate code, reinvented wheels, poor-quality algorithms (**close eye on the pitch engine**),
numerical-robustness and artifact hazards in the DSP paths, leaky pure/shell boundaries, and
**domain-modeling smells around env-coupled constants** (values stored in a frame/rate/DPI/tick-coupled
domain that should be stored rate-free and resolved at use). Every finding is **classified for
disposition**: *eliminate-before-restructure* (fix-now) vs. *document-and-defer* (with rationale).
Nothing structural (Q-W1+) begins until this wave closes and Daniel signs off on the disposition of
every finding. CONTEXT.md §Phase Q (Q-W0 audit scope + triage). See
`docs/product/code-organization.md` §2c.
**Verify:** A findings report exists and is complete over the named audit surfaces (DSP/audio incl.
pitch; architecture smells; env-coupled-constant domain-modeling smells). **Every finding is
triaged** (fix-now vs. documented-and-deferred, each with a one-line rationale). Fix-now findings are
either remediated in this wave or explicitly assigned to the downstream wave that already touches the
file (recorded per finding); no fix-now finding is left implicit. Any behavior-changing remediation
lands with its module's CTest executable green and (where a DSP path changes audibly) a stated
before/after listening or null check. **The gate to Q-W1 is: triage complete + Daniel signed off.**
**Depends on:** the GATE (tree quiescent). Precedes every other Q wave — Q-W1 depends on Q-W0.
- [ ] **DSP / audio audit — close eye on pitch.** Assess *algorithm quality* (correctness,
artifacts, numerical robustness, interpolation quality, reinvented-wheel vs. established DSP
technique) across: `src/vst/pitch_shift` (hand-rolled OLA pitch-preserve — window/overlap choice,
phase handling, transient/formant behavior, buffer edges), `sampler_core` (repitch ratio math,
interpolation order/quality, loop-point crossfade, voice-stealing correctness/click behavior),
`peaks` (envelope min/max binning), `wav_trim` (decay-scan threshold + truncate plan), and the
capture/tail paths. **Pitch-remediation depth (Q-11 SETTLED, Daniel 2026-07-27): defer to
findings** — default document-and-defer; only if the audit surfaces artifacts that matter, weigh a
bounded OLA fix (window/overlap/edge tuning) **before** a technique replacement. A technique
replacement (phase-vocoder / WSOLA) reshapes `pitch_shift`, spills a downstream Q-wave point, and is
a **Daniel decision at triage time, not an automatic Q-W0 action** — Q-W0 surfaces the
recommendation, Daniel calls it.
- [ ] **Architecture-smell audit.** Duplicate code, reinvented wheels, poor abstractions, and any
leaky pure/shell boundary (a `core/` module reaching a REAPER/host type, or geometry/algorithm
math living untestable in a shell). Cross-check against — but do not merely restate — the §2/§2b
SOLID/naming findings; Q-W0's contribution is the *functional* smells those audits didn't target.
- [ ] **Env-coupled-constant domain-modeling audit (explicit smell category).** Flag ANY value
stored in a frame/rate/DPI/tick-coupled domain that should be stored **rate-free and resolved at
the point of use** — per the load-bearing invariant that wall-clock times are rate-free SECONDS
resolved against the live project rate (`sample_map`), with NO hardcoded sample rates in `src/`.
Treat this as **domain-modeling** (store rate-free, resolve at use), **not** "rescale by rate."
There was a prior incident here — envelope times stored in the frame domain — so this is a
first-class category, not a footnote. Sweep envelope times, loop points, fade lengths, tail
lengths, and any UI geometry constant that silently assumes a DPI/rate.
- [ ] **Triage + report.** Write the findings report as a **committed doc,
`docs/product/code-quality-audit.md`** (Q-10 SETTLED, Daniel 2026-07-27 — beside the SOLID/naming
audit, not a tracked issue list); classify each finding fix-now vs. document-and-defer with a
one-line rationale; for each fix-now, note whether it is remediated in Q-W0 or assigned to the
downstream wave that already opens the file. Deferred findings carry a documented rationale so they
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. **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.**
## Q-W1 — safe opener: extract `core/json` + impose the directory/namespace layout on clean modules
> **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)
> **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 ~322369 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)
> **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)
> **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 `<epoch>-<n>` / `rt-<epoch>-<n>`; 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`
> **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`)
> **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
> **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
```
GATE: Phase S + Phase L L3 merged to dev (D2 complete, M9 abandoned) — tree quiescent
("when Phase S and L3 are finished" — L1/L2/L3/L4L7 all landed — GATE SATISFIED)
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 — 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 — 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 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,
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`.) **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<float>&`;
audition stays a direct call-through; the idle tick stays a single pointer test. (CONTEXT.md
§Phase Q, `docs/product/code-organization.md` §3.)
- **Command-id + display strings are FOREVER-STABLE** — a reorg must not change a shipped
`command_id` string, action display name, ext-state namespace, or VST3 class UID. Re-namespacing
C++ symbols is orthogonal to these on-the-wire/on-disk contract strings; keep them byte-identical
(per-channel, per the Phase V V4 contract). (CONTEXT.md §Phase Q.)
- **Name-collision sweep on sub-namespacing**`Sample` (model) vs `AudioSample` (audio) vs the
unified `Parser` (json), plus the shared pure-UI rect types `FooterRect`/`ButtonRect` (already
hand-collision-checked in `footer_bar.h`), and any other cross-lib name that collides once
flattened into granular namespaces. Resolve by each symbol's new subsystem home before landing
W1. (naming audit §2b.2; `docs/product/code-organization.md` §6 Q-4/Q-8.)
- **Naming stays zero-behavior-change and off the wire** — the §2b renames touch C++ symbols only;
no `command_id` string, action display name, ext-state namespace/key, `reasampler:` lane prefix,
or VST3 class UID is renamed (naming audit §2b.5). A rename that would alter a shipped contract
literal is out of scope, full stop.
- **The GATE** — do not begin any Q point until **Phase S + Phase L L3** are merged to dev and the
tree is quiescent (D2 complete, M9 abandoned). Re-confirm quiescence against dev before W1.
-17
View File
@@ -1,17 +0,0 @@
# TODO
Forward-looking follow-ups. Deferred by decision, not oversight — each entry records why it was deferred and what "done" looks like.
## Persist ReaSampler 9000 instance identity to let prune reclaim de-referenced captures after reopen
**Context (what shipped — Phase S usage-detection).** Each ReaSampler 9000 instance publishes the captures it holds to project ext-state (`rsusage_<guid>` keys, ComponentState v11). The extension's prune reads those records and unions every live instance's held captures into the referenced-set, so a capture any live instance holds can never be pruned. Fail-safe: unreadable/ambiguous usage state aborts prune (deletes nothing). Airtight on safety.
**The wart.** The per-instance identity token is minted fresh each incarnation and is NOT persisted. After save→reopen, an instance cannot recognize its OWN prior-session usage record — it looks foreign, so the instance defensively unions and marks the record append-only (poisoned). Net effect: after any reopen, prune stops reclaiming captures an instance once loaded but no longer uses. Safe (never deletes a used capture), but the bank folder grows without bound.
**Intended fix.** Persist the instance identity in ComponentState so an instance recognizes its own last-session record and does a clean-replace instead of union/poison → prune reclaims de-referenced instance-touched captures normally.
**The constraint the fix MUST handle (why deferred).** VST3 provides no stable per-instance identity, and Ctrl+D / in-place FX duplication clones plugin state. A persisted identity is inherited by an in-place duplicate → two live instances in one project share one `rsusage_<guid>` key. Harmless while both hold the same capture; the risk is a divergent clone — the copies load DIFFERENT captures, and last-writer-wins drops the other's held capture from the record, exposing it to prune. The fix must detect a genuine live same-identity collision and protect the union in that case, WITHOUT reintroducing the sibling-drop bug the fresh-per-session token was originally added to prevent. (Whole-project copies are a non-issue — bank files are cloned with the project and ext-state is per-project.)
**Priority / risk.** Low / deferred. Current behavior is safe; the only cost is unbounded bank-folder growth after reopens. Decided 2026-07-28 to ship the safe version and defer this.
**Done looks like.** Save → reopen → de-reference a capture from an instance → prune reclaims it. And: in-place-duplicate + diverge + delete-from-bank never deletes a capture a live instance holds.
+1065
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
# COMPLETED.md — ReaSampler landed milestones
Completed milestone entries removed from `PLAN.md`. Each entry preserves its
original Goal, Verify, and checklist points with boxes marked done.
This file holds the current (1.x) cycle's landed milestones only. For all
pre-1.0 (version-0) history, see `docs/ARCHIVE.md`.
### Comment-reduction pass (tree-wide, twelve parallel tracks)
Cut source comment volume tree-wide: 209 files changed, net **6,493** lines.
Comment-only lines went from 15,073 to ~8,671, a **~42% cut** — before the
pass, 37% of all source lines were comment-only. **Zero code drift**, verified
across all 209 files by comparing comment-stripped hashes; the sole
intentional exception (Daniel-approved) is two user-facing error strings in
`src/shell/capture/capture.cpp` that lost internal milestone IDs (`M8`, `M3`,
`M7+`). Build clean, 61/61 ctest pass. Code review surfaced 2 Major + 8 Minor
findings — all content cut that should have survived — and all ten were
remediated and re-gated before merge. Driver: Daniel's instruction — *"Brief
concise engineering comments. A little why, and maybe context, never WHAT."*
View File
+133
View File
@@ -0,0 +1,133 @@
# TODO
Forward-looking follow-ups. Deferred by decision, not oversight — each entry records why it was deferred and what "done" looks like.
## Persist ReaSampler 9000 instance identity to let prune reclaim de-referenced captures after reopen
**Context (what shipped — Phase S usage-detection).** Each ReaSampler 9000 instance publishes the captures it holds to project ext-state (`rsusage_<guid>` keys, ComponentState v11). The extension's prune reads those records and unions every live instance's held captures into the referenced-set, so a capture any live instance holds can never be pruned. Fail-safe: unreadable/ambiguous usage state aborts prune (deletes nothing). Airtight on safety.
**The wart.** The per-instance identity token is minted fresh each incarnation and is NOT persisted. After save→reopen, an instance cannot recognize its OWN prior-session usage record — it looks foreign, so the instance defensively unions and marks the record append-only (poisoned). Net effect: after any reopen, prune stops reclaiming captures an instance once loaded but no longer uses. Safe (never deletes a used capture), but the bank folder grows without bound.
**Intended fix.** Persist the instance identity in ComponentState so an instance recognizes its own last-session record and does a clean-replace instead of union/poison → prune reclaims de-referenced instance-touched captures normally.
**The constraint the fix MUST handle (why deferred).** VST3 provides no stable per-instance identity, and Ctrl+D / in-place FX duplication clones plugin state. A persisted identity is inherited by an in-place duplicate → two live instances in one project share one `rsusage_<guid>` key. Harmless while both hold the same capture; the risk is a divergent clone — the copies load DIFFERENT captures, and last-writer-wins drops the other's held capture from the record, exposing it to prune. The fix must detect a genuine live same-identity collision and protect the union in that case, WITHOUT reintroducing the sibling-drop bug the fresh-per-session token was originally added to prevent. (Whole-project copies are a non-issue — bank files are cloned with the project and ext-state is per-project.)
**Priority / risk.** Low / deferred. Current behavior is safe; the only cost is unbounded bank-folder growth after reopens. Decided 2026-07-28 to ship the safe version and defer this.
**Done looks like.** Save → reopen → de-reference a capture from an instance → prune reclaims it. And: in-place-duplicate + diverge + delete-from-bank never deletes a capture a live instance holds.
## Isolate capture from out-of-scope aux/parallel sends, not just FX/gain/pan
**Context (carried from PLAN.md's "Open questions to resolve during build").** The FX-scope capture neutralizes out-of-scope FX, gain, and pan for both item and track scope — root `CLAUDE.md`'s "Capture FX scope" precision invariant states this precisely: the out-of-scope chain (ancestors + master track, plus the item's own track for item scope) has its FX, gain, and pan/width/pan-law/mode neutralized to unity. **Aux/parallel sends are conspicuously absent from that enumerated list** — the invariant as currently written does not cover them, which is the gap this item exists to close.
**The wart.** A downstream coloring send (e.g. a folder → reverb-track send) still routes and blends into an item/track capture, past the intended isolation point. Repro from PLAN.md: folder F1; T1 (MIDI) sends MIDI to T2 (synth); T1+T2 → F1; F1 sends to reverb T3; capturing the MIDI item on T1 currently includes the reverb, but should be isolated to T2's synth output pre-F1 with the MIDI send preserved and the reverb send excluded.
**Intended fix.** Likely approach (PLAN.md): snapshot + mute out-of-scope tracks' aux sends during the render while preserving the main/source signal path.
**The constraint the fix MUST handle.** Distinguish **source routing that must be preserved** (e.g. a MIDI send T1→T2 where T2's synth is where a MIDI item's audio is actually produced — the "item level" for that MIDI item is T2's synth output) from **coloring sends that must be excluded** (folder→reverb). PLAN.md notes this is "the hard part" and that a rule is needed for which sends are load-bearing.
**Settled rule (Daniel, 2026-07-29).** The classification rule for which sends are load-bearing: (1) **ancestor sends are excluded** from the capture — the folder parent is *up* the folder tree but *downstream* in signal flow, so this item deliberately says "ancestor," not "upstream," which would read backwards to anyone applying signal-flow convention; (2) **sibling sends are preserved and captured** — a sibling is a track sharing the capture scope's parent; (3) **if the send's destination has a different parent, ignore it in the capture** — it isn't a sibling, so it isn't captured. Applied to the repro above: the T1→T2 MIDI send is a sibling send (T1 and T2 share parent F1) and is preserved — T2's synth is where the item's audio is actually produced; the F1→T3 reverb send is an ancestor send and is excluded. This resolves the repro.
**Still open.** The rule above settles *which sends to mute*, but the repro is only fully resolved together with a second, unspecified lever — *where the capture taps*. PLAN.md's own framing of this open question says a true item-level capture should be taken "at the isolated graph point — the target scope's output before out-of-scope track FX/gain/pan and before out-of-scope aux/parallel sends." How the mute-rule above and the tap-point interact is not yet specified.
**Priority / risk.** Not stated in PLAN.md (recorded there as an open `(TODO)` question, not yet triaged to a priority). Flagged here as mattering more than the other four carried-over items because it exposes a live gap in a stated precision invariant, not just a deferred feature.
**Done looks like.** Capturing the MIDI item on T1 in the repro above is isolated to T2's synth output pre-F1 — the T1→T2 MIDI send is preserved and the F1→reverb send is excluded — and the CLAUDE.md "Capture FX scope" invariant's neutralization list is extended to name sends explicitly.
## Confirm REAPER's VST3 UID-vs-filename instance rebind behavior (Phase S compat verification)
**Context.** PLAN.md, under "Phase S — product name (ReaSampler 9000)": the working assumption is that REAPER rebinds a saved instance by its VST3 class UID, not by the module filename, so a filename rename with an unchanged UID keeps saved projects working (existing instances still resolve).
**The wart.** This is not yet confirmed from source — PLAN.md records that a web check surfaced a JUCE/VST3-replace-VST2 case suggesting REAPER's binding is more nuanced than "UID only" (it can involve an FXID match), so UID-rebind is to-verify, not asserted fact.
**Intended fix / required check.** DAW-verify: save a project with a ReaSampler 9000 instance under the old filename, rename the module, reopen — confirm the instance rebinds and restores its state.
**The constraint the fix MUST handle.** If REAPER does key partly on filename, the fallback is to keep the current filename (display-strings-only) and record that as the shipped choice.
**Priority / risk.** Marked "must-DAW-verify before shipping the rename" in PLAN.md; no explicit priority level stated beyond that.
**Done looks like.** The save→rename→reopen DAW check is performed and its result (rebind confirmed, or filename-revert fallback taken) is recorded.
## S13 — cross-artifact ingest relay (deferred, spike verdict DEGRADED)
**Context.** PLAN.md records the ps-w12 (2026-07-27) spike verdict: DEGRADED — relay deferred. The instrument's REAPER bridge (`reaper_bridge`) is deliberately READ-ONLY; a relay would need a new instrument WRITE seam into ext-state and an extension-side timer poller servicing a drop-ingest inbox key with a claim/clear nonce — the same cross-process handshake race the S17 spec rejected for alternative (A). The shipped ingest gesture stays drop-onto-docked-panel (S8); the editor shows a "drop files onto the ReaSampler bank panel to add them" affordance as the degrade path.
**The wart.** Dropping a file directly onto the editor/instrument does not ingest it into the bank — only drop-onto-docked-panel does.
**Intended fix.** The editor hands the dropped path + this instance's identity to the extension as a bank-ingest request over an agreed seam.
**The constraint the fix MUST handle.** Requires (a) a new instrument WRITE seam into ext-state (breaking the current read-only-bridge invariant) and (b) an extension-side timer poller + claim/clear nonce — both are load-bearing design calls that need to be made deliberately, not as a call-site patch. Both the read-only-instrument boundary and the new poller were judged load-bearing enough that the relay is deferred to a future wave rather than pushed through now.
**Priority / risk.** PLAN.md marks this DEFERRED, awaiting a future wave, with no priority assigned.
**Done looks like.** Not stated in PLAN.md beyond "a future wave when the design is ready."
## Phase D2 — per-track lane/mode-state panel indicator (deferred)
**Context.** PLAN.md: Phase D2 is functionally complete (D2-W1, D2-W2, D2-W3-A, D2-W3-B all landed). One item was deferred out of that completion: a panel UI indicator for per-track lane/mode state (a per-track lane-split marker).
**The wart.** The mode switch already shows the active mode, but there is no per-track indicator; PLAN.md records that no natural cheap home for one was found in the bank panel.
**Intended fix.** Not specified in PLAN.md beyond the goal (a per-track lane-split marker in the bank panel) — the design is unresolved, which is part of why it was deferred rather than built.
**The constraint the fix MUST handle.** Finding a home for the indicator in the bank panel's existing layout, which PLAN.md notes doesn't currently have a natural cheap spot for it.
**Priority / risk.** Not stated in PLAN.md. PLAN.md's own framing: "Explicitly deferred — not silently dropped. Can be picked up later if wanted."
**Done looks like.** Not stated in PLAN.md.
## FX-GUID keying for `restoreFxOffline` (Design View park/restore)
**Context.** CONTEXT.md's "Open questions to resolve during build" (Design View section): the bulk of reconcile residuals shipped (`ViewModeModel::reconcile(liveGuids)` prunes orphaned snapshots on every toggle/load; folder restructure is self-healing because the tree is rebuilt each toggle; membership is intentionally kept so undo-delete preserves the tag). Two sub-items were left deferred out of that; this is the first.
**The wart.** `restoreFxOffline` currently restores per-FX offline state by slot index. If the FX chain is reshuffled while a track is parked, restore lands on whatever plugin now occupies that slot rather than the plugin it was originally captured from.
**Intended fix.** FX-GUID keying — key the per-FX offline snapshot entries by FX identity rather than slot index.
**The constraint the fix MUST handle.** The keying change requires a snapshot-schema migration; CONTEXT.md names this alongside the keying change as the reason the fix was deferred rather than folded into the reconcile-residuals work.
**Priority / risk.** Not stated in the source.
**Done looks like.** Not stated in the source beyond the fix description above.
## Dormant membership entries in persisted `view_state`
**Context.** CONTEXT.md's "Open questions to resolve during build" (Design View section), the second of the two sub-items left deferred after the reconcile-residuals ship described above.
**The wart.** Truly-deleted tracks accumulate stale entries in persisted `view_state`.
**Intended fix.** A future user-initiated "compact" action to remove the stale entries.
**The constraint the fix MUST handle.** Must NOT be automatic pruning — automatic pruning would reintroduce the undo-delete tag-loss that the deliberate membership-retention was designed to prevent.
**Priority / risk.** Not stated as a priority level; the source characterizes the wart itself as "harmless and bounded."
**Done looks like.** Not stated in the source beyond "a future user-initiated 'compact' action."
## Confirm no fight between Design View flags and screenset recall
**Context.** CONTEXT.md's "Open questions to resolve during build" (Design View section): Design View drives the same track flags a screenset recall would drive, and last writer wins between the two.
**The wart.** Not a defect — this is a verification task, not a code change. The open concern is confirming there is no surprising interaction between Design View's flag-driving and an active screenset recall.
**Intended fix.** N/A — no fix is proposed; the task is to confirm no surprising fight between the two mechanisms.
**The constraint the fix MUST handle.** N/A — verification only.
**Priority / risk.** Not stated in the source.
**Done looks like.** Not stated in the source beyond "confirm no surprising fight."
## Active-bank indicator placement (B4 polish)
**Context.** CONTEXT-ARCHIVE.md's "Open questions to resolve during build" (B4 panel section): forks 15 are all settled; one panel-polish detail remains open. Fork 4 already settled that the active-bank indicator must be "visually unmistakable" — only its placement is undecided.
**The wart.** No placement chosen yet among three candidates: per-region headers, a single header readout, or a lit-tab treatment.
**Intended fix.** Not stated in the source beyond the three candidate placements above — the choice among them is the open item. This is explicitly a panel-polish detail.
**The constraint the fix MUST handle.** Not stated in the source.
**Priority / risk.** Not stated as a priority level; the source characterizes this as a "panel-polish detail."
**Done looks like.** Not stated in the source beyond choosing one of the three placement options.
@@ -10,7 +10,8 @@ flatness, naming families) are **not restated**; where a finding below touches t
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.
audit's snapshot. Wave assignments reference the Q-W1..Q-W6 waves (landed history in
`docs/ARCHIVE.md`; sequencing in `docs/product/code-organization.md` §5).
---
@@ -2,9 +2,9 @@
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
stored **rate-free / device-free and resolved at the point of use** (`docs/product/code-organization.md`
§2c.3 env-coupled bullet; the load-bearing `sample_map` seconds invariant, documented in
`src/core/instrument/CLAUDE.md`). 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.
@@ -63,7 +63,7 @@ fix-now findings in those files must be remediated in Q-W0 itself.
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
rate change (`docs/ARCHIVE.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
@@ -165,7 +165,7 @@ fix-now findings in those files must be remediated in Q-W0 itself.
`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;
int64 SOURCE frames:** deliberate, settled source-timeline facts (`docs/ARCHIVE.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.
+16 -15
View File
@@ -1,19 +1,20 @@
# Capture tail — spec
Authoritative spec for the **capture-tail** feature: preserving reverb/release
tails that decay past the end of a capture range. The tickable milestone lives in
`PLAN.md` (Milestone T); this doc holds the full technical detail **and** the
product framing.
tails that decay past the end of a capture range. The tickable milestone's landed
history is in `docs/ARCHIVE.md` (Milestone T); this doc holds the full technical
detail **and** the product framing.
> **Why this doc carries the technical spec (not `CONTEXT.md`).** Every other
> pillar (capture M0M11, Design View, Multi-bank) keeps its authoritative
> technical spec as a `CONTEXT.md §` section and its *why* in a `docs/product/`
> note. Capture-tail is a rider on the already-shipped offline-render path
> (M3/M7), not a standalone pillar, and it is being specced without reopening
> `CONTEXT.md`. So the authoritative detail lands **here**, house-styled to match
> the CONTEXT specs; when the tail work lands, doc-keeper may fold the invariant
> deltas into `CONTEXT.md §Precision invariants` as landed history. Same standing
> discipline applies: **verify every REAPER API name/flag against
> **Why this doc carries the technical spec (not the architecture docs).** Every
> other pillar (capture M0M11, Design View, Multi-bank) keeps its authoritative
> technical spec as a per-directory `src/**/CLAUDE.md` section and its *why* in a
> `docs/product/` note. Capture-tail is a rider on the already-shipped
> offline-render path (M3/M7), not a standalone pillar, and it was specced without
> reopening the architecture spec. So the authoritative detail lands **here**,
> house-styled to match those specs; the landed invariant deltas are folded into
> root `CLAUDE.md` §Precision invariants and `src/core/capture/CLAUDE.md` /
> `src/shell/capture/CLAUDE.md` as landed history. Same standing discipline
> applies: **verify every REAPER API name/flag against
> `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use** — the flag values
> below are transcribed from that header (line numbers cited) and are not guesses.
@@ -252,7 +253,7 @@ held to bit-identical repeats.
- **Opt-in beyond the region.** The tail only ever adds audio past the range end
when a tail is explicitly requested (Auto or Manual). This is exactly the existing
invariant: *"no added silence unless a tail is explicitly requested"*
(`CONTEXT.md §Precision invariants`, `CLAUDE.md §Exact bounds`). Auto-trim
(`CLAUDE.md §Precision invariants §Exact bounds`). Auto-trim
strengthens it — the tail added is decay, not silence, and the silence past the
decay is trimmed off.
- **The null test uses NO tail.** The null-test / verify capture (M10) and any
@@ -274,8 +275,8 @@ held to bit-identical repeats.
the tail is **the track's own reverb/delay decay**, not the parent bus's. A track
with a reverb plugin captures that reverb's tail; a track feeding a folder reverb
does **not** capture the folder reverb's tail (that send is out of scope — and
note the pre-existing send-isolation caveat in `PLAN.md §Open questions`, which
the tail inherits unchanged, does not worsen). Correct and consistent.
note the pre-existing send-isolation caveat, which the tail inherits
unchanged, does not worsen). Correct and consistent.
- **Item scope:** item/take FX only, self-track + ancestors + master bypassed →
the tail is the **item/take FX decay only**. An item with a take reverb captures
its tail; the track's reverb does not ring into it. Correct.
+9 -7
View File
@@ -9,8 +9,10 @@ subdirectories) **without sacrificing runtime performance**, against a stated qu
>
> Bring the codebase "**into the realm of something I can stand to look at.**"
Its build roadmap lives in **PLAN.md §Phase Q** and its authoritative spec in
**CONTEXT.md §Phase Q**. This doc holds the *why* — the quality bar, the evidence base
Its build roadmap's landed history lives in **`docs/ARCHIVE.md`** ("Phase Q — Quality:
the decision record") and its authoritative spec now lives in root **`CLAUDE.md`**
(§Directory and namespace layout, §Performance guardrails, §Structural heuristics)
plus the per-directory `src/**/CLAUDE.md` files. This doc holds the *why* — the quality bar, the evidence base
(a grep-verified SOLID audit), the target directory/namespace shape grounded in the Vital
reference, and the numbered fork decisions.
@@ -321,10 +323,10 @@ re-namespaced, and split, not carried forward untouched into a tidier tree. "Som
look at" is partly shape (§2/§2b) and partly *functional soundness* (§2c).
Q-W0 is **not executed by product-designer** — this doc *defines the wave and its scope*; the audit
itself is staff-engineer/DSP work. The authoritative wave definition lives in **PLAN.md §Q-W0** and
its spec framing in **CONTEXT.md §"The pre-restructure audit wave (Q-W0)"**; this section is the
evidence-doc framing (the *why* and the smell taxonomy), matching how §2/§2b frame the structural
audits.
itself is staff-engineer/DSP work. The wave's landed history lives in **`docs/ARCHIVE.md`**
("Q-W0 fix-now remediations" and "Phase Q — Quality: the decision record"), and its findings live in
**`docs/product/code-quality-audit.md`**; this section is the evidence-doc framing (the *why* and
the smell taxonomy), matching how §2/§2b frame the structural audits.
### 2c.1 Audit surfaces (the named targets)
@@ -451,7 +453,7 @@ namespace of every header, splitting the four largest TUs). Meanwhile:
- **Phase L** has **L3** (VST editor + embed-strip restyle, gated on Phase S landing on dev)
still to land — it touches the Phase S draw shells (`reasampler_editor` / `reasampler_embed`),
which arrive on dev with Phase S. (L1/L2/L4/L5/L6/L7 have **already landed** — see
`COMPLETED.md`; the once-listed "L2 pending" is stale and has been corrected here.)
`docs/ARCHIVE.md`; the once-listed "L2 pending" is stale and has been corrected here.)
- **D2** is **functionally complete** (D2-W1..W3-B landed; the only open item — a per-track
lane-split panel indicator — is *explicitly deferred*, not a blocking residual). **M9** (slots)
is **abandoned** (Daniel, 2026-07-27) — will not be built. D2 is named in the gate only so a
+2 -1
View File
@@ -3,7 +3,8 @@
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
audit — `docs/product/code-organization.md` §2c.3; deliverable contract landed per `docs/ARCHIVE.md`
§Phase Q). 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:
+4 -4
View File
@@ -1,8 +1,9 @@
# Design View — product notes
Framing, rationale, and design-direction calls behind the **Design View** phase.
The tickable spec lives in `PLAN.md` (Phase D) and the authoritative technical
detail in `CONTEXT.md` (§Design View). This doc holds the *why* — the workflow
The tickable spec's landed history lives in `docs/ARCHIVE.md` (Phase D) and the
architecture detail in `src/core/view/CLAUDE.md` + `src/shell/view/CLAUDE.md`
(§Design View). This doc holds the *why* — the workflow
narrative, the N-mode reasoning, the screenset differentiation, and the
design-direction recommendations — so those don't clutter the build docs.
@@ -339,8 +340,7 @@ tooltip on the switch.
`docs/design/`), say so and it moves. Flagging because establishing the
directory is a project convention, not mine to assume silently.
2. **Phase namespace.** Proposed **Phase D** (a lettered namespace) rather than
M12 — see PLAN.md rationale. Confirm you're happy with letters for parallel,
non-capture phases.
M12. Confirm you're happy with letters for parallel, non-capture phases.
3. **SDK name correction.** The brief cited `B_SHOWINMCP`; the verified SDK flag
for mixer-panel visibility is **`B_SHOWINMIXER`** (SDK header line 2235). The
spec uses the verified name. Just flagging the discrepancy so it isn't a
+70 -56
View File
@@ -2,10 +2,11 @@
Framing for a **MIDI-triggered audio sampler** that plays back ReaSampler's captured
banks. This began as a discussion-shaping doc; with all forks now settled it has become
the **product framing behind a scoped phase**. Its build roadmap lives in **PLAN.md
§Phase S** and its authoritative spec in **CONTEXT.md §Phase S** — this doc holds the
*why* (the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, the settled
decision record).
the **product framing behind a scoped phase**. Its build roadmap lives in
**`docs/ARCHIVE.md` §Phase S** and its authoritative spec in
**`src/core/instrument/CLAUDE.md`** and **`src/shell/instrument/CLAUDE.md`** — this
doc holds the *why* (the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, the
settled decision record).
Status: framed by product-designer (2026-07-26), **revised 2026-07-27 (r11)**. r11 records the
**Sample-face recomposition** (Daniel's post-landing DAW pass, 2026-07-27): all linear sliders →
@@ -42,10 +43,11 @@ demoted to an opt-in Zones panel — see the r6 Addendum in §4. r6 also settles
S1S6 instrument: the product name **ReaSampler 9000** and the **"better than RS5K" UX
overhaul** (Phase S points S10S13) — see the r5 Addendum in §4. r4 (below) settled the four
residual forks D-A..D-D. The
"no PLAN.md footprint" era is **over** — with D-A through D-D settled (below), the
"no landed-roadmap footprint" era is **over** — with D-A through D-D settled (below), the
instrument was scoped into **Phase S** (codename Daniel's: "S" for Sampler, because "D"
collides with the existing Design View phase). **PLAN.md §Phase S is now the
authoritative roadmap; CONTEXT.md §Phase S is the authoritative spec.** This doc is the
collides with the existing Design View phase). **`docs/ARCHIVE.md` §Phase S now records
the landed roadmap; `src/core/instrument/CLAUDE.md` and `src/shell/instrument/CLAUDE.md`
are the authoritative spec.** This doc is the
framing/decision record they point back to. Prior revisions (a) established that a REAPER
*extension* cannot be a MIDI instrument, (b) corrected a material omission — REAPER's
**VST-host bridge**, which lets a VST3 plugin *hosted inside REAPER* call back into
@@ -410,8 +412,8 @@ it doesn't carry" — is unchanged. What the bridge settles is *where that mappi
between extension and instrument as **live shared `"reasampler"` state**, not a file one
writes and the other re-parses.
**What the current index carries** (from `bank_model`'s `Sample`, per CONTEXT.md §Data
model): id, display name, relative path, source range, channel count, sample rate,
**What the current index carries** (from `bank_model`'s `Sample`): id, display name,
relative path, source range, channel count, sample rate,
length, capture tempo, an **optional key**, peak/RMS/LUFS, content hash, tier,
provenance, timestamp. Notably it *already* has an optional key field and capture
tempo — the seeds of pitch-mapping are there.
@@ -426,8 +428,8 @@ tempo — the seeds of pitch-mapping are there.
to velocity zones).
- **Round-robin groups** (cycle through N samples on repeated same-note hits).
- **Loop points** (sustain loop start/end for held notes; sample-accurate,
zero-crossing-aware — CONTEXT already flags loop/zero-crossing handling as
day-one-relevant for wavetable material).
zero-crossing-aware — loop/zero-crossing handling is day-one-relevant for
wavetable material).
- **Amplitude envelope** (ADSR) and optionally filter/pitch envelopes.
- **Tuning/gain trim** per sample.
@@ -559,7 +561,7 @@ ReaSampler-native way to build it and it's assumed, not debated, going forward.
All four residual decisions are now called. Each is marked **SETTLED** with Daniel's
choice and the reasoning kept as the record of *why* — do not re-litigate. They are
scoped into **PLAN.md §Phase S** / **CONTEXT.md §Phase S**.
scoped into **`docs/ARCHIVE.md` §Phase S** / **`src/core/instrument/CLAUDE.md`**.
**D-A — SETTLED: bare Steinberg VST3 SDK + LICE editor (no JUCE).** *(The central fork.
§1a is the assessment that fed it. The sub-question — who draws the editor? — was the
@@ -649,7 +651,7 @@ After Phase S was scoped (D-A..D-D), Daniel set two further directions. These ar
**settled directions**, not open forks — specced as new Phase S points (S7S9), not
re-litigated. Recorded here per the doc's settled-decisions convention.
**D-E — Channel mode: mono | stereo, per-instance, bus-negotiated (→ PLAN.md S7).**
**D-E — Channel mode: mono | stereo, per-instance, bus-negotiated (→ `docs/ARCHIVE.md` §S7).**
Captures are often stereo; the current mono downmix is a Tier-0 simplification. The
engine gets a **per-instance channel-mode toggle (1 mono / 2 stereo)** that "works with
the REAPER audio bus automatically" — the VST3 declares/negotiates its output bus
@@ -662,8 +664,8 @@ Cross-mode policy: mono-source-in-stereo → dual-mono; stereo-source-in-mono
never a bank fact). Sequenced **first after the editor/embed work** because it touches the
engine Daniel smoke-tests.
**Ingest routes through the bank — "option 1"; the extension owns ingest (→ PLAN.md
S8 + S9).** Loading a sample into the sampler is **one gesture**: capture/import-into-bank
**Ingest routes through the bank — "option 1"; the extension owns ingest (→
`docs/ARCHIVE.md` §S8 + §S9).** Loading a sample into the sampler is **one gesture**: capture/import-into-bank
+ auto-assign to the active instance. The **extension owns ingest** (it has arrange
access, Media-Explorer access, and the drop-target surface on its own panels); the
**instrument stays a read-only bank consumer** — it never captures or imports. Sub-parts,
@@ -690,7 +692,7 @@ with the honest SDK reality verified against the vendored headers:
*The genuine spikes flagged (not decisions Daniel owes, just build-time unknowns):* the
ME merely-selected-file read (b), and the drop-onto-editor cross-artifact relay (c). Both
are honestly-flagged as spikes in PLAN.md S8, not promised.
are honestly-flagged as spikes in `docs/ARCHIVE.md` §S8, not promised.
### Addendum — product name + UX overhaul (Daniel, 2026-07-26, post-S1S6 DAW test)
@@ -700,7 +702,7 @@ open forks (the two flagged forks below are the only calls left to Daniel).
**The instrument's product name is `ReaSampler 9000`.** The extension stays **ReaSampler**
(capture + organization); the instrument is **ReaSampler 9000** (playback). Propagation is
a checklist item (PLAN.md §Phase S — product name; CONTEXT.md §Product name): the VST3
recorded in `docs/ARCHIVE.md` §Phase S — product name: the VST3
class **display name** string, the `IPlugView` editor title band (today "ReaSampler
Instrument"), the S6 embed-strip label, and the docs. **Compat guard (load-bearing):** the
**VST3 class UID must NOT change** — instances in saved projects key off it; a UID change
@@ -747,8 +749,9 @@ names the editor as the wound.
After the r5 UX-overhaul directive was specced (keymap-first S10), Daniel reframed the
workflow before S10 was implemented. This **revises S10** and settles S-NAME-1. Settled
directions, not open forks — recorded here per the doc's settled-decisions convention; PLAN.md
§S10 and CONTEXT.md §Phase S (workflow hierarchy) carry the spec.
directions, not open forks — recorded here per the doc's settled-decisions convention;
`docs/ARCHIVE.md` §S10 records what landed and `src/core/instrument/CLAUDE.md` (the
editor `ui/` modules) documents the current architecture.
**The reframe, verbatim (Daniel, 2026-07-26):** *"We need to think hard about the workflow
with this plugin. Have a giant list of 'item' blocks is visually useless. When the plugin is
@@ -806,8 +809,8 @@ partly on filename, fall back to keeping the filename and record that as shipped
Daniel directed a set of engine features for the sampler, specced as **new Phase S points
S15 (Trigger vs Gate) and S16 (pitch envelope)**. **The feature set is settled** — recorded
here per the doc's settled-decisions convention; PLAN.md §S15/S16 and CONTEXT.md §Sampling
modes carry the spec. Two forks are flagged with leans (S15-F1 choke, S15-F2 param
here per the doc's settled-decisions convention; `docs/ARCHIVE.md` §S15 / §S16 records what
landed and `src/core/instrument/CLAUDE.md` §Sampling modes documents the current spec. Two forks are flagged with leans (S15-F1 choke, S15-F2 param
granularity); the WDL question was resolved by inspection.
**Directive, verbatim (Daniel, 2026-07-26):** *"let's have product spec out some features
@@ -927,9 +930,10 @@ This reshapes S16 and **flips the r7 WDL verdict** on `WDL_SimplePitchShifter`.
`process` allocation; measure per-voice CPU + onset latency against the polyphony cap. Treat
S16's Preserve-engine point as the phase's next real DSP spike, not a thin envelope add-on.
**Where the spec lives:** PLAN.md §S16 (reshaped to "pitch engine modes + pitch envelope",
with forks S16-F1/F2 and the corrected WDL finding) and the S15 × S16 interaction note;
CONTEXT.md §Pitch engine modes — Varispeed vs Preserve + the corrected WDL surface finding.
**Where the spec lives:** `docs/ARCHIVE.md` §S16 (reshaped to "pitch engine modes + pitch
envelope", with forks S16-F1/F2 and the corrected WDL finding) and the S15 × S16
interaction note; `src/core/instrument/CLAUDE.md` §Sampling modes — Varispeed vs Preserve
+ the WDL surface finding.
### Addendum — VST channel isolation (Daniel, 2026-07-26)
@@ -972,9 +976,9 @@ with or right after the in-flight waves (S9 ext_keys, S15/S16 processor/editor)
channel's banks; stable-project + beta-VST = clean empty (not error); the S-NAME-1
rename/rebind test extends to the beta UID.
**Where the spec lives:** PLAN.md §S18; CONTEXT.md §VST3 channel identity — the UID pair + the
pairing surface. The pairing surface's data half is already load-bearing V4 machinery; S18
adds only the identity fork on top.
**Where the spec lives:** `docs/ARCHIVE.md` §S18; `src/shell/instrument/CLAUDE.md` §VST3
channel identity — the UID pair + the pairing surface. The pairing surface's data half is
already load-bearing V4 machinery; S18 adds only the identity fork on top.
---
@@ -986,8 +990,9 @@ good — but the two-view editor (today's "Browser" + "Zones" toggle) misallocat
the default window is undersized for a 1080p world, and the drop-a-capture-onto-FX gesture is
broken in practice. The directive: **make the one job — pick a capture, tune it, play it —
fast, easy, and fun. Style is a critical ingredient. No spreadsheet aesthetics.** These are the
`r9` calls. Authoritative spec: **CONTEXT.md §Phase S — editor view-model redesign (S-VIEW)**;
build roadmap: **PLAN.md §Phase S — editor view-model redesign**.
`r9` calls. Current architecture: **`src/core/instrument/CLAUDE.md`** and
**`src/shell/instrument/CLAUDE.md`**; landed record: **`docs/ARCHIVE.md` §Phase S — editor
view-model redesign**.
**The reference devices (the north star for control density).** Daniel named Ableton **Simpler**
and a Kilohearts/Phase-Plant **sampler group** as the composition targets. Both share one
@@ -1021,7 +1026,7 @@ grammar, and it is the grammar the redesign adopts:
loading a new one is a distinct act), not a three-way radio. *Why the reframe matters:* it
makes "I just want to play this capture" the zero-click default, and "I want a different one"
a single deliberate gesture, instead of making the user re-choose their whole stance every
time. See CONTEXT.md §S-VIEW for the precise navigation model.
time. See `docs/ARCHIVE.md` §S-VIEW-1 for the precise navigation model as landed.
2. **The Sample view earns the hero treatment; Browse gets ruthlessly cut.** Browse today
carries a waveform preview, root-note piano-roll, loop-point labels, a track-root message, and
@@ -1037,10 +1042,10 @@ grammar, and it is the grammar the redesign adopts:
3. **Two engineering prerequisites, framed but routed to implementation.** The **drop-to-FX bug**
(dropping a capture onto a track's FX chain does not instantiate + init ReaSampler 9000) and
the **undersized default window** are not design decisions — they are a bug and a one-line
default. Both are framed in CONTEXT.md §S-VIEW with the SDK reality swept (drop-to-FX: the S17
machinery is SDK-correct, so this is a *diagnosis* task, not a redesign; window size: the
`getSize`/`checkSizeConstraint` mechanism is verified), and both are flagged for
staff-engineer, not for a product fork.
default. Both are recorded landed in `docs/ARCHIVE.md` §S-VIEW-BUG-1 (drop-to-FX: the S17
machinery is SDK-correct, so this is a *diagnosis* task, not a redesign) and §S-VIEW-SIZE-1
(window size: the `getSize`/`checkSizeConstraint` mechanism is verified), and both were
flagged for staff-engineer, not for a product fork.
**New parameters this introduces (both instrument performance state, D-B — never bank facts):**
@@ -1064,7 +1069,7 @@ grammar, and it is the grammar the redesign adopts:
new top-level `previewVelocity` field), **not** the extension's `persist` project ext-state —
that module is REAPER-project-scoped and extension-owned, so it would make the level
project-global instead of per-instance and route an instrument concern through a bank-read-only
seam. See CONTEXT.md §S-VIEW for the round-trip and back-compat lift. This is what makes the
seam. See `docs/ARCHIVE.md` §S-VIEW-4 for the round-trip and back-compat lift as landed. This is what makes the
preview button *fun*: tap it hard or soft without reaching for a controller — and it remembers.
**Two visual components the redesign commits to:**
@@ -1101,8 +1106,10 @@ persisted fields is not a compat event; saved instances rebind and restore. And
(extended additively — `keyTrack` per-zone, `previewVelocity` per-instance via an envelope bump to
v6, both with back-compat defaults on read) are the same load-bearing core.
**Where the spec lives:** CONTEXT.md §Phase S — editor view-model redesign (S-VIEW); PLAN.md
§Phase S — editor view-model redesign. This Addendum is the *why*; those are the *what/how*.
**Where the spec lives:** `src/core/instrument/CLAUDE.md` (envelope overlay, key-tracking,
preview-velocity ownership) documents the current architecture; `docs/ARCHIVE.md` §Phase S
— editor view-model redesign records what landed (S-VIEW-1 through S-VIEW-10). This
Addendum is the *why*; those are the *what/how*.
---
@@ -1217,8 +1224,9 @@ for preview velocity — a different struct on a different version axis). Concre
the L1 kit, routing mouse through `velocity_curve`), gated on the foundation track and composing
with the S-VIEW-2 Sample face + S-VIEW-3 envelope-overlay work.
**Where the spec lives:** CONTEXT.md §Phase S — editor view-model redesign (S-VIEW), velocity-curve
sub-section; PLAN.md §Phase S — editor view-model redesign (S-VIEW-9/S-VIEW-10 + fork R10-F1). This
**Where the spec lives:** `src/core/instrument/CLAUDE.md` (the `velocity_curve` module, its
engine application point, and its ownership rules) documents the current architecture;
`docs/ARCHIVE.md` §S-VIEW-9 / §S-VIEW-10 records what landed (fork R10-F1 resolved). This
Addendum is the *why*; those are the *what/how*.
---
@@ -1273,16 +1281,20 @@ small labelled knobs in functional clusters** — and drives the **Wave B editor
same knob deck + button/popup — Sample and Zone already edit one storage site (S15-F2); the lean
is that they should speak one control grammar too. Daniel's call (it grows Wave B scope).
**What does NOT change (guardrails).** Zero engine/state/persistence change — this is a view
recomposition of *existing* controls; no new params, no component-state bump, VST3 class UID
unchanged. All drawing through the L1 kit by palette role; all layout/hit-test in new pure modules
**What does NOT change (guardrails).** Zero engine-behavior change — this is a view
recomposition of *existing* controls, with one additive exception: the new post-mixer master
gain persists as `masterGainLinear`, bumping `ComponentState` v7→v8 (back-compat: pre-v8 blobs
lift to unity gain); VST3 class UID unchanged. All drawing through the L1 kit by palette role;
all layout/hit-test in new pure modules
(`knob_deck`, `curve_popup` — mirrors of `action_bar`/`overflow_menu`); the knobs and the hero's
envelope nodes remain two surfaces on one param model (S-VIEW-F2's structural sync, untouched).
The full inventory contract (every landed element → its r11 home) is in the CONTEXT.md spec.
The full inventory of what landed (every element → its r11 home) is recorded in
`docs/ARCHIVE.md` §FB1 and §FB2.
**Where the spec lives:** CONTEXT.md §Phase S — editor view-model redesign (S-VIEW) → "The
Sample-face recomposition (r11)"; PLAN.md §Phase S — editor Wave B (S-VIEW-11/12/13 + forks
R11-F1/R11-F2). This Addendum is the *why*; those are the *what/how*.
**Where the spec lives:** `src/core/instrument/CLAUDE.md` (the `knob_deck`/`curve_popup`/
`master_gain` modules) documents the current architecture; `docs/ARCHIVE.md` §FB1 and §FB2
record what landed (S-VIEW-11/12/13 + forks R11-F1/R11-F2 resolved). This Addendum is the
*why*; those are the *what/how*.
---
@@ -1329,7 +1341,8 @@ Post-DAW-test directives (2026-07-26; see the "product name + UX overhaul" Adden
frameworks), DS-2 (Direction B "Neon Console" + Direction C's spectral keyboard strip), and
DS-3 (thorough panel layout) are all **SETTLED (2026-07-26)**. Framing + palette + the three
visual directions + forks: `docs/product/visual-design-language.md` (on `dev`); roadmap +
spec: **PLAN.md §Phase L + CONTEXT.md §Phase L** (on `dev`). **S10S13 build with the
spec: **`docs/ARCHIVE.md` §Phase L** (landed record) and **`src/core/ui/CLAUDE.md`**
(current architecture) (on `dev`). **S10S13 build with the
current drawing and adopt the L1 kit when it lands — not gated on Phase L.** Answers
Daniel's "the VST is dogshit / temple os / does Cockos have a toolkit" (2026-07-26,
post-S1S6 DAW test).
@@ -1350,15 +1363,16 @@ Post-DAW-test directives (2026-07-26; see the "product name + UX overhaul" Adden
S15-F1 (choke, held) / S15-F2 (param granularity, lean per-zone). Feature set settled;
the engine default is Daniel's fork.
**Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1S6 the original
dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded
UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10S13** the ReaSampler
9000 UX overhaul, **S15/S16** the Trigger-vs-Gate + pitch-engine-modes engine features);
**CONTEXT.md §Phase S** is the spec (seam-field semantics, scope contracts, the channel-mode
/ ingest / bank-generation / sampling-mode / pitch-engine contracts, the UX-overhaul spec,
the product-name convention, the pure/shell split, the WDL finding, the must-verify
SDK/bridge surfaces). This doc is the framing/decision record they point back to. The "no
PLAN.md footprint" era is over.
**Authoritative from here:** **`docs/ARCHIVE.md` §Phase S** is the landed roadmap (S1S6
the original dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier
1 → embedded UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10S13** the
ReaSampler 9000 UX overhaul, **S15/S16** the Trigger-vs-Gate + pitch-engine-modes engine
features); **`src/core/instrument/CLAUDE.md`**, **`src/shell/instrument/CLAUDE.md`**, and
**`src/core/wire/CLAUDE.md`** are the current spec (seam-field semantics, scope contracts,
the channel-mode / ingest / bank-generation / sampling-mode / pitch-engine contracts, the
UX-overhaul spec, the product-name convention, the pure/shell split, the WDL finding, the
must-verify SDK/bridge surfaces). This doc is the framing/decision record they point back
to. The "no landed-roadmap footprint" era is over.
---
+3 -2
View File
@@ -1,8 +1,9 @@
# Multi-bank — product notes
Framing, rationale, and design-direction calls behind the **Multi-bank** phase.
The tickable spec lives in `PLAN.md` (Phase B) and the authoritative technical
detail in `CONTEXT.md` (§Multi-bank). This doc holds the *why* — the workflow
The tickable spec's landed history lives in `docs/ARCHIVE.md` (Phase B) and the
architecture detail in `src/core/model/CLAUDE.md` + `src/shell/bank_ops/CLAUDE.md`
(§Multi-bank). This doc holds the *why* — the workflow
narrative, the pool-privilege reasoning, the movement semantics, and the
design-direction recommendations — so those don't clutter the build docs.
+4 -4
View File
@@ -1,10 +1,10 @@
# Provenance — product notes
Framing, rationale, and the dual-canvas reconciliation behind the reshaped
**Milestone 10 (provenance)**. The tickable spec lives in `PLAN.md` (M10); the
authoritative technical detail is `CONTEXT.md` (§Data model, §capture) plus this
note for the reconciliation calls. This doc holds the *why* and the open forks so
they don't clutter the build docs.
**Milestone 10 (provenance)**. The tickable spec's landed history is in
`docs/ARCHIVE.md` (M10); the architecture detail is in `src/core/model/CLAUDE.md`
and `src/shell/capture/CLAUDE.md` plus this note for the reconciliation calls.
This doc holds the *why* and the open forks so they don't clutter the build docs.
Status: **IMPLEMENTED (2026-07-26).** Settled 2026-07-23; landed 2026-07-26.
Reshaped from the old "provenance + null-test verify" M10. Two decisions were fixed
+17 -14
View File
@@ -2,24 +2,26 @@
Framing, rationale, and open forks behind the two missing removal capabilities:
**sample-remove** (a sample-level index verb) and **prune** (the file-lifecycle
path CONTEXT.md keeps forward-referencing but never scoped). The tickable spec
lives in `PLAN.md` (Phase B point B5 for remove; **Phase R** for prune) and the
authoritative technical detail in `CONTEXT.md` (§Sample removal, §Prune — file
lifecycle). This doc holds the *why* — the workflow, the guardrails, the
index-vs-file boundary, and the forks that need a Daniel decision.
path the spec kept forward-referencing but never scoped). The tickable spec's
landed history is in `docs/ARCHIVE.md` (Phase B point B5 for remove; **Phase R**
for prune) and the architecture detail lives in `src/core/model/CLAUDE.md` +
`src/shell/bank_ops/CLAUDE.md` (§Sample removal) and `src/core/reclaim/CLAUDE.md`
+ `src/shell/persist/CLAUDE.md` (§Prune — file lifecycle). This doc holds the
*why* — the workflow, the guardrails, the index-vs-file boundary, and the forks
that need a Daniel decision.
Status: framed by product-designer (2026-07-23); **all five forks settled by Daniel
(2026-07-24)** — R-A this-bank-primary, R-B batched REAPER undo points
(Phase-B-wide), R-C trash-preferred-with-unlink-fallback, R-D owned-file manifest
(seam lands early in Phase B / capture), R-E manual action + panel button. The
decisions are folded into the fork sections below and into the B5 / Phase R spec
prose in CONTEXT.md and the tickable points in PLAN.md.
decisions are folded into the fork sections below and into the B5 / Phase R
history in `docs/ARCHIVE.md` and the architecture docs above.
---
## The one boundary that governs everything: index vs. file
ReaSampler already draws a hard line, stated repeatedly in CONTEXT.md: **a bank
ReaSampler already draws a hard line: **a bank
operation touches the *index*, never the *file*.** Move, copy, evacuate, and
delete-bank are all index-only; files persist on disk "until prune." Every
removal capability below sits on exactly one side of that line, and keeping the
@@ -120,7 +122,7 @@ line. It is the verb Phase B forgot, not a new pillar.
---
## Prune — the file-lifecycle path CONTEXT.md kept promising
## Prune — the file-lifecycle path the spec kept promising
### What the user is doing
@@ -131,7 +133,7 @@ purpose. Over a long project the bank folder accumulates dead `.wav` files that
cost disk and clutter. **Prune is the reclaim pass**: "sweep the bank folder,
delete the files nothing references, tell me what you reclaimed."
This is the path CONTEXT.md forward-references in at least four places ("files
This is the path the spec forward-references in at least four places ("files
persist on disk until prune," "the capture/prune path reclaims it") but never
scopes. It is a real, promised capability with **no phase, no module, no point**
— a dangling reference the plan has to make good on.
@@ -141,8 +143,8 @@ scopes. It is a real, promised capability with **no phase, no module, no point**
ReaSampler already shipped this exact shape once. Design View's `view_mode_model`
has **`ViewModeModel::reconcile(liveGuids)`** — a pure function fed the live set
(the tracks that still exist), returning the residual membership entries to drop
(CONTEXT.md §Design View: "prunes orphaned snapshots on every toggle/load;
tolerates unknown/stale GUIDs (prune on reconcile)"). Prune is the **file-pool
(`src/core/view/CLAUDE.md`: "tolerates unknown/stale GUIDs (pruned on reconcile
via `ViewModeModel::reconcile(liveGuids)`)"). Prune is the **file-pool
mirror of that pure pattern**:
> `reconcile(liveGuids)` reconciles *membership entries* against *live tracks*.
@@ -224,7 +226,7 @@ Prune is **not** a Phase B point. Three reasons it earns its own lettered phase
1. **It is a different pillar.** Phase B is the *bank container* pillar
(index-only, non-destructive, above the file). Prune is the *file lifecycle*
pillar (the one path that deletes files). CONTEXT.md already names it as a
pillar (the one path that deletes files). The spec already named it as a
separate concern every time it says "the capture/**prune** path" — file
lifecycle is spoken of as its own thing, owned by neither the capture nor the
bank layer. Giving it its own phase matches how the spec already talks about it.
@@ -337,7 +339,8 @@ capture).** The book tracks the set of files it has created; prune reclaims
ext-state**, and Phase R's R1/R2 *consume* that manifest. The exact persistence
shape — a sibling ext-state key vs. folded into the `banks` blob — is a small
residual to settle at build; the **manifest-now decision is firm**. (Settled
2026-07-24; the up-front point is added to Phase B / the capture path in PLAN.md.)
2026-07-24; the up-front point is recorded in `docs/ARCHIVE.md` under Phase B /
the capture path.)
**Fork R-E — prune trigger: manual-only vs. offer-on-orphaning vs. periodic.
SETTLED: MANUAL ACTION + PANEL BUTTON.** Prune runs via a bindable manual action
+3 -3
View File
@@ -8,8 +8,8 @@ concrete and decidable:
2. **A beta side-channel** — so development can continue and a beta build run
*alongside* the stable one without the beta clobbering the release.
This doc holds the *why*, the forks, and a recommendation. When Daniel picks, the
tickable points land in `PLAN.md` and the deploy/build wiring hands off to dev-ops.
This doc holds the *why*, the forks, and a recommendation. The tickable points'
landed history is in `docs/ARCHIVE.md`; the deploy/build wiring hands off to dev-ops.
This is a framing note; it changes no source or CMake.
Status: framed by product-designer (2026-07-26); **all four forks settled by Daniel
@@ -54,7 +54,7 @@ Two sharp edges follow directly and recur throughout this note:
Both binaries reading/writing the same namespace on the same open project means a
**beta can read — and rewrite — a stable project's saved bank/view state.** Given
the forward-only migrations already in the design (legacy `bank_index` retired
after promotion; `banks` authoritative thereafter — CONTEXT.md §Multi-bank), a
after promotion; `banks` authoritative thereafter — `docs/ARCHIVE.md` §Phase B), a
beta that writes a newer schema into a project a user then reopens in stable is a
real corruption path, not a theoretical one.
+8 -7
View File
@@ -16,11 +16,12 @@ written after Daniel DAW-tested the S1S6 instrument:
revised 2026-07-26 (Daniel)** from the single electric-cyan accent to a **three-accent
pastel system** (primary/secondary/tertiary = pastel lime green / pastel teal / pastel
purple) — see §6. (A stylish-font upgrade was considered and **declined** by Daniel — no
font bundling/redistribution; the kit keeps its current cached-font face.) The build points it feeds live in
**PLAN.md §Phase L** (**L1** the shared LICE drawing kit, **L2** the dock-panel layout
redesign, **L3** the VST editor + embed-strip restyle) and **CONTEXT.md §Phase L** (the
design-system spec). Toolkit facts below are **verified against the vendored `vendor/WDL`
tree**, not lore.
font bundling/redistribution; the kit keeps its current cached-font face.) The build points it
fed have landed; their history is in **`docs/ARCHIVE.md`** ("Phase L — Look-and-feel", **L1** the
shared LICE drawing kit, **L2** the dock-panel layout redesign, **L3** the VST editor +
embed-strip restyle) and the architecture now lives in **`src/core/ui/CLAUDE.md`** /
**`src/shell/panel/CLAUDE.md`** (the design-system implementation). Toolkit facts below are
**verified against the vendored `vendor/WDL` tree**, not lore.
> **Phase-boundary note (2026-07-26).** This look-and-feel effort was originally drafted
> as Phase S points **S0-DS** (the shared kit) and **S14** (the panel refresh). It was
@@ -497,7 +498,7 @@ within the pastel intent — is one file.
---
## 5. The build shape (feeds PLAN.md / CONTEXT.md §Phase L)
## 5. The build shape (landed history in `docs/ARCHIVE.md` §Phase L)
Three points and their sequencing.
@@ -547,7 +548,7 @@ draw it.
affordances, per-selection **move / copy / remove** sample menu.
- **Prune** button (R-E) — the byte-deleting action, `warn`-colored.
*M11 adds (dev PLAN.md M11 — merging now):*
*M11 adds (landed — `docs/ARCHIVE.md` Milestone 11):*
- **Action trigger buttons** — clickable buttons that fire the capture + provenance
action family directly (capture item / capture track scopes, re-capture from source,
resample-and-mute-source, batch capture, conform-on-insert, insert-at-cursor, drag-out,
+35
View File
@@ -0,0 +1,35 @@
# src/app — REAPER extension entry point
## Scope
Contains only `main.cpp`. Since the Phase Q hoists (Q-W3 onward), this TU is ONLY
pointers + entry + dispatch — the actual capture/panel/persist/action orchestration
lives in `shell/`. `main.cpp` owns: receiving REAPER's dispatch struct
(`ReaperPluginEntry`), resolving the REAPER API function pointers
(`REAPERAPI_LoadAPI`), the globals other files reference via `extern` (`g_hInst`,
`g_rec`), the `ReaSamplerSession` instance, its own bindable-action family via the
Q-W6 data-driven registration table (`shell/actions/action_registry`), and invoking
the other action families' (`design_view` / `bank` / `ingest`) own
register/handle/unregister triples at load and unload.
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.
See root `CLAUDE.md`'s "REAPER extension contract" section for the full four-step
action-registration contract (`command_id` / `gaccel` / `hookcommand` / unload
mirror-unregister) that both this file's own action-table rows and the other
families' register/handle/unregister triples follow.
## Modules
- `main.cpp` — the REAPER extension's entry point and the sole `REAPERAPI_IMPLEMENT` TU; see root `CLAUDE.md`'s "REAPER extension contract" section for the registration contract this file implements.
## Gotchas
- This is intentionally a thin TU post-Phase-Q. Adding a new bindable action to
`main.cpp`'s own family means adding one row to its `ActionTableRow` table and a
flat handler function — do not hand-roll a parallel register/hookcommand/unregister
mechanism alongside the table.
- Never let a second `.cpp` define `REAPERAPI_IMPLEMENT` — that would double-allocate
the global REAPER API function pointers.
+90 -181
View File
@@ -1,25 +1,16 @@
// main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers.
//
// This file is the entire contract between REAPER and the extension:
// * At startup REAPER scans UserPlugins/ for reaper_*.dll|dylib|so and
// dlopen()s each one, then looks up ONE exported symbol: ReaperPluginEntry
// (that name is produced by the REAPER_PLUGIN_ENTRYPOINT macro).
// * REAPER calls it, handing over `rec` — a small dispatch struct.
// - rec->GetFunc(name) resolves any REAPER API function to a pointer
// - rec->Register(what,ptr) plugs OUR callbacks into REAPER
// * REAPERAPI_LoadAPI(rec->GetFunc) walks reaper_plugin_functions.h and
// fills in every global function pointer (ShowConsoleMsg, InsertMedia...).
// REAPER dlopen()s reaper_*.dll|dylib|so from UserPlugins/ and calls the exported
// ReaperPluginEntry, handing over `rec` (rec->GetFunc resolves API pointers,
// rec->Register plugs our callbacks in). Exactly ONE .cpp defines
// REAPERAPI_IMPLEMENT (this one) — that allocates storage for the global API
// pointers every other TU gets `extern`. Never let a second TU define it.
//
// 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; 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.
// This TU is ONLY pointers + entry + dispatch. Its own action family registers
// through the data-driven table below (buildMainActionTable + action_registry) —
// adding a bindable action means adding ONE row and its handler function (OCP). The
// design_view / bank / ingest families keep their own register/handle/unregister
// triples, called from entry.
#define REAPERAPI_IMPLEMENT
#include "reaper_plugin.h"
@@ -32,9 +23,9 @@
#include "core/capture/render_settings.h" // captureActionTable
#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)
#include "shell/actions/design_view_actions.h" // Design View action family (D4; Q-W4 home)
#include "shell/actions/action_registry.h" // the registration table
#include "shell/actions/bank_actions.h" // multi-bank action family
#include "shell/actions/design_view_actions.h" // Design View action family
#include "shell/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
@@ -46,20 +37,13 @@
namespace capture = reasampler::capture;
// 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
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr;
reaper_plugin_info_t* g_rec = nullptr;
// 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 command-id SUFFIXES: kept ONLY to mirror-unregister on unload so a user's
// stale keybindings are cleaned up. Never re-register these. The four-mode WET ids,
// the removed master scope/realtime actions, and the removed per-action tail variants
// (tail is now a panel toggle, not a paired action).
static const char* const kRetiredCaptureCmdSuffixes[] = {
"CAPTURE_TRACKS_WET",
"CAPTURE_ITEMS_WET",
@@ -70,35 +54,30 @@ static const char* const kRetiredCaptureCmdSuffixes[] = {
"CAPTURE_TRACK_TAIL",
};
// 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
// the book back into the active project's ext state (the `banks` key) so it travels
// with the .rpp. Replaces the M3 session-only g_bank.
// 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() (resolves to the active bank's index), and we serialize the book
// back into the active project's ext state (the `banks` key) so it travels with the .rpp.
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.
// Each handler is a thin stateless routing shim: (session, per-row arg) -> the
// action body in shell/capture/ or shell/panel/, existing only so 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).
// `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.
static void RunCaptureScopeRow(int arg) {
capture::RunCapture(g_session,
capture::captureActionTable()[static_cast<std::size_t>(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.
// `arg` != 0 is the EXPLICIT conform-to-project-tempo opt-in (never silent); 0
// inserts at native length.
static void RunInsertSelected(int arg) {
capture::RunInsertSelected(g_session, arg != 0);
}
@@ -108,24 +87,15 @@ static void RunCaptureRealtime(int) { capture::RunCaptureRealtimeTrack(g_sessi
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).
// On-demand only — 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.
// ONE row per bindable action this TU owns: FOREVER-STABLE id suffix, Actions-list
// phrase, handler, per-row arg. Registration, hookcommand dispatch, and the unload
// mirror-unregister all iterate this data. The capture scope rows come first,
// sourced from the pure captureActionTable() taxonomy; the rest are this TU's singles.
static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
using reasampler::ActionTableRow;
std::vector<ActionTableRow> rows;
@@ -135,40 +105,32 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
rows.push_back(ActionTableRow{cap[i].commandSuffix, cap[i].descriptionPhrase,
&RunCaptureScopeRow, static_cast<int>(i)});
// M5: show/hide the docked bank panel (display-only; never captures/inserts).
// 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.
// Two variants differing ONLY in InsertOptions — native length vs 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.
// 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).
// Realtime sibling of the offline CAPTURE_TRACK scope, 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;
@@ -180,74 +142,52 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
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.
// 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.
// torn down/restored before session.poll() reacts to that switch. LOAD-BEARING:
// the idle fast-path is a SINGLE POINTER TEST — drive only when a capture is live.
if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session);
g_session.poll();
// D4 reapply-on-open glue. persist stays MODEL-ONLY (it loads the saved view
// model but deliberately does NOT apply visibility — that would couple persist
// to the view shell). Instead poll() raises a one-shot load signal; here — the
// integration layer that already drives both persist and the view shell — we
// drain it and reapply the SAVED active mode's visibility/processing so opening a
// project saved in Design mode parks the Arrange tracks automatically, no manual
// toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks
// skip it. proj = nullptr -> REAPER's active project (the one poll just loaded).
//
// The SAME signal re-arms the bank panel's new-content detector: a load must
// re-baseline the detector against the just-loaded project's content so its
// pre-existing tracks are never mis-detected as "new" and mass-tagged into the
// active mode (the reload-mis-tag bug). Notify BEFORE the reapply so the detector's
// re-arm and the model restore ride the one authoritative load event.
// persist stays MODEL-ONLY (loads the saved view model but does not apply
// visibility, to avoid coupling persist to the view shell); poll() raises a
// one-shot load signal that we drain here to reapply the SAVED active mode so a
// project saved in Design mode parks Arrange tracks automatically. The same
// signal re-arms the bank panel's new-content detector — notified BEFORE the
// reapply so re-arm and model restore ride the one load event (otherwise
// pre-existing tracks can be mis-detected as "new" and mass-tagged).
if (g_session.consumeLoadSignal()) {
reasampler::bankPanelNotifyProjectLoaded();
// Reconcile the restored lane-ownership index against the live project's lanes
// FIRST (via REAPER's durable P_LANENAME — the cross-session source of truth),
// so a saved lane-split project's managed/manual classification is correct
// before the active mode's lane visibility is reapplied. Never re-mints, never
// mass-tags — it only records managed ownership recovered from lane names.
// Reconcile lane ownership against the live project's lanes (P_LANENAME,
// the cross-session source of truth) BEFORE reapplying visibility. Never
// re-mints, never mass-tags.
reasampler::reconcileManagedLanes(g_session.view(), nullptr);
reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
}
// Reflect a live bank change (capture / project load) in the docked grid.
// Cheap when the bank is unchanged (a fingerprint compare); repaints only on
// an actual change. No-op when the panel is closed.
reasampler::bankPanelRefresh();
reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed
}
// --- projectconfig hook: reload the session on undo/redo (R-B) ---------------
// A Ctrl-Z / Ctrl-Shift-Z rolls back / forward the "reasampler" project ext state on
// disk but keeps the SAME project identity (ReaProject*/GUID/.rpp path), so the timer's
// identity poll reads it as NoOp and never re-reads ext state — the in-memory book/view
// would stay stale until close+reopen. REAPER's projectconfig extension fires
// BeginLoadProjectState on every project-state (re)load, INCLUDING an undo/redo restore
// (isUndo == true for both). We hook it to drive a session reload.
// A Ctrl-Z/Ctrl-Shift-Z rolls back/forward the "reasampler" project ext state on disk
// but keeps the SAME project identity, so the timer's identity poll never re-reads
// ext state on undo/redo — the in-memory book/view would stay stale until
// close+reopen. REAPER's projectconfig fires BeginLoadProjectState on every
// project-state (re)load INCLUDING undo/redo (isUndo == true for both); we hook it.
//
// TIMING (the crux): BeginLoadProjectState is documented (reaper_plugin.h ~1203) as
// firing BEFORE any state restore. Reading GetProjExtState synchronously here would
// return the PRE-undo value. So we do NOT read here — we raise a one-shot reload request
// (g_session.requestReload()) that OnTimer's poll() drains on the NEXT tick, by which
// point REAPER has finished restoring the <EXTSTATE> block and GetProjExtState returns
// the POST-undo value. Deterministic, event-driven — NOT ext-state content polling.
//
// GATED ON isUndo: a normal project open also fires BeginLoadProjectState (isUndo=false);
// we ignore that here so a normal open flows solely through the timer's identity-transition
// Load path (no double load). Only undo/redo (isUndo=true) requests the reload.
// TIMING: BeginLoadProjectState fires BEFORE any state restore, so reading
// GetProjExtState here would return the PRE-undo value. Instead we raise a one-shot
// reload request that OnTimer's poll() drains on the NEXT tick, once REAPER has
// finished restoring the <EXTSTATE> block. A normal project open also fires this
// (isUndo=false); ignored here so a normal open flows solely through the timer's
// identity-transition Load path (no double load).
static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/)
{
if (isUndo)
g_session.requestReload();
}
// ProcessExtensionLine / SaveExtensionConfig are intentional no-ops: ReaSampler stores
// its state via project EXT STATE (SetProjExtState/GetProjExtState under "reasampler"),
// which REAPER persists in its own <EXTSTATE> RPP block — NOT via this extension's own
// project lines. We register the struct ONLY for the BeginLoadProjectState undo/redo
// notification. Returning false from ProcessExtensionLine means "not our line" so REAPER
// keeps dispatching (we claim none). SaveExtensionConfig writes nothing.
// Intentional no-ops: ReaSampler stores state via project EXT STATE, not this
// extension's own project lines. The struct is registered ONLY for the
// BeginLoadProjectState undo/redo notification.
static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/,
bool /*isUndo*/, project_config_extension_t* /*reg*/)
{
@@ -257,7 +197,6 @@ static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*
static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/,
project_config_extension_t* /*reg*/)
{
// Nothing to write: our data rides in ext state, not project lines.
}
// Storage must outlive registration — REAPER holds this pointer until we unregister it.
@@ -268,19 +207,15 @@ static project_config_extension_t g_projectConfig{
nullptr, // userData
};
// REAPER calls this for EVERY action fired anywhere; claim only our own id,
// 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.
// REAPER calls this for EVERY action fired anywhere; claim only our own id, return
// false otherwise so REAPER keeps looking. This TU's own family dispatches through
// the registration table; the other families claim their own ids after it.
static bool OnHookCommand(int command, int /*flag*/)
{
if (command == 0) return false;
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;
// Multi-bank action family (B3). Same contract: claims only its own ids.
if (reasampler::bankHandleCommand(command)) return true;
// S8 ingest action family (Media-Explorer import). Same contract.
if (reasampler::ingestHandleCommand(command)) return true;
return false;
}
@@ -299,39 +234,30 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
{
if (!rec)
{
// rec == nullptr => REAPER is UNLOADING us. Mirror-unregister every
// callback with the same strings prefixed '-' (per the contract).
// rec == nullptr => REAPER is UNLOADING us.
if (g_rec)
{
// Abort any in-flight realtime capture FIRST, 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. Commit whatever was captured (best effort) before tearing down.
// still live, so we never leave a temp track, an armed track, or an
// altered transport/cursor in the user's project on unload.
capture::AbortRealtimeCaptureForUnload(g_session);
g_rec->Register("-timer", (void*)&OnTimer);
g_rec->Register("-projectconfig", (void*)&g_projectConfig);
g_rec->Register("-toggleaction", (void*)&OnToggleAction);
g_rec->Register("-hookcommand", (void*)&OnHookCommand);
// Tear down the Design View action family (D4) — mirror-unregisters each
// gaccel + command_id with '-'-prefixed strings. After the hook is gone.
reasampler::designViewUnregisterActions(g_rec);
// Tear down the multi-bank action family (B3) — same mirror-unregister.
reasampler::bankUnregisterActions(g_rec);
// Tear down the S8 ingest action family — same mirror-unregister.
reasampler::ingestUnregisterActions(g_rec);
// 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).
// This TU's own family, reverse table order; each '-command_id'
// re-presents the SAME interned 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 its own.
// for them this session).
for (const char* suffix : kRetiredCaptureCmdSuffixes)
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).
// Before dropping the API pointers: DockWindowRemove/DestroyWindow need them live.
reasampler::bankPanelShutdown();
g_rec = nullptr;
return 0;
@@ -349,13 +275,10 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
g_hInst = hInstance;
g_rec = rec;
// 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.
// Point the bank panel at the live session BEFORE registering its action, so a
// toggle firing immediately has a session to read. Does not open the window.
reasampler::bankPanelInit(&g_session);
// 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<reasampler::ActionTableRow> rows = buildMainActionTable();
reasampler::registerActionTable(rec, rows.data(), rows.size());
@@ -367,37 +290,23 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
if (g_cmdToggleBankPanel)
rec->Register("toggleaction", (void*)&OnToggleAction);
// 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
// hookcommand below routes them via designViewHandleCommand. Registered before
// the hook so every id is minted first.
// Each family mints its own command_id + gaccel, shares g_session, and is routed
// by the same hookcommand below. Registered before the hook so every id is
// minted first.
reasampler::designViewRegisterActions(rec, &g_session);
// Register the multi-bank action family (B3): create/rename/delete/evacuate bank,
// activate (cycle + pool), move/copy selected samples to a bank, and the two
// full-height layout toggles. Shares g_session with the Design View family; routed
// by the same hookcommand via bankHandleCommand. Registered before the hook.
reasampler::bankRegisterActions(rec, &g_session);
// 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 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 (table + the three families).
// Registered once, after all command ids are minted.
rec->Register("hookcommand", (void*)&OnHookCommand);
// Drive project-load / Save-As detection (M4 persist). The timer polls the
// active project each tick; on a project load it reloads the bank from ext
// state, on a Save-As it relocates the bank folder under the new .rpp.
// Drives project-load / Save-As detection: the timer polls the active project
// each tick; on a project load it reloads the bank from ext state, on a Save-As
// it relocates the bank folder under the new .rpp.
rec->Register("timer", (void*)&OnTimer);
// Register the projectconfig hook so an UNDO/REDO state restore reloads the
// session's book + view from the restored ext state (R-B). The timer's identity
// poll cannot see an undo (same project identity), so this hook owns undo/redo; it
// requests a deferred reload that the next timer tick drains (see the hook comment).
// An UNDO/REDO state restore reloads the session's book + view from the restored
// ext state. The timer's identity poll cannot see an undo (same project
// identity), so this hook owns it (see OnBeginLoadProjectState).
rec->Register("projectconfig", (void*)&g_projectConfig);
return 1; // success — REAPER keeps us loaded
+13
View File
@@ -0,0 +1,13 @@
# src/core/audio — pure audio-data math
## Scope
Pure, REAPER-free audio-data math with no dependence on REAPER's own peak-cache
API. Currently one module: waveform min/max bin computation from raw PCM. Does
**not** include: LICE waveform drawing (`draw_kit`, `shell/panel`), the editor's
waveform/marker geometry (`waveform_view`, `core/instrument/ui`), or PCM
decoding itself.
## Modules
- `peaks` — waveform min/max bin computation from raw PCM; does not depend on REAPER's peak API.
+13 -28
View File
@@ -5,14 +5,11 @@
#include <cmath>
#include <cstdint>
// peaks implementation.
// peaks — pure implementation. See peaks.h.
//
// One linear pass per channel. The frame->bin partition is computed with integer
// arithmetic so it is exact for any frameCount / binCount pairing: bin b owns the
// half-open frame span [b*frameCount/binCount, (b+1)*frameCount/binCount). That
// span formula distributes the remainder deterministically (earlier bins get the
// extra frames) with no rounding drift and no dropped tail — the last bin's end is
// always exactly frameCount.
// One linear pass per channel. Frame->bin partition uses integer arithmetic so it's exact for
// any frameCount/binCount pairing: bin b owns [b*frameCount/binCount, (b+1)*frameCount/binCount)
// — earlier bins absorb the remainder, no rounding drift, no dropped tail.
namespace reasampler::audio {
@@ -22,11 +19,10 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
std::size_t binCount) {
Envelope envelope(channelCount);
if (channelCount == 0) {
return envelope; // no channels -> no envelopes
return envelope;
}
// Never read past what the buffer actually holds, even if the caller's
// frameCount overstates the buffer (defensive: no OOB on a short buffer).
// Never read past what the buffer actually holds, even if frameCount overstates it.
const std::size_t availableFrames = interleaved.size() / channelCount;
const std::size_t frames = std::min(frameCount, availableFrames);
@@ -35,14 +31,10 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
bins.assign(binCount, MinMax{}); // empty/degenerate bins default to {0,0}
for (std::size_t b = 0; b < binCount; ++b) {
// Half-open frame span for this bin: [b*frames/binCount, (b+1)*frames/binCount).
// Guard against size_t overflow in b*frames and (b+1)*frames: binCount is
// caller-controlled and unbounded, so when b >= SIZE_MAX/frames either
// multiplication could wrap. Any such bin is unreachable in practice
// (allocating that many MinMax entries would OOM first), but we guard
// explicitly to eliminate UB.
// Guard b*frames / (b+1)*frames overflow: binCount is caller-controlled and
// unbounded. Unreachable in practice (would OOM first) but guarded to avoid UB.
if (frames > 0 && b >= SIZE_MAX / frames) {
continue; // b*frames or (b+1)*frames would overflow; span is empty
continue;
}
const std::size_t begin = (b * frames) / binCount;
const std::size_t end = ((b + 1) * frames) / binCount;
@@ -69,21 +61,17 @@ MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col) {
const int nbins = static_cast<int>(bins.size());
if (columnCount <= 0 || nbins == 0) return MinMax{};
// Clamp col to [0, columnCount-1].
if (col < 0) col = 0;
if (col >= columnCount) col = columnCount - 1;
// Half-open bin range for this column, mirroring computeEnvelope's exact partition.
// 64-bit products: col*nbins can exceed int range for a large oversampled envelope
// (same overflow discipline as computeEnvelope's frame-span arithmetic above).
// Half-open bin range for this column, mirroring computeEnvelope's partition. 64-bit
// products: col*nbins can exceed int range for a large oversampled envelope.
const std::int64_t begin64 = (static_cast<std::int64_t>(col) * nbins) / columnCount;
const std::int64_t end64 =
(static_cast<std::int64_t>(col) + 1) * nbins / columnCount;
// col <= columnCount-1 guarantees begin64 <= (columnCount-1)*nbins/columnCount < nbins.
const int colBinBegin = static_cast<int>(begin64);
// When the column spans no full bin (more columns than bins), use the enclosing bin
// so no column is left empty.
// When the column spans no full bin (more columns than bins), use the enclosing bin.
const int scanEnd = (end64 > begin64) ? static_cast<int>(end64) : colBinBegin + 1;
const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins;
@@ -102,14 +90,11 @@ std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
AudioSample linearThreshold) {
if (channelCount == 0) return kNoFrameAboveThreshold;
// Clamp to what the buffer actually holds — a caller frameCount that overstates
// the buffer must never read past the end (mirror of computeEnvelope's guard).
const std::size_t availableFrames = interleaved.size() / channelCount;
const std::size_t frames = std::min(frameCount, availableFrames);
if (frames == 0) return kNoFrameAboveThreshold;
// Scan backward: the first frame (from the end) whose loudest channel exceeds the
// threshold is the last audible frame. `f` runs frames..1 so `f-1` never wraps.
// Scan backward; `f` runs frames..1 so `f-1` never wraps.
for (std::size_t f = frames; f > 0; --f) {
const std::size_t frame = f - 1;
const std::size_t base = frame * channelCount;
+42 -76
View File
@@ -1,32 +1,20 @@
#pragma once
// peaks — waveform min/max envelope (thumbnail) computation from raw interleaved
// PCM. We compute our own thumbnails from the captured file rather than depending
// on REAPER's peak API: we own the file format, so this is simpler, testable, and
// dependency-free. A future bank panel (M5) calls this at whatever bin resolution
// the panel width dictates and draws one min/max envelope per channel.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. Builds and unit-tests without REAPER.
// peaks — waveform min/max envelope (thumbnail) computation from raw interleaved PCM. We compute
// our own thumbnails rather than depending on REAPER's peak API: we own the file format, so this
// is simpler, testable, and dependency-free.
#include <cstddef>
#include <vector>
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
// peaks consumes that directly with no lossy conversion. If a capture ever lands
// as a different depth, the caller converts to float at the boundary — the
// thumbnail core stays single-typed.
//
// NAMED AudioSample, not `Sample`: `reasampler::Sample` is already bank_model's
// metadata struct. A `using Sample = float` here would collide at namespace scope
// wherever both headers are visible (the bank_panel module includes both). The
// audio-domain name also reads more precisely — this is one PCM sample value.
// REAPER's native audio buffer format (interleaved 32-bit float), consumed directly with no
// lossy conversion. Named AudioSample rather than Sample to avoid colliding with bank_model's
// metadata struct of the same short name.
using AudioSample = float;
// One bin of a channel's envelope: the extremes of every sample that fell in it.
// min <= max always. For an empty bin (more bins than frames), both are 0.
// One bin's extremes across the samples that fell in it. min <= max always; an empty bin
// (more bins than frames) is {0, 0}.
struct MinMax {
AudioSample min = 0.0f;
AudioSample max = 0.0f;
@@ -37,83 +25,61 @@ struct MinMax {
// One channel's envelope: exactly `binCount` bins, in time order.
using ChannelEnvelope = std::vector<MinMax>;
// Per-channel envelopes: outer index is channel (channelCount entries, order
// preserved — never mixed or folded), inner is that channel's bins.
// Per-channel envelopes: outer index is channel (channelCount entries, order preserved — never
// mixed or folded), inner is that channel's bins.
using Envelope = std::vector<ChannelEnvelope>;
// Computes a per-channel min/max envelope from interleaved PCM.
//
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...].
// Size must be >= frameCount * channelCount; extra is ignored.
// channelCount channels per frame (the stride). Each channel is enveloped
// INDEPENDENTLY — no averaging, no stereo fold (precision
// invariant: channel count preserved).
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. Size must be
// >= frameCount * channelCount; extra is ignored.
// channelCount channels per frame (the stride). Each channel is enveloped INDEPENDENTLY — no
// averaging, no stereo fold (channel count is preserved end to end).
// frameCount frames (samples-per-channel) to consider.
// binCount requested bins per channel. Honored exactly for any frameCount.
//
// Frame->bin partition: frames are split into `binCount` contiguous spans as
// evenly as possible; when frameCount does not divide evenly, the remainder is
// spread one-frame-per-bin across the earliest bins (ceil/floor split), so the
// tail is never dropped and no bin reads out of bounds. When binCount > frameCount
// the trailing empty bins are {0, 0}.
// Frame->bin partition: frames split into `binCount` contiguous spans as evenly as possible;
// when frameCount doesn't divide evenly, the remainder spreads one-frame-per-bin across the
// earliest bins, so the tail is never dropped and no bin reads out of bounds.
//
// Defined behavior for degenerate input (no UB, no throw):
// binCount == 0 -> per channel: an empty bin vector.
// channelCount == 0 -> an empty envelope (no channels).
// frameCount == 0 -> per channel: binCount bins, all {0, 0}.
// Degenerate input (no UB, no throw): binCount == 0 -> empty bin vector per channel;
// channelCount == 0 -> empty envelope; frameCount == 0 -> binCount bins, all {0, 0}.
Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
std::size_t channelCount,
std::size_t frameCount,
std::size_t binCount);
// The merged min/max for display column `col` (0-based, of `columnCount` total columns)
// of a pre-computed per-bin ChannelEnvelope: the true extremes of every bin that projects
// to that column. This is the display-side collapse of an envelope computed at HIGHER
// resolution than the drawn width (oversampled bins -> per-pixel-column min/max), so a
// steep transient whose adjacent bins hold disjoint spans (e.g. {0.9,1.0} then
// {-1.0,-0.9}) renders as one gap-free vertical span instead of two separated dots.
// Merged min/max for display column `col` (0-based, of `columnCount` total) of a pre-computed
// ChannelEnvelope the true extremes of every bin projecting to that column. This is the
// display-side collapse when the envelope was computed at a higher resolution than the drawn
// width, so a steep transient split across adjacent bins (e.g. {0.9,1.0} then {-1.0,-0.9})
// renders as one gap-free span instead of two separated dots.
//
// Bin->column mapping mirrors computeEnvelope's half-open partition:
// column col owns bins [col*nbins/columnCount, (col+1)*nbins/columnCount).
// When that range is empty (more columns than bins), the enclosing bin
// (col*nbins/columnCount) fills the column — so no column is left empty and no bin is
// ever dropped. columnCount <= 0 or bins.empty() returns {0, 0}; `col` is clamped to
// [0, columnCount-1]. Pure.
// Bin->column mapping mirrors computeEnvelope's half-open partition: column col owns bins
// [col*nbins/columnCount, (col+1)*nbins/columnCount). When that range is empty (more columns
// than bins), the enclosing bin fills the column instead. columnCount <= 0 or bins.empty()
// returns {0, 0}; col is clamped to [0, columnCount-1].
MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col);
// Sentinel returned by lastFrameAboveThreshold when NO frame in the scanned range
// peaks above the threshold (pure silence at that level). SIZE_MAX is unambiguous:
// no valid frame index can equal it (a real index is < frameCount <= SIZE_MAX for
// any allocatable buffer), so the caller tests `== kNoFrameAboveThreshold` cleanly.
// Sentinel for "no frame in the scanned range peaked above threshold". SIZE_MAX is unambiguous
// since no real frame index can reach it.
inline constexpr std::size_t kNoFrameAboveThreshold =
static_cast<std::size_t>(-1);
// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (the max
// absolute value across all channels of that frame — NO stereo fold, just the
// loudest channel that frame) exceeds `linearThreshold`, returning that frame index.
// Returns kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input).
// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (max |sample| across
// all channels of that frame — no stereo fold) exceeds `linearThreshold`. Returns
// kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input).
//
// This is the boundary primitive behind the realtime tail's decay-scan trim
// (docs/product/capture-tail.md §The realtime path): the recorded tail window is
// scanned back from the end for the last frame still above -72 dB, and the file is
// truncated one frame past it. Deliberately a separate primitive from
// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail),
// this answers "the last frame above a level" (a boundary). Bending the bin-oriented
// envelope to a frame-exact boundary question is a worse fit (spec §option a).
// This is the boundary primitive behind the realtime tail's decay-scan trim (see
// docs/product/capture-tail.md): the recorded tail is scanned back from the end for the last
// frame still above -72 dB, and the file truncated one frame past it. Deliberately separate from
// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail), this answers
// "the last frame above a level" (a boundary); bending a bin-oriented envelope to a frame-exact
// question is a worse fit.
//
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...].
// Must hold >= frameCount * channelCount; extra is ignored, and a
// short buffer is clamped to what it actually holds (no OOB read).
// channelCount channels per frame (the stride). The per-frame test is the max
// |sample| over these channels — the frame is "above" if its
// loudest channel is above the threshold.
// frameCount frames to consider (the scan starts at the last of these).
// linearThreshold the comparison level as a LINEAR amplitude ratio (e.g. the
// -72 dB ratio from render_settings::autoTrimEndRatio), NOT dB.
// A frame counts as above when its peak is STRICTLY > this.
//
// Pure, stdlib-only, unit-tested (a synthetic decaying ramp, silence, all-above,
// and degenerate inputs) so the trim boundary math is locked outside the DAW.
// linearThreshold a LINEAR amplitude ratio (e.g. the -72 dB ratio from
// render_settings::autoTrimEndRatio), NOT dB. A frame counts as above when
// its peak is STRICTLY greater than this.
std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
std::size_t channelCount,
std::size_t frameCount,
+69
View File
@@ -0,0 +1,69 @@
# src/core/capture — pure logic behind the capture pillar
## Scope
Pure, REAPER-free logic behind the capture pillar: path arithmetic, the RIFF/WAV
codec, render-settings/FX-scope/tail-mode mapping, `InsertMedia` mode-bit
computation, the realtime-record state machine, and batch-capture planning.
Does **not** include: the REAPER-bound capture backends themselves
(`shell/capture`), the docked panel's tail-toggle window/click-handling
(`shell/panel`), or the `InsertMedia` call/undo-block mechanics
(`shell/capture`'s `insert.cpp`).
## Invariants
The repo-wide precision invariants (null test, bit-identical repeats,
non-destructive, exact bounds, relative-paths-only, capture FX scope) are
authoritative in root `CLAUDE.md` — reference them, don't re-copy them.
Detail specific to these pure modules:
- **No silent time-stretch, made checkable.** `insert_plan` never sets the &4
("stretch/loop to fit time sel") bit; `kStretchToTimeSelBit` is exposed
precisely so a test can assert it is never present in any computed
`InsertMedia` mode.
- **Tail is a three-state mode (`docs/product/capture-tail.md`), not a
per-action variant:** None (exact bounds, byte-identical, the only mode for
null-test/verify captures), Auto (generous 8 s tail then trim trailing
silence to -72 dB surgical normalize), Manual (fixed length, clamped to the 8
s cap, no trim). `render_settings` owns the offline RENDER_* mapping;
`tail_control` owns the panel-facing toggle/cycle/clamp/label logic sharing
the same `TailMode` enum and the same 8 s / -72 dB constants (single source
of truth — do not hardcode a second copy in either module).
- **Capture FX scope is enforced via FX-bypass + gain-neutralize, not a render
bit.** `render_settings::fxBypassPlanFor` selects which tracks (self /
ancestors / master) get their FX bypassed for a given `CaptureScope`; there
is no master capture scope (to capture the master, render a track instead).
- **Relative paths only, by construction.** `capture_paths::BankPaths`
separates the absolute render directory REAPER needs from the
project-relative path the `BankIndex` stores; `bankRelativeForName` spells an
enumerated folder entry the identical way `deriveBankPaths` spelled it at
capture time, so the prune core's exact-string match cannot drift.
- **Project-identity transition is GUID-primary.** `capture_paths`'s
`classifyProjectTransition` checks the minted GUID before the live
`ReaProject*` object, specifically because REAPER can recycle a closed
project's pointer address onto an unrelated project.
## Modules
- `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`).
- `batch_capture` — pure batch-capture planner: maps source ranges to capture units and aggregates results.
- `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition``NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives.
- `insert_plan` — the REAPER-free logic behind the `insert` shell (M6): computes the `InsertMedia` `mode` bitmask from an `InsertOptions` struct (placement target, tempo-conform ratio, preserve-pitch flag), guaranteeing the &4 stretch-to-time-selection bit is never set and that no tempo bits are set when `conform == None`.
- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode``RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), and the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family.
- `tail_control` — the REAPER-free logic behind the docked `bank_panel`'s tail-mode toggle: the cycle order (None → Auto → Manual → None), the Manual-length clamp/scroll-wheel fine-adjust (`clampManualMs`/`adjustManualMs`, 250 ms/notch, 2000 ms default), the toggle's label text (e.g. "Tail: Manual 2.0s"), and the `TailSetting` JSON round-trip persist stores per-project.
## Gotchas
- `render_settings`'s `RENDER_SETTINGS`/`RENDER_NORMALIZE`/`RENDER_TAILFLAG`/
`RENDER_TRIMEND` bit values are transcribed verbatim from the SDK header
(`reaper_plugin_functions.h` lines ~3041/~3047/~3051/~3062) — re-verify
against the header before changing any bit value, per the root `CLAUDE.md`
API-verification rule.
- `kRenderPreFaderStems` (&8192) is deliberately **not** used — REAPER offline
render has no true pre-FX "dry" bit; FX scoping is done entirely by the
FX-bypass-around-render mechanism, never by a render bit.
- `tail_control`'s `kDefaultManualTailMs`/`kManualStepMs` and
`render_settings`'s `kMaxTailMs`/`kAutoTrimThresholdDb` are separate constants
in separate files by design (panel-facing default/step vs. runaway-guard cap)
— don't conflate them when touching either.
+3 -5
View File
@@ -1,5 +1,5 @@
// batch_capture.cpp — pure logic for M11 batch capture. See header.
// NO REAPER types; unit-tested by tests/test_batch_capture.cpp.
// batch_capture.cpp — pure logic for batch capture. See header.
// Unit-tested by tests/test_batch_capture.cpp.
#include "core/capture/batch_capture.h"
@@ -12,9 +12,7 @@ std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges)
units.reserve(ranges.size());
int ordinal = 0;
for (const BatchRange& r : ranges) {
// Drop empty/inverted ranges — the offline backend refuses end<=start too, so
// planning one would only manufacture a guaranteed per-unit failure. Ordinals
// count kept units so the reported numbering is contiguous.
// Drop empty/inverted ranges — the offline backend refuses end<=start too.
if (!(r.endSeconds > r.startSeconds)) continue;
++ordinal;
units.push_back({ordinal, r.startSeconds, r.endSeconds});
+30 -38
View File
@@ -1,29 +1,24 @@
#pragma once
// batch_capture — the REAPER-free logic behind M11 batch capture (one action fires
// N captures: one bank sample per selected item / per razor area).
// batch_capture — the REAPER-free logic behind batch capture (one action fires N
// captures: one bank sample per selected item / per razor area).
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The batch shell (main.cpp) reads the DAW
// state (selected items -> their exact bounds; every track's P_RAZOREDITS -> areas)
// and hands the raw ranges here so the genuinely-pure, easy-to-get-wrong pieces are
// unit-tested outside the DAW:
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
// only. The batch shell reads the DAW state (selected items -> exact bounds;
// each track's P_RAZOREDITS -> areas) and hands the raw ranges here:
//
// 1. planCaptureUnits: an ordered list of (start,end) source ranges -> an ordered
// list of CaptureUnit, each carrying its 1-based ordinal and validated bounds.
// Empty/inverted ranges are DROPPED (mirrors the offline backend's own
// end>start guard) so a zero-length item/area never produces a stray render.
// Order is preserved: unit ordinals count only the KEPT units, so a batch of
// three valid items yields ordinals 1,2,3 regardless of dropped neighbors.
// 2. BatchOutcome: order-preserving aggregation of per-unit results into a summary
// (succeeded / failed counts + the ordered list of failures) so the shell can
// report a mixed result with one console line and no partial-corruption
// ambiguity. The AGGREGATION is pure; the render loop that feeds it is shell.
// 1. planCaptureUnits: an ordered list of (start,end) ranges -> an ordered
// list of CaptureUnit, each with a 1-based ordinal and validated bounds.
// Empty/inverted ranges are dropped (mirrors the offline backend's own
// end>start guard); ordinals count only the kept units, so three valid
// items yield 1,2,3 regardless of dropped neighbors.
// 2. BatchOutcome: order-preserving aggregation of per-unit results into a
// summary (succeeded/failed counts + ordered failures) for one console
// line with no partial-corruption ambiguity.
//
// Range is the ONLY thing that varies per unit here. FX scope (item vs track) is a
// per-ACTION constant the shell already owns (fxBypassPlanFor); it is not a
// per-unit field. Item-batch uses item scope; razor-batch uses track scope — the
// shell passes the scope straight through to each render, unchanged from the
// single-capture path.
// Range is the only thing that varies per unit here. FX scope (item vs track) is
// a per-action constant the shell already owns; item-batch uses item scope,
// razor-batch uses track scope, passed through unchanged from the single-capture
// path.
#include <cstddef>
#include <string>
@@ -31,10 +26,10 @@
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
// unique tag is 1-second-granular, so a fast batch could otherwise collide N files
// onto one name) and labels a failure in the summary.
// 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 unique tag is 1-second-granular, so a fast batch could otherwise
// collide N files onto one name) and labels a failure in the summary.
struct CaptureUnit {
int ordinal = 0; // 1-based, counts kept units only
double startSeconds = 0.0; // exact — no rounding
@@ -42,20 +37,18 @@ struct CaptureUnit {
};
// A source range handed in by the shell (a selected item's [pos, pos+len] or one
// razor area's [start, end]). Kept as a distinct type from CaptureUnit so the input
// (raw, possibly-invalid) and the output (validated, ordinal-assigned) do not share
// a shape by accident. Named BatchRange (not SourceRange) to avoid collision with
// bank_model's SourceRange, which carries PPQ fields this planner does not need.
// razor area's [start, end]). Named BatchRange (not SourceRange) to avoid
// collision with bank_model's SourceRange, which carries PPQ fields this planner
// doesn't need.
struct BatchRange {
double startSeconds = 0.0;
double endSeconds = 0.0;
};
// Validates + orders a batch's source ranges into capture units. Preserves input
// order; DROPS every range with end <= start (empty/inverted) so no stray render is
// planned; assigns 1-based ordinals over the KEPT units. An empty input (no selected
// item / no razor area) yields an empty plan — the shell reports "nothing to batch"
// and writes nothing (the same no-op posture the single-capture path takes).
// order; drops every range with end <= start; assigns 1-based ordinals over the
// kept units. An empty input yields an empty plan — the shell reports "nothing
// to batch" and writes nothing.
std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges);
// The per-unit verdict the shell records after each render attempt, in unit order.
@@ -65,10 +58,9 @@ struct BatchUnitResult {
std::string detail; // failure reason (empty on success) — for the summary
};
// Order-preserving aggregation of a batch's per-unit results. Built incrementally by
// the shell (record() after each unit) so a mid-batch failure is captured without
// aborting the remaining units (no partial corruption: each unit is independent, and
// the selection is restored on every exit path by the shell's RAII guard).
// Order-preserving aggregation of a batch's per-unit results. Built incrementally
// by the shell (record() after each unit) so a mid-batch failure doesn't abort
// the remaining units each unit is independent.
class BatchOutcome {
public:
// Records one unit's verdict. Order of calls IS the reported order.
+11 -60
View File
@@ -6,25 +6,16 @@
namespace reasampler::capture {
// 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;
for (char& c : out) {
if (c == '\\') c = '/';
}
// Strip a single trailing slash so joins do not double up. Preserve a lone
// "/" (root) — stripping it would turn root into empty.
// Strip a trailing slash but preserve a lone "/" (root).
if (out.size() > 1 && out.back() == '/') {
out.pop_back();
}
#ifdef _WIN32
// Windows paths are case-insensitive. Fold to lowercase so that two paths
// that differ only in drive-letter or component casing compare equal (e.g.
// "C:/Foo/BAR.wav" == "c:/foo/bar.wav"). On macOS/Linux, exact case is
// preserved (the filesystem is case-sensitive; folding would be wrong).
for (char& c : out) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
#endif
return out;
@@ -39,8 +30,7 @@ std::string sanitizeStem(const std::string& baseName) {
c == '-';
out.push_back(keep ? static_cast<char>(c) : '_');
}
// Collapse to a stable default if nothing usable survived (e.g. all spaces).
// A stem of only separators ('.', '_', '-') is also unhelpful as a name.
// Collapse to a stable default if nothing alnum survived.
bool hasAlnum = false;
for (unsigned char c : out) {
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
@@ -66,21 +56,16 @@ BankPaths deriveBankPaths(const std::string& projectDir,
}
const std::string fileName = stem + ".wav";
// Precondition: the capture shell must resolve a non-empty project directory
// before calling this function. An empty projectDir would produce a bare
// relative "reasampler_bank" path — the silent default-location fallback this
// tool explicitly forbids. Assert in debug; leave absoluteDir empty in release
// so any caller that ignores the precondition fails loudly at the render/stat
// step rather than silently writing to CWD.
// Precondition: caller must resolve a non-empty project directory — an
// empty one would otherwise fall back to a bare relative path (forbidden).
// Assert in debug; leave absoluteDir empty in release so a caller that
// ignores it fails at the render/stat step, not silently onto CWD.
assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty");
BankPaths p;
p.fileStem = stem; // stem only — REAPER appends extension
p.fileName = fileName;
p.relativePath = std::string(kBankSubfolder) + "/" + fileName;
// absoluteDir intentionally omits a trailing slash (RENDER_FILE wants the
// directory itself; RENDER_PATTERN supplies the file name separately).
// Empty when precondition is violated (dir empty) — caller must not proceed.
p.absoluteDir = dir.empty() ? std::string{}
: dir + "/" + kBankSubfolder;
return p;
@@ -88,15 +73,12 @@ BankPaths deriveBankPaths(const std::string& projectDir,
std::string bankRelativeForName(const std::string& fileName) {
if (fileName.empty()) return {};
// The SAME expression deriveBankPaths uses for relativePath, kept in one place so
// the two spellings can never drift (Phase R spelling-consistency invariant).
// Same expression deriveBankPaths uses, so the two spellings can't drift.
return std::string(kBankSubfolder) + "/" + fileName;
}
std::string resolveBankFile(const std::string& projectDir,
const std::string& relativePath) {
// No default-location fallback (CLAUDE.md invariant): an empty project dir or
// relative path yields empty, not a bare relative path resolved against CWD.
if (projectDir.empty() || relativePath.empty()) {
return {};
}
@@ -109,10 +91,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 the former persist shell's
// projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes.
if (rppPath.empty()) return {};
if (rppPath.empty()) return {}; // unsaved project: keep empty, no fallback
std::string dir = std::filesystem::path(rppPath).parent_path().string();
return normalizeSlashes(dir);
}
@@ -128,9 +107,7 @@ BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
r.oldBankDir = oldDir + "/" + kBankSubfolder;
r.newBankDir = newDir + "/" + kBankSubfolder;
// A Save (in place) leaves the project dir unchanged — nothing to relocate.
// Only a Save-As to a different directory needs the bank moved.
r.needed = (oldDir != newDir);
r.needed = (oldDir != newDir); // Save-in-place leaves the dir unchanged
return r;
}
@@ -139,42 +116,16 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject,
const std::string& lastPath,
const std::string& currentGuid,
const std::string& currentPath) {
// 1. The GUID is the identity of record and is checked FIRST. A different
// stored GUID means a genuinely different project is active — Load ITS index.
// This catches the regression that pointer-primary classification missed:
// REAPER RECYCLES ReaProject* addresses across close/open, so a reopened /
// new project can reuse the previous project's address (sameProjectObject ==
// true) while carrying a different stored GUID. Deciding on the pointer alone
// then returned NoOp/SaveAsRelocate and the bank never reloaded. The GUID is
// immune to address recycling, so it leads. Also covers new/unsaved<->saved
// transitions (one GUID empty, the other not) and switching between two
// distinct saved projects.
// See capture_paths.h for the GUID-primary rationale and rule order.
if (currentGuid != lastGuid) {
return ProjectTransition::Load;
}
// From here currentGuid == lastGuid (they are equal; both may be empty for
// unsaved projects). The pointer now disambiguates the same-GUID case.
// 2. Same GUID but a DIFFERENT object is a forked sibling: Save-As copied our
// GUID onto a distinct project object. Load its (own) index; never relocate.
// Two unsaved projects (both GUIDs empty, distinct objects) also land here —
// Load, so switching between them installs the right in-memory state.
if (!sameProjectObject) {
return ProjectTransition::Load;
return ProjectTransition::Load; // forked sibling: same GUID, different object
}
// 3. Same object AND same GUID with a NEW path is a genuine Save-As (the object
// identity is proven and the record identity is unchanged — only the .rpp
// moved). Also the first save of an unsaved project (both GUIDs empty, old
// path empty): SaveAsRelocate is safe there because deriveRelocationPlan
// no-ops on the empty old dir (empty-GUID safety preserved) while poll()
// mints a GUID.
if (currentPath != lastPath) {
return ProjectTransition::SaveAsRelocate;
}
// 4. Same object, same GUID, same path — Save in place / idle tick.
return ProjectTransition::NoOp;
}
+51 -122
View File
@@ -1,16 +1,8 @@
#pragma once
// capture_paths — the REAPER-free path arithmetic behind offline capture.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The capture shell resolves the
// current project directory via REAPER APIs, then hands the raw strings here so
// the fiddly, easy-to-get-wrong path arithmetic (bank subfolder, unique file
// name, absolute render dir, project-relative index path) is unit-tested outside
// the DAW.
//
// Path convention: this module works in forward-slash form and does NOT touch
// the filesystem. The bank subfolder name is a fixed constant so the same
// project always resolves the same bank location (determinism).
// capture_paths — the REAPER-free path arithmetic behind offline capture. The
// capture shell resolves the current project directory via REAPER APIs, then
// hands the raw strings here. Forward-slash form throughout, no filesystem
// access; the bank subfolder name is a fixed constant.
#include <cstddef>
#include <cstdint>
@@ -20,7 +12,7 @@
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).
// travels with the .rpp.
inline constexpr const char* kBankSubfolder = "reasampler_bank";
// A resolved pair of paths for one capture: where REAPER must be told to write
@@ -34,150 +26,87 @@ struct BankPaths {
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
};
// 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).
// Platform case rule: on Windows (_WIN32) the result is also lowercased so that
// paths differing only in drive-letter or component casing compare equal (Windows
// paths are case-insensitive). On macOS/Linux the case is preserved exactly (those
// filesystems are case-sensitive).
// Normalizes a path to forward slashes and strips any trailing slash (does not
// consult the filesystem). On Windows (_WIN32) also lowercases the result so
// paths differing only in casing compare equal; macOS/Linux preserve case.
std::string normalizeSlashes(const std::string& path);
// Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps
// [A-Za-z0-9._-], replaces every other byte (spaces, slashes, quotes, control)
// with '_', and collapses to "capture" if nothing usable remains. Deterministic:
// the same input always yields the same stem (feeds bit-identical file naming).
// [A-Za-z0-9._-], replaces every other byte with '_', and collapses to
// "capture" if nothing usable remains. Deterministic.
std::string sanitizeStem(const std::string& baseName);
// Derives the bank paths for one capture.
// projectDir : absolute directory of the current .rpp (any slash style)
// baseName : human base for the file stem (sanitized)
// uniqueTag : caller-supplied disambiguator appended to the stem (e.g. a
// timestamp or counter) so repeated captures do not collide.
// Also sanitized. May be empty.
// Produces "<stem>[_<tag>].wav". The relativePath is always project-relative and
// forward-slashed so it satisfies BankModel::add's relative-only invariant.
// Derives the bank paths for one capture: baseName is the sanitized file-stem
// source, uniqueTag an optional sanitized disambiguator (timestamp/counter) so
// repeated captures don't collide. Produces "<stem>[_<tag>].wav".
BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName,
const std::string& uniqueTag);
// The project-relative index spelling for a bank file KNOWN ONLY by its file name —
// the forward derivation the Phase R prune shell uses to spell an ENUMERATED folder
// entry the SAME way deriveBankPaths spelled it at capture time. By construction it
// is the identical expression deriveBankPaths().relativePath uses (kBankSubfolder +
// "/" + fileName), so a file the capture path created and a directory listing of that
// same file resolve to the byte-identical relative string — the safety-critical
// spelling-consistency the prune core's exact-string match depends on (a divergence
// here could make a referenced file look like an orphan). fileName is a bare entry
// name (no directory component); the caller supplies forward-slash-free names from the
// folder enumeration. Empty in -> empty out.
// The project-relative index spelling for a bank file known only by its file
// name (bare entry, no directory) — the prune shell uses this to spell an
// enumerated folder entry the SAME way deriveBankPaths spelled it at capture
// time; a divergence here could make a referenced file look like an orphan.
std::string bankRelativeForName(const std::string& fileName);
// --- Persist-side path arithmetic (M4) --------------------------------------
// --- Persist-side path arithmetic -------------------------------------------
//
// The index stores relative paths only; on project load the persist shell must
// turn each entry's relativePath back into an absolute path against the CURRENT
// project directory (so a project opened from a new location still resolves its
// bank). This is the inverse of the relativePath the capture path produced.
//
// projectDir : absolute directory of the current .rpp (any slash style)
// relativePath : a project-relative index entry (e.g. "reasampler_bank/x.wav")
//
// Returns "<projectDir>/<relativePath>" forward-slashed. Returns empty when
// either input is empty (no default-location fallback — CLAUDE.md invariant) so
// a caller that ignores an unsaved/unset project fails loudly rather than
// resolving against CWD.
// The index stores relative paths only; on project load the persist shell
// turns each relativePath back into an absolute path against the current
// project directory — the inverse of deriveBankPaths.
// Returns "<projectDir>/<relativePath>" forward-slashed, or empty if either
// input is empty (no default-location fallback — an unsaved/unset project
// fails loudly rather than resolving against CWD).
std::string resolveBankFile(const std::string& projectDir,
const std::string& relativePath);
// The project directory that holds a .rpp: its parent directory, forward-slashed,
// trailing slash stripped. Empty in -> empty out (an unsaved project has an empty
// .rpp path, which must stay empty so resolveBankFile refuses to resolve — the
// no-default-location invariant). This is the M4 convention persist uses to place
// the bank alongside the .rpp; extracted here (pure) so the VST3 instrument resolves
// audio paths the SAME way persist does rather than re-implementing the derivation.
// The project directory that holds a .rpp: parent directory, forward-slashed,
// trailing slash stripped. Empty in -> empty out (an unsaved project reports
// an empty .rpp path). Pure so the VST3 instrument resolves audio paths the
// same way persist does.
std::string projectDirOfRpp(const std::string& rppPath);
// A relocation plan for the physical bank folder on Save-As to a new project
// location. The index's relative paths do NOT change (they are relative to the
// project dir, which is what moved with the .rpp), so relocation is purely a
// folder move: copy/move the whole bank subfolder from the old project dir to
// the new one. Both dirs are absolute, forward-slashed, trailing-slash-stripped.
// project dir, which moved with the .rpp), so relocation is purely a folder
// move. Both dirs are absolute, forward-slashed, trailing-slash-stripped.
struct BankRelocation {
std::string oldBankDir; // <oldProjectDir>/reasampler_bank
std::string newBankDir; // <newProjectDir>/reasampler_bank
bool needed = false; // false when old==new (Save in place, not Save-As)
};
// Derives the relocation plan from the old and new project directories.
// oldProjectDir : project dir the bank currently sits under (any slash style)
// newProjectDir : project dir the .rpp was just saved to (any slash style)
// `needed` is true iff the normalized dirs differ (a genuine Save-As-to-new-dir).
// Returns a plan with empty dirs and needed=false when either input is empty.
// Derives the relocation plan: `needed` is true iff the normalized old/new
// project dirs differ (a genuine Save-As-to-new-dir); empty dirs/needed=false
// when either input is empty.
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
const std::string& newProjectDir);
// --- Project-identity transition (W12 combined identity fix) -----------------
// --- Project-identity transition ---------------------------------------------
//
// What the persist timer must do on each tick. Identity rests on TWO facts,
// layered GUID-PRIMARY:
// 1. the minted GUID — content-based identity of record, stored in ext state.
// It is IMMUNE to REAPER recycling a closed project's ReaProject* address,
// so it is checked FIRST.
// 2. sameProjectObject — did the same live ReaProject* stay active across the
// two ticks (computed in poll() as `proj == lastProject_`)? Used ONLY to
// disambiguate the same-GUID case: a forked sibling (Save-As copied our GUID
// onto a distinct object) vs a genuine Save-As (one object, new path).
//
// This fix layers both prior designs, GUID-primary. 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. W10 (pointer-primary, GUID voided) broke pointer
// RECYCLING: REAPER reuses a closed project's address, so a reopened/new project
// can present the previous project's pointer with a different stored GUID —
// pointer-primary read that 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).
//
// The load-bearing rule: a DIFFERENT record identity (GUID) is always a Load; a
// DIFFERENT project object with the same GUID is a fork Load, never a relocate.
// What the persist timer must do on each tick. GUID is checked FIRST because
// two prior pointer-primary/GUID-only designs each broke a real case: a
// GUID-only check misreads a Save-As fork as the same project (fork and
// parent share a GUID on disk); a pointer-primary check misreads REAPER
// recycling a closed project's ReaProject* address onto an unrelated project
// (a different project, same recycled pointer, read as NoOp/SaveAsRelocate —
// the bank never reloads). Checking GUID first catches recycling; the pointer
// (sameProjectObject) then separates a forked sibling (Load) from a genuine
// Save-As (SaveAsRelocate).
enum class ProjectTransition {
NoOp, // same object, same GUID, same location — nothing to do
Load, // a different project is active — load ITS index from ext state
SaveAsRelocate, // SAME object + SAME GUID, new .rpp location — relocate the bank
};
// Classifies what a poll tick observed.
// sameProjectObject : true iff the SAME ReaProject* stayed active across the two
// ticks (poll() computes `proj == lastProject_`). The pure
// classifier takes the bool, not the raw pointer, to stay
// REAPER-free and testable.
// lastGuid : the GUID of the project persist last acted on ("" if none/unsaved)
// lastPath : that project's .rpp path when last seen ("" if unsaved)
// currentGuid : the GUID stored in the now-active project's ext state ("" if
// unsaved or never written)
// currentPath : the now-active project's .rpp path ("" if unsaved)
//
// Rules (evaluated in EXACTLY this order):
// 1. currentGuid != lastGuid -> Load (different record identity:
// recycled pointer w/ different GUID,
// new/unsaved<->saved, or two distinct
// saved projects)
// 2. !sameProjectObject -> Load (same GUID, different object:
// forked sibling, or two unsaved projects)
// 3. currentPath != lastPath -> SaveAsRelocate (same object + same GUID,
// new path: genuine Save-As, or first save
// of an unsaved project — relocate no-ops
// on the empty old dir, poll() mints a GUID)
// 4. otherwise -> NoOp (same object, same GUID, same path)
//
// The GUID (identity of record) leads; the pointer only disambiguates the same-GUID
// case (fork-Load in step 2 vs Save-As in step 3). The empty-GUID safety (unsaved
// projects never physically relocate) is preserved because an empty old project dir
// makes deriveRelocationPlan's `needed` false.
// Classifies what a poll tick observed. sameProjectObject is passed as a bool
// (not the raw pointer) to keep the classifier REAPER-free and testable;
// lastGuid/lastPath is the project persist last acted on, currentGuid/
// currentPath the now-active project (both "" if unsaved/unwritten).
// Evaluated in order: currentGuid!=lastGuid -> Load; !sameProjectObject ->
// Load (forked sibling); currentPath!=lastPath -> SaveAsRelocate (also covers
// first save of an unsaved project); else NoOp.
ProjectTransition classifyProjectTransition(bool sameProjectObject,
const std::string& lastGuid,
const std::string& lastPath,
+19 -44
View File
@@ -1,6 +1,5 @@
// 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.)
// capture_realtime.cpp — pure logic for the realtime-record backend. See header.
// Unit-tested by tests/test_capture_realtime.cpp.
#include "core/capture/capture_realtime.h"
@@ -9,11 +8,8 @@ namespace reasampler::capture {
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) {
RecordModePlan p;
// Stereo vs mono output recording, latency-compensated either way so the
// recorded file lines up with the source. A request asking for <= 1 channel
// records mono-out; anything else records stereo-out. (Higher channel counts
// still record stereo-out here — REAPER's output-record modes are mono/stereo
// only; a >2-channel realtime capture is out of scope for this increment.)
// REAPER's output-record modes are mono/stereo only; >2 channels still
// records stereo-out (a >2-channel realtime capture is out of scope).
p.recMode = (channelCount <= 1) ? kRecModeMonoOutLatComp
: kRecModeStereoOutLatComp;
@@ -26,42 +22,32 @@ RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) {
}
OutputTap outputTapForWetDry(double wetDry) {
// Fully wet (1.0) taps post-fader; any dry-ward value taps pre-FX — the true
// pre-FX dry that offline render cannot produce (the realtime backend's whole
// reason to exist for the M10 null test). PostFxPreFader is an explicit future
// option, not reachable from the wet/dry axis, so it is not returned here.
return (wetDry >= 1.0) ? OutputTap::PostFader : OutputTap::PreFx;
}
Sample sampleFromRecordedCapture(const RecordedCapture& cap) {
Sample s;
// Same id shape as the offline path: "cap-<tag>-<fileName>" would need the file
// name; here the recorded file name is the tail of relativePath. Keep the id
// stable + unique via the tag, and include the relative path tail so two
// captures with the same tag (impossible in practice) still differ.
// Relative path tail included so two same-tag captures (shouldn't happen) still differ.
s.id = "cap-" + cap.uniqueTag + "-" + cap.relativePath;
s.displayName = cap.displayName;
s.relativePath = cap.relativePath; // project-relative (invariant)
s.relativePath = cap.relativePath;
s.sourceMode = cap.sourceMode;
s.sourceRange.startSeconds = cap.startSeconds;
s.sourceRange.endSeconds = cap.endSeconds;
// PPQ/beats deferred (musical-placement concern) — identical to the offline path.
// PPQ/beats deferred (musical-placement concern), as offline.
s.wetDry = cap.wetDry;
s.trackGuids = cap.trackGuids;
s.channelCount = cap.channelCount;
s.sampleRate = cap.sampleRate; // 0 when project rate was unknown
s.lengthSeconds = cap.endSeconds - cap.startSeconds;
s.captureTempo = cap.captureTempo;
s.captureTimeSigNum = cap.captureTimeSigNum; // L7 F1 meter stamp (0/0 = unstamped)
s.captureTimeSigNum = cap.captureTimeSigNum; // 0/0 = unstamped
s.captureTimeSigDenom = cap.captureTimeSigDenom;
s.tier = Tier::Scratch; // captures land in scratch by default
// contentHash set by the caller (capture_realtime.cpp) after the file is
// finalized and on disk the hash is over the finished file bytes. Left empty
// here because sampleFromRecordedCapture runs before the file exists (the
// mapping is pure / DAW-free); the shell patches it in after the move+trim.
// Phase S seam fields (rootNote / loop) left empty (D-B) — same reasoning as the
// offline path: a realtime record of wet output is not a single played note, so
// no root note is derivable; loop points are set by a later explicit action.
s.tier = Tier::Scratch;
// contentHash is left empty: this mapping runs before the file exists on
// disk; the shell patches the hash in after the move+trim.
// rootNote/loop left empty: a realtime record of wet output isn't a single
// played note, so no root note is derivable; loop points are a later action.
s.createdTimestamp = cap.createdTimestamp;
return s;
}
@@ -72,20 +58,15 @@ RecordPhase advanceRecordPhase(RecordPhase current,
double rangeEndSeconds) {
switch (current) {
case RecordPhase::Recording: {
// Transport stopped while we still expected to be recording -> the user
// (or REAPER) stopped early. Move to the flush wait and finalize whatever
// was captured up to the stop.
// Stopped early (user or REAPER) -> finalize what was captured so far.
if (!inputs.transport.recording) return RecordPhase::Finalizing;
// Reached the range end (latency-compensated play position). >= (not >)
// so a cursor landing exactly on the end completes.
// >= (not >): a cursor landing exactly on the end completes.
if (inputs.transport.playPosition >= rangeEndSeconds)
return RecordPhase::Finalizing;
// Self-defense (review §3): the transport is running but the play cursor
// is not advancing to the end (stuck / looping). Without this the machine
// stays in Recording forever, leaking the temp track + armed sink. Force
// the flush wait once wall-clock exceeds the nominal duration + margin.
// Self-defense: a stuck/looping transport that never reaches end would
// otherwise stay in Recording forever, leaking the temp track + armed sink.
const double ceiling =
(rangeEndSeconds - rangeStartSeconds) + kRecordMarginSeconds;
if (inputs.elapsedSeconds > ceiling) return RecordPhase::Finalizing;
@@ -94,22 +75,16 @@ RecordPhase advanceRecordPhase(RecordPhase current,
}
case RecordPhase::Finalizing: {
// The transport is stopped; wait for REAPER to flush/close the recorded
// take on the audio thread. Finalize (move + Sample) only once the file
// exists AND is stable (review §2) — moving it early races the flush and
// yields a truncated / missing capture.
// Moving the file before it's stable would race REAPER's flush and
// yield a truncated/missing capture.
if (inputs.fileReady) return RecordPhase::Done;
// Bound the wait: a file that never stabilizes fails cleanly rather than
// hanging the in-flight state for the session.
if (inputs.finalizingSeconds > kFinalizeFlushCeilingSeconds)
return RecordPhase::Failed;
return RecordPhase::Finalizing;
}
// Terminal phases are sticky: once the verdict is in, a later tick (a stray
// extra call before the shell has finished tearing down) must not flip it.
case RecordPhase::Done:
case RecordPhase::Failed:
default:
+70 -150
View File
@@ -1,27 +1,11 @@
#pragma once
// 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 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:
//
// 1. the record-mode/recipe bookkeeping: given a capture scope + a desired
// FX-tap point (post-fader / pre-FX / post-FX-pre-fader), the I_RECMODE and
// I_RECMODE_FLAGS integer values the temp track must carry.
// 2. the recorded-file -> Sample mapping: given a finished capture (the
// recorded file's project-relative path + the request's own bounds/format),
// the populated Sample handed to bank_model. Mirrors the inline Sample
// population OfflineRenderBackend does — factored out so it is tested once,
// without a DAW, and shared shape with the offline path is guaranteed.
//
// The I_RECMODE / I_RECMODE_FLAGS bit MEANINGS are transcribed verbatim from
// reaper_plugin_functions.h line ~2197-2198 (see kRecMode* constants); the CHOICE
// of which values each scope needs is this module's logic and is tested.
// capture_realtime — the REAPER-free logic behind the realtime-record backend.
// The shell drives the transport, temp track, send routing, and file move; the
// pure pieces split out here and unit-tested outside the DAW are: (1) record-
// mode bookkeeping — scope + FX-tap point -> I_RECMODE/I_RECMODE_FLAGS values
// (bit MEANINGS transcribed verbatim from reaper_plugin_functions.h ~2197-2198;
// the CHOICE of value per scope is this module's tested logic) — and (2) the
// recorded-file -> Sample mapping (mirrors OfflineRenderBackend's population).
#include <cstdint>
#include <string>
@@ -35,26 +19,17 @@ using model::Sample;
using model::Tier;
using model::SourceMode;
// --- I_RECMODE values (verbatim from SDK header ~2197) -----------------------
//
// I_RECMODE : int * : record mode, 0=input, 1=stereo out, 2=none,
// 3=stereo out w/latency compensation, 4=midi output, 5=mono out,
// 6=mono out w/ latency compensation, 7=midi overdub, 8=midi replace.
//
// We record a track's OUTPUT (the scoped signal routed into the temp track),
// latency-compensated, so the recorded file lines up sample-accurately with the
// source. Stereo vs mono is chosen by the request's channel count.
// I_RECMODE (verbatim from SDK header ~2197): 0=input, 1=stereo out, 2=none,
// 3=stereo out w/latency comp, 4=midi output, 5=mono out, 6=mono out w/latency
// comp, 7=midi overdub, 8=midi replace. We record a track's OUTPUT, latency-
// compensated, so the recorded file lines up sample-accurately with the source.
inline constexpr int kRecModeStereoOutLatComp = 3; // stereo out w/latency comp
inline constexpr int kRecModeMonoOutLatComp = 6; // mono out w/latency comp
// --- I_RECMODE_FLAGS values (verbatim from SDK header ~2198) ------------------
//
// I_RECMODE_FLAGS : int * : record mode flags, &3=output recording mode
// (0=post fader, 1=pre-fx, 2=post-fx/pre-fader).
//
// This is the ONLY documented pre-FX tap in the whole SDK — offline render has no
// pre-FX bit (see render_settings.h note + the M10 null-test note in PLAN.md).
// The realtime backend is therefore the true pre-FX "dry" path.
// I_RECMODE_FLAGS (verbatim from SDK header ~2198): &3=output recording mode
// (0=post fader, 1=pre-fx, 2=post-fx/pre-fader). This is the only documented
// pre-FX tap in the SDK — offline render has no pre-FX bit — so the realtime
// backend is the true pre-FX "dry" path.
inline constexpr int kRecOutPostFader = 0; // &3==0: post-fader (fully wet)
inline constexpr int kRecOutPreFx = 1; // &3==1: pre-FX (true dry)
inline constexpr int kRecOutPostFxPreFader = 2; // &3==2: post-FX, pre-fader
@@ -68,41 +43,34 @@ enum class OutputTap {
};
// The concrete record-mode values a temp track must carry to capture the scoped
// output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated); `recModeFlags`
// sets the &3 output-recording tap bits (higher bits are left at their default 0
// here — we only own the tap-point bits).
// output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated);
// `recModeFlags` sets the &3 output-recording tap bits (we only own those bits).
struct RecordModePlan {
int recMode = kRecModeStereoOutLatComp;
int recModeFlags = kRecOutPostFader;
};
// Maps (channelCount, tap) to the record-mode values.
// channelCount <= 1 -> mono-out latency-comp; otherwise stereo-out latency-comp.
// tap -> the &3 output-recording bits.
// Pure so the "which I_RECMODE for N channels + this tap" rule is unit-tested
// without a DAW; the shell reads the request and applies these via
// SetMediaTrackInfo_Value(I_RECMODE / I_RECMODE_FLAGS).
// Maps (channelCount, tap) to the record-mode values: channelCount <= 1 ->
// mono-out latency-comp, else stereo-out; tap -> the &3 bits. The shell applies
// these via SetMediaTrackInfo_Value(I_RECMODE / I_RECMODE_FLAGS).
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap);
// Maps a wetDry value to the output tap point. 1.0 (fully wet) -> PostFader; any
// value < 1.0 -> PreFx (true dry — the realtime backend's distinguishing
// capability). Kept pure + separate from recordModePlanFor so the wet/dry ->
// tap decision is tested on its own; PostFxPreFader is not selected by wetDry
// (it is an explicit future option, not on the wet/dry axis).
// Maps a wetDry value to the output tap point: 1.0 (fully wet) -> PostFader,
// anything less -> PreFx (true dry — the realtime backend's distinguishing
// capability over offline render). PostFxPreFader is not reachable from wetDry.
OutputTap outputTapForWetDry(double wetDry);
// --- Recorded-file -> Sample mapping ----------------------------------------
//
// The inputs a finished realtime capture yields, gathered by the shell into a
// pure struct so the Sample population is a single tested transform (mirror of
// the inline population in OfflineRenderBackend::capture).
// pure struct so Sample population is a single tested transform (mirrors the
// inline population in OfflineRenderBackend::capture).
struct RecordedCapture {
// Project-relative path of the recorded file (relative-paths-only invariant;
// the shell resolves REAPER's recorded absolute path back to project-relative).
// Project-relative path of the recorded file (the shell resolves REAPER's
// absolute path back to project-relative).
std::string relativePath;
// The disambiguating tag that named the file (feeds the Sample id, so id and
// file name stay consistent — same discipline as the offline path).
// The disambiguating tag that named the file (feeds the Sample id).
std::string uniqueTag;
// Echoed from the request (exact bounds — no re-measuring the file).
@@ -115,60 +83,41 @@ struct RecordedCapture {
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.
// Left at defaults here — capture_realtime_finalize.cpp calls
// stampCaptureSample(result.sample, ...) afterward, overwriting these five
// from the live project. Kept because the pure unit tests still assert them.
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).
// 0/0 = unstamped (matches the Sample default; formatter renders a blank read-out).
int captureTimeSigNum = 0;
double captureTempo = 0.0; // BPM at capture time
int captureTimeSigNum = 0; // 0/0 = unstamped
int captureTimeSigDenom = 0;
std::int64_t createdTimestamp = 0; // unix epoch seconds (shell reads the clock)
std::int64_t createdTimestamp = 0; // unix epoch seconds
};
// Builds the Sample for a finished realtime capture. Deliberately identical in
// shape to OfflineRenderBackend's population: exact request bounds (no rounding),
// scratch tier, empty content hash (does not dedup), lengthSeconds = end - start.
// PPQ/beats are left 0 (a musical-placement concern deferred exactly as offline).
// Builds the Sample for a finished realtime capture: exact request bounds,
// scratch tier, empty content hash, lengthSeconds = end - start. PPQ/beats
// left 0 (deferred, as offline).
Sample sampleFromRecordedCapture(const RecordedCapture& cap);
// --- Async record-phase state machine (M8 rework) ----------------------------
// --- Async record-phase state machine ----------------------------------------
//
// A realtime record spans many timer ticks (CSurf_OnRecord starts the transport on
// REAPER's audio thread and returns immediately — it does NOT block until the range
// completes). The completion decision — "given where the transport is now, should
// the tick keep waiting, stop-and-flush, finalize, or give up?" — is pure and
// exactly the kind of off-by-one/edge logic a unit test locks without a DAW. It is
// factored out here; the REAPER shell only reads the transport/clock/file and applies
// the verdict (stop, wait for the file to flush, then finalize/abort + restore).
// A realtime record spans many timer ticks (CSurf_OnRecord starts the transport
// on REAPER's audio thread and returns immediately — it does not block until the
// range completes). The completion decision — keep waiting, stop-and-flush,
// finalize, or give up — is pure and unit-tested without a DAW; the shell only
// reads the transport/clock/file and applies the verdict.
//
// The lifecycle has TWO waits, not one:
// 1. the RECORD wait (Recording): the transport is running; we wait for the play
// cursor to reach the range end OR the user stops early OR a wall-clock
// safety ceiling trips (a started-but-never-advancing transport, §3 of review).
// 2. the FLUSH wait (Finalizing): the transport is stopped but REAPER closes/flushes
// the recorded take on the AUDIO thread — the file may not be fully written/closed
// for a tick or two. We defer the file move until the file exists AND is stable
// (§2 of review), bounded by a flush ceiling so a file that never appears fails
// cleanly rather than hanging.
// Two waits, not one:
// 1. RECORD wait (Recording): transport running; wait for the play cursor to
// reach the range end, OR the user stops early, OR a wall-clock safety
// ceiling trips (a started-but-never-advancing transport).
// 2. FLUSH wait (Finalizing): transport stopped but REAPER closes/flushes the
// recorded take on the audio thread — the file may lag a tick or two.
// Defer the move until the file exists AND is stable, bounded by a flush
// ceiling so a file that never appears fails cleanly instead of hanging.
// Where an in-progress capture is in its lifecycle.
// Recording — live: transport running, shell keeps ticking.
// Finalizing — live-but-stopped: transport halted, shell stops the transport once
// then ticks waiting for the recorded file to flush/stabilize.
// Done — terminal: the file is flushed + stable, finalize (move + Sample) now.
// Failed — terminal: the flush ceiling tripped without a stable file — give up
// (RenderFailed) + restore. (A record that produced NO file at all also
// lands here via the shell's finalize returning RenderFailed.)
// Only Recording and Finalizing are live phases the shell advances per tick; Done and
// Failed are the shell's verdict to act on (finalize-or-fail, then restore).
// Where an in-progress capture is in its lifecycle: Recording (live, transport
// running) and Finalizing (live-but-stopped, waiting for flush) are the two
// waits above; Done/Failed are terminal — the shell's verdict to act on.
enum class RecordPhase {
Recording,
Finalizing,
@@ -176,63 +125,34 @@ enum class RecordPhase {
Failed
};
// A distilled transport reading for the pure transition, so the state machine never
// touches a REAPER type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition`
// is GetPlayPositionEx (latency-compensated what-you-hear position).
// A distilled transport reading so the state machine never touches a REAPER
// type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition` is
// GetPlayPositionEx (latency-compensated).
struct TransportReading {
bool recording = false;
double playPosition = 0.0;
};
// Everything the pure transition needs beyond the current phase, gathered by the
// shell each tick so the machine stays REAPER-free AND owns every timing/ceiling
// decision (the shell only reads and reports; it never decides a transition itself).
// Everything the pure transition needs beyond the current phase, gathered by
// the shell each tick (the shell only reads and reports; never decides).
struct RecordTickInputs {
TransportReading transport;
// Wall-clock seconds since begin() (the shell reads a steady clock). Drives the
// record safety ceiling: a transport that starts but never advances to the range
// end (stuck / looping) would otherwise keep the machine in Recording forever.
double elapsedSeconds = 0.0;
// Wall-clock seconds spent in the Finalizing phase (since the transport stop).
// Drives the flush ceiling: bound the deferred-finalize wait so a file that never
// stabilizes fails cleanly instead of hanging.
double finalizingSeconds = 0.0;
// Whether the recorded take's file exists AND is stable/closed this tick (the
// shell resolves the take source path and checks size-stable-across-a-tick).
// Only consulted in Finalizing.
bool fileReady = false;
double elapsedSeconds = 0.0; // wall-clock since begin() — record ceiling
double finalizingSeconds = 0.0; // wall-clock in Finalizing — flush ceiling
bool fileReady = false; // recorded file exists+stable (Finalizing only)
};
// --- Safety ceilings (named constants, review §2/§3) -------------------------
//
// kRecordMarginSeconds: added to the record's nominal duration (end - start) to form
// the record wall-clock ceiling. Generous so a normal record (with pre-roll, count-in,
// or transport latency) never trips it; tight enough that a stuck transport is force-
// terminated within a few seconds of overrun.
// Record ceiling margin added to nominal duration: generous enough that
// pre-roll/count-in/latency never trips it, tight enough a stuck transport is
// force-terminated within seconds.
inline constexpr double kRecordMarginSeconds = 5.0;
// kFinalizeFlushCeilingSeconds: the max wall-clock the Finalizing phase waits for the
// recorded file to flush/stabilize before giving up (RenderFailed). REAPER closes the
// take on the audio thread within a tick or two in practice; this is a generous bound.
// Max wall-clock Finalizing waits for the file to flush/stabilize before
// giving up (REAPER closes the take within a tick or two in practice).
inline constexpr double kFinalizeFlushCeilingSeconds = 5.0;
// The pure transition: given the current phase, this tick's inputs, and the record
// range end, return the next phase. Total + deterministic.
//
// From Recording:
// * recording AND cursor < end AND under the record ceiling -> Recording (wait)
// * recording AND cursor >= end -> Finalizing (reached end)
// * NOT recording -> Finalizing (stopped early)
// * recording BUT over the record ceiling (end-start+margin)-> Finalizing (stuck: forced)
// From Finalizing:
// * fileReady -> Done (flushed + stable)
// * over the flush ceiling without a stable file -> Failed (give up)
// * otherwise -> Finalizing (keep flushing)
// Done and Failed are sticky: feeding a terminal phase back returns it unchanged, so a
// late tick before teardown finishes cannot flip the verdict (the idempotence the
// The pure transition (total + deterministic). Done/Failed are sticky — a late
// tick before teardown finishes cannot flip the verdict (the idempotence the
// shell's single-restore relies on).
RecordPhase advanceRecordPhase(RecordPhase current,
const RecordTickInputs& inputs,
+8 -11
View File
@@ -7,14 +7,14 @@ namespace reasampler::capture {
namespace {
// Base target bits (mode&3). We use only 0 (current track) and 1 (new track).
constexpr int kBaseCurrentTrack = 0; // add to current track
constexpr int kBaseNewTrack = 1; // add new track
constexpr int kBaseCurrentTrack = 0;
constexpr int kBaseNewTrack = 1;
// Tempo-conform bits, verbatim from the header doc-comment.
constexpr int kMatchTempo1x = 8; // &8: try to match tempo 1x
constexpr int kMatchTempoHalf = 16; // &16: try to match tempo 0.5x
constexpr int kMatchTempoDbl = 32; // &32: try to match tempo 2x
constexpr int kDontPreservePitch = 64; // &64: don't preserve pitch when matching tempo
constexpr int kMatchTempo1x = 8;
constexpr int kMatchTempoHalf = 16;
constexpr int kMatchTempoDbl = 32;
constexpr int kDontPreservePitch = 64;
} // namespace
@@ -24,8 +24,7 @@ int computeInsertMode(const InsertOptions& opts) {
switch (opts.conform) {
case TempoConform::None:
// No tempo bits: native length, no stretch. (Also never &4.)
return mode;
return mode; // native length, no stretch; never &4
case TempoConform::Ratio1x:
mode |= kMatchTempo1x;
break;
@@ -37,9 +36,7 @@ int computeInsertMode(const InsertOptions& opts) {
break;
}
// Tempo bits are set (conform != None). Add the pitch-shift bit only when the
// caller asked NOT to preserve pitch. When conform == None we already returned
// above, so this can never fire without a tempo bit present.
// Reached only when a tempo bit is set (None already returned above).
if (!opts.preservePitch)
mode |= kDontPreservePitch;
+15 -17
View File
@@ -1,34 +1,32 @@
#pragma once
// insert_plan — the REAPER-free logic behind the `insert` shell (M6): computing
// the InsertMedia `mode` bitmask from a small options struct.
// insert_plan — the REAPER-free logic behind the `insert` shell: computing the
// InsertMedia `mode` bitmask from a small options struct.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The one genuinely testable-outside-DAW
// piece of insert is the mode-bit arithmetic — the InsertMedia bitfield is easy to
// get wrong and its bits are load-bearing for the "no silent time-stretch"
// invariant, so it is factored here and unit-tested. The REAPER-bound placement
// (InsertMedia call, edit-cursor movement, undo block) lives in insert.cpp and is
// DAW-verified.
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
// only. The InsertMedia bitfield is easy to get wrong and its bits are
// load-bearing for the "no silent time-stretch" invariant, so it's factored here
// and unit-tested. The REAPER-bound placement (InsertMedia call, edit-cursor
// movement, undo block) lives in insert.cpp and is DAW-verified.
//
// The bit meanings below are transcribed VERBATIM from the authoritative header
// Bit meanings below are transcribed VERBATIM from the authoritative header
// doc-comment (vendor/reaper-sdk/sdk/reaper_plugin_functions.h, InsertMedia):
// mode: 0=add to current track, 1=add new track, 3=add to selected items as
// takes, &4=stretch/loop to fit time sel, &8=try to match tempo 1x,
// &16=try to match tempo 0.5x, &32=try to match tempo 2x,
// &64=don't preserve pitch when matching tempo, ...
// We intentionally use only the base target (0/1) and the tempo-conform bits
// (&8/&16/&32/&64). We NEVER set &4 (stretch/loop to fit time selection) — that is
// the silent-time-stretch path the tool forbids (CONTEXT.md §Non-goals).
// We use only the base target (0/1) and the tempo-conform bits (&8/&16/&32/&64).
// We NEVER set &4 (stretch/loop to fit time selection) — the silent-time-stretch
// path the tool forbids.
#include <cstdint>
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
// later concern (YAGNI). Both insert AT THE EDIT CURSOR — that is REAPER's
// convention for base modes 0/1 (the header names no explicit edit-cursor bit;
// see the flagged runtime assumption in insert.cpp).
// We expose only the two placement targets needed here; "add as takes" (3) is
// out of scope. Both insert at the edit cursor — REAPER's convention for base
// modes 0/1 (the header names no explicit edit-cursor bit; see the flagged
// runtime assumption in insert.cpp).
enum class InsertTarget {
NewTrack, // mode base 1: add a new track for the item
CurrentTrack, // mode base 0: add to the current/selected track
+22 -59
View File
@@ -10,9 +10,7 @@
namespace reasampler::capture {
double autoTrimEndRatio() {
// Amplitude ratio = 10^(dB/20). Derived from kAutoTrimThresholdDb so the dB is
// the single source of truth (header ~3062: RENDER_TRIMEND is an amplitude ratio,
// "0.5 means -6.02 dB"). For -72 dB this is ~= 0.00025119.
// Amplitude ratio = 10^(dB/20) (header ~3062). For -72 dB this is ~0.00025119.
return std::pow(10.0, kAutoTrimThresholdDb / 20.0);
}
@@ -20,8 +18,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
TailRenderSettings t;
switch (mode) {
case TailMode::None:
// Exact bounds — byte-identical to the pre-tail no-tail capture. Tail off,
// disable-all normalize (the current default), no trim.
// Exact bounds — byte-identical to the pre-tail capture.
t.tailFlag = kTailFlagNone;
t.tailMs = 0.0;
t.normalize = kNormalizeDisableAll;
@@ -29,12 +26,10 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
return t;
case TailMode::Auto:
// Generous 8 s tail, then SURGICAL normalize: ONLY the trim-ending-silence
// bit (32768) — every other postprocessing bit clear. A fixed-threshold
// trailing-silence trim is a pure boundary decision (it scales/limits/fades
// nothing), so it re-introduces none of the coloring the disable-all bit
// guarded against, and two identical requests trim at the identical sample
// -> bit-identical repeats hold (spec §surgical normalize).
// Surgical normalize: only the trim-ending-silence bit set, every other
// postprocessing bit clear. A fixed-threshold trim scales/limits/fades
// nothing, so identical requests trim at the identical sample -> holds
// the bit-identical-repeats invariant.
t.tailFlag = kTailFlagCustomBounds;
t.tailMs = kMaxTailMs;
t.normalize = kNormalizeTrimEnd;
@@ -42,10 +37,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
return t;
case TailMode::Manual:
// Fixed tail, no trim -> keep the disable-all normalize exactly as the
// no-tail path does. Clamp to the 8 s cap even here: the runaway guard
// applies whether the length came from the Auto default or an explicit
// request (spec §Manual override). Negative requests floor to 0.
// Clamped to the cap regardless of source; negative floors to 0.
t.tailFlag = kTailFlagCustomBounds;
t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs);
t.normalize = kNormalizeDisableAll;
@@ -60,14 +52,10 @@ double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
double manualTailMs) {
switch (mode) {
case TailMode::None:
// Exact no extra recording (byte-identical to today's realtime capture).
return rangeEndSeconds;
return rangeEndSeconds; // exact, no extra recording
case TailMode::Auto:
// The 8 s runaway cap past the range end; the decay-trim shortens it later.
return rangeEndSeconds + kMaxTailSeconds;
return rangeEndSeconds + kMaxTailSeconds; // runaway cap; decay-trim shortens later
case TailMode::Manual:
// Fixed window: range + the set length, clamped to the 8 s cap (the same
// runaway guard the offline Manual path applies). Negative floors to 0.
return rangeEndSeconds + std::clamp(manualTailMs, 0.0, kMaxTailMs) / 1000.0;
}
// Unreachable for a valid enum; fail closed to exact bounds (never a stray tail).
@@ -75,42 +63,36 @@ double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
}
RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
// `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future
// dry work (M10 null test), but it does not affect this mapping. FX scoping is
// handled by fxBypassPlanFor, not by these render bits.
// wetDry doesn't affect this mapping (seam for future dry work); FX scoping
// is handled by fxBypassPlanFor, not by these render bits.
RenderSettingsChoice c;
switch (mode) {
case SourceMode::MasterMix:
case SourceMode::TimeSelection:
// Master IS the mix — wet-only; &(1|2)==0, no source bits.
c.settings = kRenderMasterMix;
c.settings = kRenderMasterMix; // wet-only, no source bits
c.supported = true;
return c;
case SourceMode::SelectedTracks:
// Selected tracks via master (&128) — wet (post-FX). Header ~3041.
c.settings = kRenderSelTracksViaMaster;
c.supported = true;
return c;
case SourceMode::SelectedItems:
// Selected media items, rendered to ONE file (single-file bit) so a
// multi-item selection yields a single bank entry, not N wavs.
// Single-file bit so a multi-item selection yields one bank entry.
c.settings = kRenderSelItems | kRenderSingleFile;
c.supported = true;
return c;
case SourceMode::RazorArea:
// Render razor edits to ONE file (same single-file rationale as items).
c.settings = kRenderRazorEdits | kRenderSingleFile;
c.supported = true;
return c;
case SourceMode::Realtime:
// Not an offline-render source — the realtime backend (M8) owns it.
c.settings = kRenderMasterMix;
c.supported = false;
c.supported = false; // not an offline-render source
return c;
}
// Unreachable for a valid enum; fail closed (unsupported) rather than render.
@@ -135,17 +117,13 @@ FxBypassPlan fxBypassPlanFor(CaptureScope scope) {
FxBypassPlan p;
switch (scope) {
case CaptureScope::Item:
// Item = take/item FX ONLY. Bypass the item's own track FX, every
// ancestor's FX, and the master's FX. (Take FX live in the item and
// are always rendered — there is no track to bypass them from.)
// Take FX live in the item and are always rendered — bypass everything else.
p.bypassSelfFx = true;
p.bypassAncestorFx = true;
p.bypassMaster = true;
return p;
case CaptureScope::Track:
// Track = item FX + the selected track's OWN FX. Keep self FX; bypass
// every ancestor (parent/folder) and the master. Parent/master GAIN
// still applies (I_FXEN is FX-only) — documented boundary.
// Keep self FX; bypass every ancestor (parent/folder) and the master.
p.bypassSelfFx = false;
p.bypassAncestorFx = true;
p.bypassMaster = true;
@@ -158,24 +136,19 @@ std::vector<RazorRange> parseRazorEdits(const std::string& razorString) {
std::vector<RazorRange> ranges;
std::istringstream in(razorString);
// The string is space-separated TRIPLES: <start> <end> <envGuidString>.
// A track-audio area's third token is the literal two-char string `""`; an
// envelope-lane area's is a GUID `{…}`. We keep only track-audio triples.
std::string startTok, endTok, guidTok;
while (in >> startTok >> endTok >> guidTok) {
// Envelope-lane areas carry a real GUID; skip them (razor captures track audio only).
// A track-audio area's GUID token is the empty quoted string `""`.
// Skip envelope-lane areas (real GUID); keep only track-audio (`""`).
if (guidTok != "\"\"") continue;
// Parse the two time tokens. std::stod throws on garbage — guard so one
// malformed triple does not abort the whole parse.
// std::stod throws on garbage — guard so one malformed triple doesn't
// abort the whole parse.
double start = 0.0, end = 0.0;
try {
std::size_t sp = 0, ep = 0;
start = std::stod(startTok, &sp);
end = std::stod(endTok, &ep);
// Reject tokens with trailing garbage (e.g. "1.0x") — a partial parse
// is a malformed area, not a valid range.
// Reject trailing garbage (e.g. "1.0x") — a partial parse is malformed.
if (sp != startTok.size() || ep != endTok.size()) continue;
} catch (...) {
continue;
@@ -197,23 +170,13 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges) {
}
const std::vector<CaptureActionDef>& captureActionTable() {
// Built once (function-local static): two SCOPE actions, item + track. Both
// exact bounds by default; the tail mode a capture applies is read from the
// docked-panel setting at fire time (tail_control + bank_panel), so tail is NOT
// a per-action variant. Ids are FOREVER-STABLE — never edit a shipped string.
// Each action infers its range (razor-else-time) at fire time and enforces its
// FX-scope invariant via fxBypassPlanFor. The M7 CAPTURE_TRACKS_WET /
// CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids are RETIRED (mirror-unregistered in
// main.cpp); the CAPTURE_MASTER scope action is REMOVED (its id is likewise
// mirror-unregistered) — to capture the master you render a track.
// FOREVER-STABLE ids — never edit a shipped string. No master capture
// action (its id was retired; do not reintroduce it).
static const std::vector<CaptureActionDef> table = {
// Item scope — item/take FX only. Suffix + phrase are channel-agnostic; the shell
// composes the FOREVER-STABLE id (prefix + "CAPTURE_ITEM") and the display name.
{"CAPTURE_ITEM",
"capture selected item(s)", "item",
CaptureScope::Item},
// Track scope — item FX + the track's own FX.
{"CAPTURE_TRACK",
"capture selected track(s)", "track",
CaptureScope::Track},
+67 -148
View File
@@ -1,26 +1,9 @@
#pragma once
// render_settings — the REAPER-free logic behind the capture action family.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The capture shell (capture.cpp) and
// action layer (main.cpp) read the actual DAW state (time selection, selected
// tracks/items, razor strings, the ancestor-track chain) and hand the raw values
// here so the genuinely-pure, easy-to-get-wrong pieces are unit-tested outside
// the DAW:
//
// 1. sourceMode -> the RENDER_SETTINGS integer bit value (wet only).
// 2. a P_RAZOREDITS string -> the list of (start,end) ranges + their union bound.
// 3. range inference: razor-present -> razor union, else time selection. Range
// is a SOURCE choice orthogonal to the capture scope.
// 4. the FX-scope bypass plan: given a scope + an ancestor-chain length, which
// tracks' FX to bypass so each scope hears only the FX it should (the M7
// "items captured through parent FX" defect is corrected here).
// 5. the capture-action table (id string, description, scope) — the taxonomy,
// in one place so main.cpp iterates it instead of hand-listing.
//
// The RENDER_SETTINGS bit MEANINGS are transcribed verbatim from
// reaper_plugin_functions.h line ~3041 (see kRender* constants); the CHOICE of
// which bits each source mode sets is this module's logic and is tested.
// render_settings — the REAPER-free logic behind the capture action family:
// sourceMode -> RENDER_SETTINGS bits, P_RAZOREDITS parsing + range union,
// razor-else-time inference, the FX-scope bypass plan, and the capture-action
// table main.cpp iterates. Bit MEANINGS below are transcribed verbatim from
// reaper_plugin_functions.h; the CHOICE of which bits each mode sets is tested.
#include <string>
#include <vector>
@@ -32,67 +15,44 @@ namespace reasampler::capture {
using model::SourceMode;
// --- RENDER_SETTINGS source/processing bits (verbatim from SDK header ~3041) --
//
// Only the bits this module actually uses are named. Values are the documented bit
// weights; the DOC of each is the SDK header's, not a guess.
inline constexpr int kRenderMasterMix = 0; // (&(1|2))==0, no source bits
inline constexpr int kRenderSelItems = 32; // &32 selected media items
inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master
inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master
inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits
// NOTE: kRenderPreFaderStems (&8192) is NOT used. REAPER offline render has no
// true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-render
// mechanism (see fxBypassPlan below) — bypassing the FX-enable of the tracks that
// fall outside a scope — NOT by any render bit. All capture actions render wet
// (post the FX that remain enabled); the scope decides which FX remain enabled.
// kRenderPreFaderStems (&8192) is deliberately NOT used REAPER offline render
// has no true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-render
// mechanism (see fxBypassPlan below), not by any render bit. All capture actions
// render wet; the scope decides which FX remain enabled.
inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file
// --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ----------
//
// The capture-tail feature (docs/product/capture-tail.md) preserves reverb/release
// decay past the range end. Every offline capture renders custom-time-bounds, so
// the only tail-flag bit that ever applies is &1 (RENDER_TAILFLAG, header ~3047).
// These values are the pure part — mode -> (RENDER_* values) — unit-tested outside
// the DAW exactly like renderSettingsFor; the backend just applies them.
//
// RENDER_NORMALIZE bit meanings (verbatim from SDK header ~3051):
// &32768 = trim ending silence (the surgical Auto path)
// &(4<<16) = disable all render postprocessing (the None/Manual path)
// Every offline capture renders custom-time-bounds, so &1 (RENDER_TAILFLAG,
// header ~3047) is the only tail-flag bit that ever applies. RENDER_NORMALIZE
// (verbatim, header ~3051): &32768 = trim ending silence (Auto path);
// &(4<<16) = disable all render postprocessing (None/Manual path).
inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence
inline constexpr int kNormalizeDisableAll = (4 << 16); // &(4<<16) = 262144, disable all
// RENDER_TAILFLAG &1 = apply tail for custom time bounds (header ~3047). We render
// custom bounds unconditionally, so this is the only tail bit that ever applies.
inline constexpr int kTailFlagNone = 0;
inline constexpr int kTailFlagCustomBounds = 1; // &1
inline constexpr int kTailFlagCustomBounds = 1; // &1, header ~3047
// Auto-trim trailing-silence threshold. -72 dB is quiet enough that the trimmed
// region is inaudible decay, loud enough to not chase a reverb's infinite noise
// floor. Daniel-set. Single source of truth: the RENDER_TRIMEND ratio derives from
// this dB, never the reverse.
// Auto-trim trailing-silence threshold; single source of truth (RENDER_TRIMEND
// ratio derives from this dB, never the reverse). Daniel-set.
inline constexpr double kAutoTrimThresholdDb = -72.0;
// Max tail rendered past the range end. The runaway guard: a non-decaying or
// looping signal never crosses the trim threshold, so this caps the render.
// Daniel-set. Shared by the offline (T1) and future realtime (T2) tail paths.
// Runaway guard: max tail rendered past the range end, so a non-decaying or
// looping signal doesn't render forever. Daniel-set; shared by offline+realtime.
inline constexpr double kMaxTailSeconds = 8.0;
inline constexpr double kMaxTailMs = 8000.0;
// Derived linear amplitude ratio for RENDER_TRIMEND. The header (~3062) documents
// RENDER_TRIMEND as an amplitude ratio ("0.5 means -6.02 dB"), i.e. 10^(dB/20).
// Derived from kAutoTrimThresholdDb so the dB stays the single source of truth and
// a future config change to the dB does not require hand-recomputing the ratio.
//
// std::pow is not constexpr before C++26, so this is a function, not a constant.
// For -72 dB: 10^(-72/20) = 10^(-3.6) ~= 0.00025119 (the value the DAW confirm targets).
// Derived linear amplitude ratio for RENDER_TRIMEND (header ~3062: an amplitude
// ratio, "0.5 means -6.02 dB", i.e. 10^(dB/20)) from kAutoTrimThresholdDb.
// Function not constant: std::pow isn't constexpr before C++26.
double autoTrimEndRatio();
// The three tail states (docs/product/capture-tail.md §The three tail states):
// None — exact bounds, no tail. Byte-identical to the pre-tail capture. The
// default and the ONLY mode for null-test / verify captures.
// Auto — generous 8 s tail then trim trailing silence to -72 dB (surgical
// normalize). The user-facing tail-on option (panel toggle).
// Manual — a fixed tail length (clamped to the 8 s cap), no trim.
// The three tail states — see src/core/capture/CLAUDE.md.
enum class TailMode {
None,
Auto,
@@ -100,9 +60,9 @@ enum class TailMode {
};
// The RENDER_* values a tail mode drives, in addition to the exact STARTPOS/ENDPOS
// the backend already sets. `trimEnd` is meaningful only when the trim-end normalize
// bit is set (Auto); it is 0 otherwise. This is the pure mapping — the backend reads
// these four fields straight onto GetSetProjectInfo.
// the backend already sets. `trimEnd` is meaningful only when the trim-end
// normalize bit is set (Auto). The backend reads these straight onto
// GetSetProjectInfo.
struct TailRenderSettings {
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1)
double tailMs = 0.0; // RENDER_TAILMS
@@ -110,54 +70,36 @@ struct TailRenderSettings {
double trimEnd = 0.0; // RENDER_TRIMEND (only used when trim bit set)
};
// Maps a tail mode (+ the requested manual tail ms) to its RENDER_* values.
// `manualTailMs` is used ONLY for TailMode::Manual (ignored otherwise). Manual is
// clamped to kMaxTailMs — the runaway guard applies whether the length came from
// the Auto default or an explicit request (spec §Manual override). Pure + tested.
// Maps a tail mode (+ requested manual tail ms, used only for Manual) to its
// RENDER_* values. Manual is clamped to kMaxTailMs regardless of source.
TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs);
// The REALTIME record-window end (in project seconds) a tail mode records to, given
// the request's exact range end (docs/product/capture-tail.md §The realtime path).
// Realtime does NOT drive RENDER_*; it records a generous window and trims later, so
// the window end is where the transport actually stops:
// None -> rangeEndSeconds (exact — no extra recording).
// Auto -> rangeEndSeconds + kMaxTailSeconds (the 8 s runaway cap; trimmed later).
// Manual -> rangeEndSeconds + clamp(manualTailMs, kMaxTailMs)/1000 (fixed, no trim).
// `manualTailMs` is used ONLY for Manual. Pure so the mode->window arithmetic (and
// the Manual clamp) is unit-tested outside the DAW; the backend applies the returned
// end to the record time selection. Shared -72 dB / 8 s constants are the same ones
// the offline tail uses (single source of truth).
// The realtime record-window end (project seconds): realtime does NOT drive
// RENDER_*, it records a generous window and trims later, so this is where the
// transport actually stops. None -> exact rangeEndSeconds; Auto -> +8s runaway
// cap; Manual -> + clamp(manualTailMs, kMaxTailMs)/1000.
double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
double manualTailMs);
// The RENDER_SETTINGS value for a given source mode. `supported` is false only
// for SourceMode::Realtime (that is the M8 backend, not offline render).
// for SourceMode::Realtime (that backend doesn't use offline render).
struct RenderSettingsChoice {
int settings = kRenderMasterMix;
bool supported = true; // false => not an offline-render source (e.g. Realtime)
};
// Maps a source mode to its RENDER_SETTINGS value (which content the render
// covers). FX scoping is orthogonal done by fxBypassPlan, not by these bits.
// `wetDry` is accepted but ignored for the mapping — retained in CaptureRequest
// as the seam for future dry work (M10 null test).
//
// CONFIRMED (SDK header ~3041):
// MasterMix / TimeSelection -> master mix (0).
// SelectedTracks -> &128 selected tracks via master.
// SelectedItems -> &32 | single-file (one wav, not one-per-item).
// RazorArea -> &4096| single-file.
// covers); FX scoping is orthogonal (done by fxBypassPlan). `wetDry` is
// accepted but ignored — retained as the seam for future dry work. CONFIRMED
// (SDK header ~3041): MasterMix/TimeSelection -> 0; SelectedTracks -> &128;
// SelectedItems -> &32|single-file; RazorArea -> &4096|single-file.
RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry);
// --- Capture scope: the FX-scope invariant (Daniel, critical) ----------------
// --- Capture scope: the FX-scope invariant ------------------------------------
//
// Two FX scopes. The render RANGE (razor-else-time) is orthogonal to the scope.
// Item -> item/take FX ONLY (no track, no parent/folder, no master FX).
// Track -> item FX + the selected track's OWN track FX (no parent/folder/master).
// There is NO master scope: to capture the master you render a track instead. The
// master track's FX/gain/pan are still NEUTRALIZED as part of the out-of-scope
// chain for both item and track captures (bypassMaster below) — master is a
// bypass target, not a capture scope.
// See src/core/capture/CLAUDE.md for the scope contract. There is NO master
// scope; the master track's FX/gain/pan are still NEUTRALIZED as part of the
// out-of-scope chain (bypassMaster below) — master is a bypass target only.
enum class CaptureScope {
Item,
Track,
@@ -169,34 +111,28 @@ SourceMode sourceModeForScope(CaptureScope scope);
// --- Range inference: razor-else-time (orthogonal to scope) -------------------
//
// Every scope action infers its render range the same way: if a razor area is
// present, use the razor union; otherwise use the time selection. Razor is a
// range SOURCE, not a capture mode (the M7 four-mode model conflated them).
// Razor-present -> razor union; otherwise time selection. Razor is a range
// source, not a capture mode.
enum class RangeSource {
Razor, // a razor area is present -> use its union bound
TimeSelection, // no razor -> use the time selection
};
// Picks the range source. Pure so the "razor wins when present" rule is tested
// without a DAW; the shell supplies whether any razor area was found.
// Picks the range source. Pure so "razor wins when present" is tested without
// a DAW; the shell supplies whether any razor area was found.
RangeSource inferRangeSource(bool hasRazorArea);
// --- FX-bypass plan: which tracks' FX to bypass for a scope -------------------
//
// Given a CaptureScope, returns three boolean flags: whether to bypass (a) the
// captured track's OWN FX, (b) each of its ancestor (parent/folder) tracks' FX,
// and (c) the master FX. The caller (FxBypassGuard) resolves these flags to
// concrete MediaTrack* by walking the ancestor chain via GetParentTrack and
// clears I_FXEN on each flagged track, snapshotting first (RAII restore).
//
// SCOPE BOUNDARY: I_FXEN bypasses a track's FX plugins but NOT its volume/pan.
// The guard (FxBypassGuard, main.cpp) therefore ALSO neutralizes the fader GAIN
// (D_VOL -> unity) of every track in this same bypass set, so a Track/Item
// capture rendered via master does NOT bake in the parent/folder/master fader
// level (Daniel: the capture is likely re-routed through that chain later). PAN
// is deliberately left untouched (D_PAN is coupled to D_WIDTH/D_PANLAW — a clean
// neutralize is non-trivial; flagged as a follow-up, not half-done). This plan
// selects the SET; the guard applies both the FX bypass and the gain neutralize.
// Given a CaptureScope, returns three boolean flags: bypass (a) the captured
// track's OWN FX, (b) every ancestor (parent/folder) track's FX, (c) the
// master FX. The caller (FxBypassGuard, shell) walks the ancestor chain via
// GetParentTrack, clears I_FXEN on each flagged track (RAII restore), and also
// neutralizes D_VOL/D_PAN/D_WIDTH/D_PANLAW/I_PANMODE to unity/center on the
// same set (I_PANMODE is load-bearing: in pan mode 6, D_PAN/D_WIDTH are
// ignored entirely, so forcing it is what makes the other neutralizations
// take effect) — I_FXEN alone doesn't touch a track's volume/pan. This plan
// selects the set; the guard applies both the FX bypass and the neutralize.
struct FxBypassPlan {
bool bypassSelfFx = false; // the captured track's own FX
bool bypassAncestorFx = false; // every ancestor (parent/folder) track's FX
@@ -213,38 +149,24 @@ struct RazorRange {
};
// Parses ONE track's P_RAZOREDITS string (SDK header ~2899): space-separated
// TRIPLES of <start> <end> <envGuidString>. The envelope GUID is "" (an empty
// quoted string, i.e. the literal two chars `""`) for a track-audio area and a
// GUID like {…} for an envelope-lane area.
//
// Returns only the track-audio ranges (envelope-lane triples are skipped — razor
// captures target track audio, not envelope lanes). Malformed/short trailing tokens are ignored, not fatal.
// A range with end <= start is dropped (no negative/empty areas leak through).
// TRIPLES of <start> <end> <envGuidString>, envGuid == `""` for a track-audio
// area vs a GUID for an envelope-lane area. Returns only track-audio ranges
// (envelope-lane triples skipped); malformed trailing tokens are ignored, not
// fatal; a range with end <= start is dropped.
std::vector<RazorRange> parseRazorEdits(const std::string& razorString);
// The union bound (min start, max end) of a set of razor ranges — the exact
// window the offline render must cover so every area is inside the rendered file.
// Returns {0,0} for an empty input (caller treats that as "no razor area").
// window the offline render must cover. {0,0} for empty input ("no razor area").
RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges);
// --- Capture-action taxonomy (the bindable set main.cpp registers) -----------
//
// One row per bindable SCOPE action: item and track. The range each captures
// (razor-else-time) is inferred at fire time, not a mode. TAIL is NOT a per-action
// variant — the tail MODE (None/Auto/Manual) is a panel SETTING the capture reads
// at fire time (see tail_control + bank_panel), so a single pair of actions covers
// every tail state. Bounded, discoverable, NO dialogs (the tool's no-clutter ethos).
// One row per bindable scope action (item/track); range inference and tail
// mode are read at fire time, not baked into the row. The row stores only the
// channel-agnostic command-id SUFFIX + description PHRASE; the registering
// shell composes the full channel-qualified id/name via app_version.
//
// Phase V (V4): the row stores the channel-AGNOSTIC pieces — a command-id SUFFIX (the
// tail after the family prefix) and a description PHRASE (the label after the "ReaSampler:
// " lead). The registering shell composes the full, channel-qualified id/name via
// app_version's channelCommandId / channelActionName (commandIdPrefix + suffix /
// actionDisplayPrefix + phrase). This keeps the pure table free of any channel branch:
// stable rebuilds the exact shipped id "CEREBELLUM_REASAMPLER_CAPTURE_TRACK" from
// prefix + "CAPTURE_TRACK"; beta yields "CEREBELLUM_REASAMPLER_BETA_CAPTURE_TRACK".
//
// commandSuffix is FOREVER-STABLE (user keybindings key off the composed id) — never
// change a shipped value. baseName feeds the file stem (sanitized by capture_paths).
// commandSuffix is FOREVER-STABLE (user keybindings key off the composed id).
struct CaptureActionDef {
const char* commandSuffix; // e.g. "CAPTURE_TRACK" — FOREVER-STABLE (composed w/ prefix)
const char* descriptionPhrase; // e.g. "capture selected track(s)" — Actions-list phrase
@@ -252,14 +174,11 @@ struct CaptureActionDef {
CaptureScope scope; // FX scope (item / track)
};
// The capture-action table. Iterated by main.cpp to register the family and route
// each fired command back to its definition. Kept here (pure) so the taxonomy is
// one testable list, not scattered registration code.
// The capture-action table. Iterated by main.cpp to register the family and
// route each fired command back to its definition.
//
// Two rows: CAPTURE_ITEM / CAPTURE_TRACK. There is no master capture — to capture
// the master you render a track. Razor is an inferred range, not a mode, and each
// scope enforces its FX-scope invariant via fxBypassPlanFor. The tail mode each
// capture applies is read from the docked-panel setting, not baked into the row.
// Two rows: CAPTURE_ITEM / CAPTURE_TRACK. There is no master capture — to
// capture the master you render a track.
const std::vector<CaptureActionDef>& captureActionTable();
} // namespace reasampler::capture
+6 -18
View File
@@ -1,4 +1,4 @@
// tail_control — pure implementation. See tail_control.h. NO REAPER / SWELL / vendor.
// tail_control — pure implementation. See tail_control.h.
#include "core/capture/tail_control.h"
@@ -19,14 +19,10 @@ TailMode cycleTailMode(TailMode current) {
}
double clampManualMs(double manualMs) {
// Same runaway guard the pure tailRenderSettingsFor applies to Manual: floor a
// negative request to 0, cap at the 8 s ceiling.
return std::clamp(manualMs, 0.0, kMaxTailMs);
}
double adjustManualMs(double current, int notches, double stepMs) {
// Clamp the stepped value so both scroll directions saturate at the bounds rather
// than running away (the same [0, kMaxTailMs] guard clampManualMs enforces).
return clampManualMs(current + notches * stepMs);
}
@@ -35,8 +31,8 @@ std::string tailToggleLabel(const TailSetting& setting) {
case TailMode::None: return "Tail: Off";
case TailMode::Auto: return "Tail: Auto";
case TailMode::Manual: {
// Append the CLAMPED length in seconds to one decimal so the readout can
// never show an over-cap value even if manualMs was stored past the cap.
// Clamped so the readout can't show an over-cap value even if
// manualMs was stored past the cap.
const double seconds = clampManualMs(setting.manualMs) / 1000.0;
char buf[32];
std::snprintf(buf, sizeof(buf), "Tail: Manual %.1fs", seconds);
@@ -46,17 +42,9 @@ std::string tailToggleLabel(const TailSetting& setting) {
return "Tail: Off"; // unreachable for a valid enum; fail to the safe default
}
// ---------------------------------------------------------------------------
// JSON round-trip
// ---------------------------------------------------------------------------
//
// 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.
// --- JSON round-trip ---------------------------------------------------------
// manualMs round-trips exactly (json::numToStr uses the shortest %.17g-class
// form for doubles); deserialize returns nullopt on any parse failure.
namespace {
+19 -39
View File
@@ -1,13 +1,7 @@
#pragma once
// tail_control — the REAPER-free logic behind the docked bank_panel's tail-mode
// 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.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
// only (plus render_settings for the pure TailMode enum). Builds and unit-tests
// without REAPER.
// toggle. The panel shell owns the SWELL window, LICE drawing, and click
// hit-testing; the cycle order, manual-length clamp, and label text live here.
#include <optional>
#include <string>
@@ -16,54 +10,40 @@
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
// project with no stored tail setting (older / never-adjusted) falls back to on load.
// The Manual-mode starting length: 2s, a musically useful default (a bar of
// reverb throw at moderate tempo), well under the 8s cap. Also the fallback
// for a project with no stored tail setting.
inline constexpr double kDefaultManualTailMs = 2000.0;
// The fine-adjust step per scroll-wheel notch in Manual mode. 250 ms is coarse enough
// that a few notches cover the useful range, fine enough to dial a length precisely.
// Daniel-set. The panel maps one wheel notch to +/- this many ms via adjustManualMs.
// Fine-adjust step per scroll-wheel notch in Manual mode. Daniel-set.
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 (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.
// The panel's current tail setting: mode + the length used only when Manual.
// Default None so a capture with no explicit choice stays exact-bounds.
// `manualMs` is clamped to kMaxTailMs before it ever reaches a CaptureRequest.
struct TailSetting {
TailMode mode = TailMode::None;
double manualMs = kDefaultManualTailMs;
};
// Cycles the tail mode: None -> Auto -> Manual -> None. Pure so the wrap order is
// pinned by a test and the panel's click handler owns no enum arithmetic of its own.
// An out-of-range value (unreachable for a valid enum) cycles back to None.
// Cycles the tail mode: None -> Auto -> Manual -> None.
TailMode cycleTailMode(TailMode current);
// The effective manual length a Manual capture uses: `manualMs` clamped to
// [0, kMaxTailMs] (the runaway guard the pure tailRenderSettingsFor also applies).
// Exposed so the panel can show the clamped value and main.cpp hands a pre-clamped
// tailMs into the CaptureRequest. Meaningful only for TailMode::Manual.
// The effective manual length a Manual capture uses: clamped to [0, kMaxTailMs].
// Exposed so the panel can show the clamped value. Meaningful only for Manual.
double clampManualMs(double manualMs);
// Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped to
// [0, kMaxTailMs]. Positive notches lengthen, negative shorten. Pure so the fine-adjust
// arithmetic (and its clamp at both bounds) is unit-tested; the panel wheel handler
// owns no arithmetic of its own. Meaningful only for TailMode::Manual.
// Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped
// to [0, kMaxTailMs]. Meaningful only for TailMode::Manual.
double adjustManualMs(double current, int notches, double stepMs);
// The toggle's label for a setting, e.g. "Tail: Off", "Tail: Auto". In Manual mode the
// clamped length is appended in seconds to one decimal, e.g. "Tail: Manual 2.0s"
// Off/Auto carry no length. Pure so the exact strings (and the Manual format) are
// test-pinned, including the boundary lengths (0.0s, 8.0s).
// The toggle's label, e.g. "Tail: Off", "Tail: Auto", or (Manual, clamped
// length to one decimal) "Tail: Manual 2.0s".
std::string tailToggleLabel(const TailSetting& setting);
// JSON round-trip of a TailSetting (mode + manualMs), for persist to store the tail
// setting per-project alongside the bank and view model. Kept pure/testable here —
// the natural home, mirroring bank_model's serialize/deserialize. serialize emits a
// compact object; deserialize returns std::nullopt on malformed input so the caller
// (persist) falls back to a default setting, exactly as an absent key does.
// JSON round-trip of a TailSetting, for persist to store per-project. Pure/
// testable here, mirroring bank_model's serialize/deserialize; deserialize
// returns nullopt on malformed input so the caller falls back to a default.
std::string serializeTailSetting(const TailSetting& setting);
std::optional<TailSetting> deserializeTailSetting(const std::string& json);
+23 -45
View File
@@ -1,7 +1,6 @@
// 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.
// wav_codec — pure implementation. See wav_codec.h. The one RIFF chunk
// traversal lives here (nextWavChunk); layout parse and content hash both
// walk with it, so their view of the container cannot drift.
#include "core/capture/wav_codec.h"
@@ -12,8 +11,7 @@ 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.
// Little-endian readers. Caller checks bounds before each read (off + N <= size).
std::uint16_t readU16LE(const std::vector<std::uint8_t>& b, std::size_t off) {
return static_cast<std::uint16_t>(b[off] | (b[off + 1] << 8));
}
@@ -28,7 +26,7 @@ bool tagEquals(const std::vector<std::uint8_t>& b, std::size_t off, const char*
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).
// WAVE format tags we accept as 32-bit float (see wav_codec.h).
constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003;
constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE;
@@ -37,19 +35,16 @@ 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];
char buf[17]; // 16 hex digits, zero-padded
std::snprintf(buf, sizeof(buf), "%016llx", static_cast<unsigned long long>(h));
return std::string(buf);
}
// --- The ONE RIFF chunk traversal --------------------------------------------
// --- 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.
// One sub-chunk of a RIFF/WAVE container: header at `headerOffset` (id(4) +
// size(4)), body at `bodyOffset`/`bodySize`. `bodyInBounds` false means the
// declared body runs past the buffer — still reported, but must not be read.
struct WavChunkView {
std::size_t headerOffset = 0;
std::size_t bodyOffset = 0;
@@ -60,9 +55,8 @@ struct WavChunkView {
// 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.
// would overrun the buffer, the chunk is still reported (return true) and `pos`
// is parked past the end so the next call returns false.
bool nextWavChunk(const std::vector<std::uint8_t>& bytes, std::size_t& pos,
WavChunkView& out) {
if (pos + 8 > bytes.size()) return false;
@@ -100,8 +94,8 @@ WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
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.
// Walk the sub-chunks after "WAVE" (offset 12). A malformed/truncated file
// is "invalid", never an OOB read.
std::size_t pos = 12;
WavChunkView c;
while (nextWavChunk(bytes, pos, c)) {
@@ -112,11 +106,9 @@ WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
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).
// WAVE_FORMAT_EXTENSIBLE: the real format lives in the SubFormat GUID's
// leading 2-byte tag at body offset 24, not in fmtTag itself. Body must
// reach offset 24+16; otherwise leave the tag at 0 (rejected).
if (fmtTag == kWaveFormatExtensible) {
if (c.bodySize >= 40 && c.bodyOffset + 40 <= bytes.size()) {
extensibleSubFormatTag = readU16LE(bytes, c.bodyOffset + 24);
@@ -124,16 +116,13 @@ WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
}
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).
// Reject if the declared body runs past the buffer (truncated/lying
// header), or if data arrived before fmt.
if (!c.bodyInBounds) return out;
if (!haveFmt) return out; // data before fmt — not a WAV we parse
if (!haveFmt) return out;
// 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.
// Extensible tag (0xFFFE) is float only when its SubFormat sub-tag is
// also IEEE-float (0x0003) — PCM-integer-in-extensible must be rejected.
const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) ||
(fmtTag == kWaveFormatExtensible &&
extensibleSubFormatTag == kWaveFormatIeeeFloat);
@@ -279,12 +268,6 @@ std::string hashBytes(const std::uint8_t* data, std::size_t len) {
}
std::string hashWavContent(const std::vector<std::uint8_t>& 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) {
@@ -295,23 +278,18 @@ std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
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<std::uint8_t>('W'));
feedByte(static_cast<std::uint8_t>('W')); // domain-separation prefix
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]);
+38 -103
View File
@@ -1,39 +1,9 @@
#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.
// wav_codec — the pure owner of the WAV/RIFF byte format: chunk walker, layout
// parse, float32 build, size-field patch, and the WAV-aware content hash — one
// chunk traversal shared by all of them so hashing and decoding cannot desync.
// Handles 32-bit float WAV only (RIFF/WAVE, `fmt ` tag 3 or 0xFFFE-extensible
// w/ float subformat, float32 `data`); anything else parses as invalid.
#include <cstddef>
#include <cstdint>
@@ -49,22 +19,20 @@ 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.
// bytes are not a WAV we can safely trim; 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)
std::uint16_t channelCount = 0; // from `fmt ` (interleave stride)
std::uint32_t sampleRate = 0;
// The `data` chunk: byte offset of its first PCM byte within the file, and its
// declared PCM byte length. frameCount = dataByteLength / (channelCount * 4).
// The `data` chunk: PCM byte offset + declared 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).
// Offsets of the two LE uint32 size fields the truncate patch rewrites.
std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file
std::size_t dataSizeFieldOffset = 0;
@@ -74,19 +42,15 @@ struct WavLayout {
}
};
// 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).
// Parses a WAV byte buffer's header geometry; {valid=false} for anything not a
// canonical float32 RIFF/WAVE, or a `data` length running past the buffer.
// Does not copy PCM, only locates it. Pure + total (no throw, no UB).
WavLayout parseWavLayout(const std::vector<std::uint8_t>& 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.
// Copies `frameCount` interleaved float frames starting at `startFrame` out of
// the WAV's `data` region into a flat [f0c0,f0c1,...] buffer, clamped to frames
// actually present; never reads past `data`. Reads little-endian via memcpy —
// target is x86/ARM-LE only, no big-endian byte-swap.
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
const WavLayout& layout,
std::size_t startFrame,
@@ -94,10 +58,8 @@ std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& byt
// --- 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).
// The plan to truncate a parsed WAV to `keptFrames` frames. `valid` is false if
// the layout is invalid or keptFrames exceeds the file's frames (never grow).
struct WavTruncatePlan {
bool valid = false;
@@ -109,64 +71,37 @@ struct WavTruncatePlan {
// 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.
// Computes the truncate plan to keep exactly `keptFrames` frames. The shell
// applies it: patch the two size fields (patchU32LE), then truncate 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).
// Patches a little-endian uint32 into a byte buffer at `off`. Caller guarantees
// off + 4 <= bytes.size() (the plan's offsets came from a valid parse of the same
// buffer).
void patchU32LE(std::vector<std::uint8_t>& 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.
// Builds a minimal canonical float32 RIFF/WAVE byte buffer from interleaved
// double samples (narrowed to float by cast). Round-trips through
// parseWavLayout/extractFloatFrames.
std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
std::size_t frameCount,
const std::vector<double>& 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).
// Deterministic FNV-1a 64-bit content hash over `len` bytes, as 16-char lowercase
// hex. Fills Sample::contentHash for the confirm-on-last-reference dedup guardrail.
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.
// WAV-aware content hash: hashes only the `fmt ` body + `data` payload, skipping
// other chunks. WHY: REAPER's offline renderer embeds a render-varying `bext`
// timestamp chunk even with no BWF metadata requested, so two renders of
// identical audio would otherwise hash differently and never dedup. Prefixed
// with tag byte 'W' so it can't collide with a same-size hashBytes result.
// Falls back to whole-file hashBytes (no prefix) for a file that doesn't parse.
std::string hashWavContent(const std::vector<std::uint8_t>& bytes);
} // namespace reasampler::capture
+223
View File
@@ -0,0 +1,223 @@
# src/core/instrument — pure VST3-instrument core (engine / map / ui)
## Scope
The ReaSampler 9000 instrument's pure, REAPER-free, VST3-free, unit-tested core, in three
subdirectories:
- **`engine/`** — the polyphonic voice engine, per-zone play params, pitch shifting,
velocity curve, and master-gain taper math.
- **`map/`** — the zone/keymap payload, the cross-artifact `ComponentState` codec, and the
small pure helpers the engine/shell share (bank-generation sync, bridge-read
marshalling, note-name parsing, Trigger frame↔fraction conversion).
- **`ui/`** — pure editor geometry/hit-test modules (layout, waveform, keyboard strip,
capture browser, param controls, envelope overlay/edit). These are geometry-and-math
only; the LICE draw + REAPER/VST3 plumbing is the `shell/instrument` editor shell,
**out of scope for this file** (owned by a parallel dispatch), along with the VST3
processor, `reaper_bridge`, `reasampler_embed`, and `vst_entry`.
## Invariants
### The three locked decisions this spec assumes (settled 2026-07-26)
- **D1 — native VST3.** Not JSFX. Full sampler sophistication, clean integration, and
access to the REAPER VST-host bridge.
- **D5 — Windows-only, VST3-only, REAPER-only.** No cross-platform DSP/build/signing
matrix, no multi-format wrapper, no standalone-in-other-hosts concern.
- **D6 — two products, tightly integrated.** A separate artifact, but not a divorced
file-only companion: via the VST-host bridge it reads the live `"reasampler"` project
ext-state and is project-aware. (The bridge mechanism itself is documented in
`src/core/wire/CLAUDE.md`.)
### The two seams (audio via files, mapping via live state)
- **File seam (audio, permanent).** The sample **audio** is the on-disk 32-bit-float
WAVs — project-relative, travelling with the `.rpp`. The instrument resolves those
paths the same way `persist` does (a shared convention, not a re-implementation).
There is no live PCM stream across the bridge, by design.
- **Live-state seam (the mapping, via the bridge).** For everything that is not raw
audio — the bank index, the mapping, which project is active — the instrument reads
the live `"reasampler"` ext-state via the bridge.
### The seam fields — what becomes a bank intrinsic (D-B, settled 2026-07-26)
The split model is the settled answer, mirroring the capture/placement separation:
- **Bank intrinsics (facts about the captured file) live on `Sample`.** Root note (the
MIDI note the sample was recorded at) and loop points (sustain-loop start/end for held
notes) are facts about the file, added as an additive field extension (same shape as
`provenance`).
- **The performance map (a creative arrangement) lives in the instrument.** Key zones,
velocity layers, round-robin groups, amplitude envelopes, and per-sample tuning/gain
trim are a performance choice, not a fact about a file — they belong to the instrument,
not the bank. This "who owns which field" rule (D-B) governs every performance-map
field added since, including play mode/AHDSR/Trigger params (S15), pitch engine mode
and pitch envelope (S16), key-tracking, preview velocity, and the velocity curve
(S-VIEW) — all are per-instance/per-zone `ComponentState`, never written to `Sample` or
the bank.
### The pure core (D3 — the load-bearing split)
The sampler's voice engine, envelope math, key/velocity mapping, repitch/interpolation,
and keymap resolution are a pure, REAPER-free, DAW-free, unit-tested module — the mirror
of `bank_model`/`peaks`/`view_mode_model`/`bank_book`. The VST3 wrapper (the
`SingleComponentEffect` subclass, bus setup, `process` marshalling, the `IPlugView` LICE
editor, and the bridge calls) is the thin shell — the only part that touches VST3 or
REAPER at all. Any VST3 or REAPER type leaking into this core is a bug.
- **The bank is one source; the instrument is another view of it (never a fork).** The
instrument is a pure consumer of the bank — it does not copy samples, does not own a
private sample store, and does not mutate the bank.
- **`Sample` field additions are additive and lossless.** No existing `Sample` field
changes; no `BankIndex` behavior changes.
- **Relative-paths-only survives.** The instrument resolves audio via the project-relative
machinery; it introduces no absolute paths.
### Channel mode — current reality
**Current reality (root `CLAUDE.md`, GA post-launch pass): the output bus is
permanently stereo.** `ChannelMode` is decode-only; the dynamic mono↔stereo bus
renegotiation (`setBusArrangements` per-instance toggle) has been deleted. Channel mode
auto-defaults from the loaded capture's channel count via a pure `channelModeFor` helper,
gated by a persisted `channelModeExplicit` flag (`ComponentState` v9). Mono source +
stereo mode → dual-mono (same signal both channels, centered); stereo source + mono mode
→ downmix (existing decode-side policy).
> **Superseded design, do not reintroduce:** an earlier "Channel mode — mono |
> stereo (D-E)" design specified a per-instance toggle that **dynamically
> renegotiates the REAPER audio bus** via `setBusArrangements`/`getBusArrangement`
> (the instrument reporting mono or stereo per instance and REAPER's routing
> following). That dynamic-bus-negotiation design was superseded by the GA fix
> above; root `CLAUDE.md` is current and wins.
### Sampling modes — Gate vs Trigger, pitch engine, pitch envelope (S15/S16 — settled, landed)
Daniel's directive (2026-07-26, verbatim): *"Sampling mode: Trigger vs Gate. Gate has an
AHDSR envelope. Trigger has fade in, % length, and fade out. Both modes have modifiable
start point, Gate has modifiable loop points too. In addition to amp env, there will be a
pitch envelope/curve (AD?) which is off by default."*
- **Gate — classic held note.** Note-on enters the amp envelope; note-off enters
release; a sustain loop applies for held notes. Envelope is **AHDSR**: `0→1` over
attack, hold at 1 over `holdFrames`, `1→sustain` over decay, hold sustain until
note-off, `level→0` over release. `holdFrames == 0` is exactly the pre-Gate ADSR — a
back-compat degenerate.
- **Trigger — one-shot drum-pad.** Note-on fires playback of a defined `%` of sample
length with a fade-in and fade-out ramp; note-off is ignored (the voice plays through,
no sustain loop). Frame span `[startFrame, playEnd)` where `playEnd = startFrame +
round(lengthFraction·(frames startFrame))`; amplitude ramps `0→1` over
`fadeInFrames` at the head and `1→0` over `fadeOutFrames` anchored to `playEnd`; fades
clamp so `fadeInFrames + fadeOutFrames ≤ play length`. Fade curve is equal-power
(constant-power sin/cos). **Note-off in Trigger is a no-op** — choke-on-note-off is
held/out of scope (fork S15-F1).
- **Both modes: modifiable start point.** Playback begins at `startFrame` (clamped `0 ≤
startFrame < frames`). Gate additionally has modifiable loop points; Trigger has none.
- **Pitch engine — Varispeed vs Preserve (per-zone toggle, S16).** Varispeed (current/
classic path): `ratio_ = pitchRatio(note,root)`, `readPos_ += ratio_` with linear
interp — resampling that couples pitch and duration; cheap, zero-latency, musically
right for drums/one-shots. Preserve (duration-preserving): the read advances at the
source rate while a pitch shifter transposes the output — musically right for
tempo-locked loops/phrases; **the engine default leans Preserve** (fork S16-F1).
Contract for Gate's sustain loop under Preserve: *loop the source, shift the output*
(loop points stay source-frame facts). `WDL_Resampler` is **not** a Preserve engine (it
is a resampler that couples duration) — never wire it as the duration-preserving path.
- **Pitch envelope — AD, off by default.** A short attack-decay pitch-offset curve
(`peakSemitones` over `attackFrames`, decaying to 0 over `decayFrames`) riding on top of
whichever pitch engine; a zero attack gives a pure percussive pitch drop. **Off by
default** — a regression that applies pitch modulation when the envelope is disabled is
a bug. Under Varispeed the offset is a per-frame multiply of `ratio_`; under Preserve it
is added to the shifter's shift amount.
- **Preserve RT discipline.** The shifter pre-warms at voice-allocation; no allocation in
`process()` in steady state. **Note (supersedes an earlier framing):** the
shifter's onset latency (~25 ms, half-window) was once described as "an
accepted property, not a defect." Root `CLAUDE.md`'s GA2 pass **eliminated** that onset
latency (ring buffer primed with the actual upcoming source at note-on instead of
zero-filled, so Preserve now speaks on frame 0, matching Varispeed) — a
cold-started/un-pre-warmed shifter producing a click or smear remains a bug.
- **S15/S16 stay channel-count-agnostic.** The mode/envelope logic is per-frame amplitude
and read-rate, independent of the stereo channel dimension — any S15/S16 code that
assumes a fixed (mono) channel count rather than operating per-frame pre-mix is a bug.
- **S15/S16 are Tier 01 engine features, not Tier 2/3** — do not let the held Tier-2
feature list (velocity layers / round-robin / filter work) drive their build shape.
### Non-goals / guardrails (instrument-specific; repo-wide invariants live in root CLAUDE.md)
- **No cross-platform / multi-format.** Windows-only, VST3-only, REAPER-only (D5). Do not
add an AU/AAX/VST2/CLAP wrapper, a mac/Linux build, or a standalone host target.
- **The pure core stays REAPER-free *and* VST3-free.** Any VST3 or REAPER type leaking
into the voice engine / envelope / keymap / repitch module is a bug (the D3 split).
- **Channel mode is a performance choice, not a bank fact.** Never written to `Sample` or
the bank.
- **Do not spec Tier 2/3** from this directory. Tier 2 is held, Tier 3 is
optional-forever; don't let their feature lists drive Tier 01's build shape.
### Envelope overlay + draggable nodes (S-VIEW, settled 2026-07-27, landed)
The amp envelope is drawn as a curve over the Sample view's hero waveform at the shared
time base — Gate → the AHDSR shape, Trigger → the fade-in/unity/%-length/fade-out shape
anchored to `playEnd`. **The overlay is directly editable — draggable nodes
(SETTLED, S-VIEW-F2).** Dragging a node and the existing sliders are two surfaces onto
one model: both read/write the same zone envelope fields, so a drag updates the params,
the sliders reflect them live, and a slider edit re-lays the nodes — one source of truth,
structural (re-read-every-paint), not a listener chain. Nodes are monotonic in time (a
node cannot be dragged past its neighbours) and range-clamped to the same per-param
min/max the sliders enforce, so node-drag can never produce a param the slider couldn't.
Two pure modules split the forward (draw) and inverse (edit) maps — see `envelope_overlay`
and `envelope_edit` in Modules below.
### New performance-map parameters — ownership and persistence (D-B)
- **Key-tracking** — per-zone, additive/version-bumped component state, default 100%
(absent field on an older blob lifts to 100%, bit-identical playback).
- **Preview velocity** — a per-instance utility setting for the Sample view's
preview-trigger button (not a musical parameter of the capture); **persists across
reloads** via the instrument's own `ComponentState` (envelope-bumped), never via the
extension's `persist` ext-state module (that would make it project-global rather than
per-instance and leak an instrument concern into the extension's key space).
- **Velocity curve** — per-zone; the one non-back-compat surface in S-VIEW: an
already-saved zone with no stored curve now plays every velocity at unity under the
flat-default (Option A), not bit-identical to the old linear `velocity/127` mapping —
a deliberate, Daniel-approved behavior change (see `velocity_curve` in Modules).
## Modules
### `engine/`
- `sampler_core` — polyphonic voice engine with bounded stealing, user-parameterized voice count (132, 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.
- `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()`.
- `velocity_curve` — pure velocity→amp transfer curve: `VelocityCurve` evaluated by a FritschCarlson monotone cubic Hermite spline (no overshoot outside [0,1]). `eval(velocity)` called once per note-on. `flat()` default (y=1, every velocity→unity) replaces the prior fixed `velocity/127` path — a deliberate non-back-compat behavior change (Daniel-approved).
- `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.
### `map/`
- `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.
- `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.
- `note_entry` — parses a raw string into a clamped MIDI note [0,127]; accepts plain decimal integers or note names (C4==60, DAW convention).
- `trigger_seam` — pure Trigger frames↔fraction converter: owns the shared formula for converting between engine source-frame fade counts and the overlay's fractional representation, threading `startFrame` correctly through pack and unpack directions.
### `ui/`
- `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.
- `browser_scroll` — scroll + type-to-filter layered over `capture_browser`: vertical scroll offset, scrollbar thumb, thumb-drag mapping, and name-substring search.
- `param_slider` — parameter control-panel: vertical stack of TOGGLE (two-segment selector) and SLIDER (horizontal track) rows; maps normalized value to/from handle pixel.
- `embed_strip` — compact single-row control layout for embed mode in the track FX chain.
- `knob_deck` — pure knob-deck layout + hit-test (FB1): group-box / caption-row / compact-toggle / knob-cell geometry, deterministic whole-group wrap, `DeckLayout` / `DeckHit`. Mirror of `action_bar`/`param_slider`; no LICE or REAPER types.
- `curve_popup` — pure curve-popup geometry + dismissal test (FB1): centered sheet over the Sample face — width/height clamps, title row, Close button rect, curve-box rect, outside-sheet dismissal test. Mirror of `overflow_menu`; no LICE or REAPER types.
- `envelope_overlay` — pure amp-envelope→polyline geometry for the Sample-view envelope overlay (read from `envelope_overlay.h`): maps Gate's AHDSR shape or Trigger's fade-in/unity/%-length/fade-out shape to a polyline inside a rect at the shared time base (Gate: a bounded param-domain schematic, sample-length-free; Trigger: PCM-aligned wall-clock), every vertex clamped in-canvas (`x`/`y` inside the rect). Shares the `EnvNode`/`AmpEnvelope`/`timeToX`/`levelToY` vocabulary with `envelope_edit` so the drawn handle and its grab region agree pixel-for-pixel. No VST3/REAPER/LICE types at the boundary.
- `envelope_edit` — pure node hit-test + pixel-delta→clamped-param inverse map for the draggable envelope nodes (read from `envelope_edit.h`): `nodeAtPoint` resolves a grab to the nearest node within a pick radius (Chebyshev distance, draw-order tie-break); `resolveNodeDrag` maps a pixel delta since grab to a new `AmpEnvelope`, enforcing monotonic-in-time ordering between neighbouring nodes and the same caller-supplied per-param clamp bounds the sliders use — a drag can never produce a param a slider couldn't. Mirror of `card_drag`/`waveform_view`; the inverse of `envelope_overlay`'s params→polyline forward map, so node-drag and slider-edit read/write one shared model and can never diverge.
## Gotchas
- **Gate's envelope-overlay x-axis is schematic, not PCM-aligned** (per `envelope_overlay.h`'s FA2 contract note) — it does NOT line up with the waveform under it; only Trigger's x-axis is wall-clock/PCM-aligned. Don't assume the Gate curve is time-accurate against the sample.
- **Trigger's fade fields require a non-trivial converter, not a field copy.** `TriggerParams` (engine) stores fades as source *frames*; `AmpEnvelope` (the overlay's view struct) stores them as *fractions* of the played span. A converter is owed on both the pack (draw) and unpack (commit) directions — `trigger_seam` owns this formula; do not copy the fields directly.
- **`param_slider`'s linear slider rows are retired on the Zone panel** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (`editor_geometry`/knob deck grammar) is now the only live consumer of that half of `param_slider` on the Zone face. Don't assume `param_slider`'s SLIDER row type is still drawn there.
- **Two superseded designs are called out in Invariants above**: the earlier
Channel-mode (D-E) bus-renegotiation design and the earlier Preserve-onset-latency
framing in the S16 guardrails. Root `CLAUDE.md` is the current source of truth
for both — do not reintroduce either superseded design.
+1 -1
View File
@@ -11,7 +11,7 @@
namespace reasampler::instrument::engine {
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
using util::clamp01;
double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); }
+15 -35
View File
@@ -1,19 +1,9 @@
// master_gain.h — PURE dB<->linear<->knob-taper math for the FB1 post-mixer master gain.
// NO VST3, NO REAPER, NO SWELL/LICE types. The mirror of trigger_seam: one tiny module owns
// the ONE formula both sides of a seam share — here the editor's Gain knob (normalized 0..1)
// and the processor's stored/applied linear gain — so the drawn needle, the persisted value,
// and the audio-thread multiply can never drift.
//
// THE CONTROL (Daniel, FB1). A post-mixer master gain, range -inf .. +24 dB, dB-scaled taper
// with -inf at the BOTTOM of the knob: normalized 0 maps to TRUE ZERO linear gain (silence,
// not a tiny epsilon), and the remaining travel maps linearly in dB from kMasterGainMinDb
// (the finite taper floor) up to kMasterGainMaxDb. Unity (0 dB) sits at norm
// kMasterGainMinDb/(kMasterGainMinDb - kMasterGainMaxDb) ~= 0.714 — most of the throw is
// usable trim, the last stretch is boost. The PERSISTED value is the LINEAR gain (a plain
// finite double, 0 = silence — no -inf on the wire); the taper is a UI-side view of it.
//
// RT DISCIPLINE: the processor applies the linear gain as one multiply over the summed
// output — these functions run on the UI/state threads only.
// master_gain.h — dB<->linear<->knob-taper math for the post-mixer master gain.
// One shared formula so the drawn needle, the persisted value, and the audio-thread
// multiply can't drift. Norm 0 = true zero gain (not an epsilon); persisted value is
// linear gain, the dB taper is a UI-side view of it. Unity (0 dB) sits at ~0.714 norm.
// RT: the processor applies the linear gain as one multiply over the summed output;
// these functions themselves run on UI/state threads only.
#pragma once
@@ -21,38 +11,28 @@
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.
// 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.
inline constexpr double kMasterGainMinDb = -60.0;
inline constexpr double kMasterGainMaxDb = 24.0;
// The largest linear gain the control can produce (kMasterGainMaxDb as a ratio, ~15.849).
// Largest linear gain the control can produce (kMasterGainMaxDb as a ratio, ~15.849).
double masterGainMaxLinear();
// Knob taper: normalized [0,1] -> dB. norm <= 0 -> -infinity; else the linear-in-dB sweep
// [kMasterGainMinDb, kMasterGainMaxDb]. norm is clamped to [0,1]. Pure.
double masterGainDbFromNorm(double norm);
// Inverse taper: dB -> normalized [0,1]. -infinity (or any dB at or below kMasterGainMinDb,
// including below-floor values like -80 dB) maps to norm 0 (the -inf bottom detent) — the
// finite sweep only covers the range above kMasterGainMinDb; everything at or below it collapses
// to the same true-zero bottom. +24 -> 1. Pure.
// Anything at or below kMasterGainMinDb (including -inf) collapses to norm 0 — the finite
// sweep only covers the range above the floor.
double masterGainNormFromDb(double db);
// Knob taper composed with dB->ratio: normalized [0,1] -> LINEAR gain. norm 0 -> exactly
// 0.0 (true silence); norm 1 -> masterGainMaxLinear(). Pure.
double masterGainLinearFromNorm(double norm);
// Inverse: LINEAR gain -> normalized [0,1]. linear <= 0 -> 0 (the -inf bottom); a linear at
// or below the kMasterGainMinDb floor (e.g. 0.001 = -60 dB, or anything below) also maps to 0
// — the floor IS the -inf detent; values between true-zero and the floor cannot be represented
// on the knob and collapse to the bottom. unity -> ~0.714; masterGainMaxLinear() -> 1.
// Out-of-range/non-finite input clamps. Pure.
// linear <= 0, or at/below the kMasterGainMinDb floor, collapses to norm 0 — values between
// true-zero and the floor aren't representable on the knob. Out-of-range/non-finite clamps.
double masterGainNormFromLinear(double linear);
// The knob's hover/drag value label for a normalized value: "-inf" at the bottom, else a
// signed one-decimal dB string ("-12.0dB", "+0.0dB", "+2.4dB"). Writes at most `len` bytes
// including the terminator. Pure.
// "-inf" at the bottom, else a signed one-decimal dB string ("-12.0dB", "+2.4dB").
// Writes at most `len` bytes including the terminator.
void formatMasterGainLabel(double norm, char* buf, std::size_t len);
} // namespace reasampler::instrument::engine
+84 -121
View File
@@ -1,24 +1,15 @@
// pitch_shift — pure implementation. See pitch_shift.h for the contract, the S16-F2
// route-(b) rationale (WDL drags <windows.h>), and the GA-Preserve root cause that replaced
// the naive dual-tap OLA with correlation-aligned splices.
// NO VST3 / REAPER / SWELL / vendor includes; standard library only.
// pitch_shift — pure implementation. See pitch_shift.h for the contract and regression history.
//
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per input
// sample (source rate -> duration preserved). ONE active read tap advances by the shift
// sample (source rate, duration preserved). One active read tap advances by the shift
// `ratio_` per frame, so its delay behind the writer drifts at (1 - ratio) per frame. When
// that delay leaves the safe band [dLow, dHigh], the tap is RELOCATED by a nominal jump of
// one window (+window toward older content for up-shifts, -window toward the writer for
// down-shifts) — CLAMPED to the filled span so it can never land in unwritten silence (the
// GA2 onset fix) — refined by a cross-correlation search over +/- maxLag PLUS a parabolic
// peak interpolation for a SUB-SAMPLE lag, so the relocated read point is waveform-aligned
// to a fraction of a sample (integer-lag splices left +/-0.5-sample errors: a -59 dB
// sideband comb at the splice cadence on a repitched pure sine — the GA2 "alias lines" on
// the spectrogram). Old and new taps then crossfade over fadeFrames with a raised-cosine,
// amplitude-complementary pair (in-phase content sums to exactly unity gain). For a pure
// sine the correlation snaps the jump to an (integer + fraction) period count, so the output
// stays a single tone at the shifted frequency — the GA-Preserve acceptance bar. At unity
// 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.
// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump of
// one window — clamped to the filled span so it never lands in unwritten silence — refined
// by a cross-correlation search over +/- maxLag plus a parabolic peak interpolation for a
// sub-sample lag (an integer-only lag left +/-0.5-sample errors: a sideband comb at the
// splice cadence on a repitched pure sine). Old and new taps then crossfade over fadeFrames
// with a raised-cosine, amplitude-complementary pair (in-phase content sums to unity gain).
// At unity ratio the delay is frozen mid-band and no splice ever fires.
#include "core/instrument/engine/pitch_shift.h"
@@ -55,17 +46,15 @@ void PitchShifter::configure(std::int64_t windowFrames) {
ringLen_ = 2 * window_;
ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f);
// Geometry (all quarters of the window):
// - fadeFrames_: the NOMINAL splice crossfade. This window/4 length is only safe when
// the outgoing tap cannot reach the writer before the fade ends; splice() scales the
// live fade length (fadeLen_) down by the current ratio for up-shifts past ~2x, so
// ordinary sampler transpositions (+24 st = ratio 4) never read stale data mid-fade.
// - maxLag_: the alignment search half-range — one window/4 covers a full period of any
// tone down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
// - dLow_/dHigh_: the safe delay band; unity parks the tap mid-band (window/2 delay).
// - corrFrames_: the correlation segment length. At an up-splice the reference segment
// reads FORWARD from the tap at delay ~dLow_, so dLow_-1 frames is exactly what exists
// between the tap and the writer — the cap expresses that safety rather than leaving
// it coincidental. 512 bounds the splice burst.
// - fadeFrames_: nominal splice crossfade; only safe while the outgoing tap can't reach
// the writer before the fade ends. splice() scales fadeLen_ down by ratio for up-shifts
// past ~2x so ordinary transpositions (+24 st) never read stale data mid-fade.
// - maxLag_: alignment search half-range — one window/4 covers a full period of any tone
// down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
// - dLow_/dHigh_: safe delay band; unity parks the tap mid-band (window/2 delay).
// - corrFrames_: at an up-splice the reference segment reads forward from the tap at
// delay ~dLow_, so dLow_-1 is exactly what exists between tap and writer; 512 bounds
// the splice burst.
fadeFrames_ = std::max<std::int64_t>(window_ / 4, 1);
maxLag_ = window_ / 4;
dLow_ = window_ / 4;
@@ -77,10 +66,9 @@ void PitchShifter::configure(std::int64_t windowFrames) {
void PitchShifter::reset() {
if (window_ > 1) {
// Zero the ring and seed the active tap one window behind the writer — the exact
// middle of the safe band [dLow, dHigh] = [w/4, 2w - w/4], so unity holds it there
// forever and either shift direction has maximal drift room. No history is declared
// (filled_ = 0): follow with prime() or warm() before streaming.
// Seed the active tap one window behind the writer — the exact middle of the safe
// band [dLow, dHigh], so unity holds it there forever with maximal drift room either
// direction. No history declared (filled_ = 0): follow with prime() or warm().
std::fill(ring_.begin(), ring_.end(), 0.0f);
writePos_ = 0;
posA_ = static_cast<double>(ringLen_ - window_);
@@ -104,15 +92,12 @@ void PitchShifter::reset() {
void PitchShifter::freezeTail() {
if (window_ <= 1 || tailFrozen_) return;
tailFrozen_ = true;
// An in-flight crossfade was sized for a RETREATING writer (outgoing tap drains at
// ratio-1 per frame); frozen, the outgoing tap closes at the full ratio. Cap the live
// fade so it completes before tap B reaches the parked writer and reads lapped (oldest-
// window) content mid-fade. fadePos_ is re-anchored to the same fractional t so gNew is
// continuous at the freeze frame (no gain step); see the re-anchor block below.
// An in-flight crossfade was sized for a retreating writer (outgoing tap drains at
// ratio-1 per frame); frozen, it closes at the full ratio instead. Cap the live fade so
// it completes before tap B reaches the parked writer and reads lapped content mid-fade.
if (fading_) {
// Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the
// freeze frame (no gain step). Compute tOld BEFORE overwriting fadeLen_, then
// re-anchor fadePos_ to the same fractional position in the new (shorter) fade.
// freeze frame (no gain step). Compute tOld before overwriting fadeLen_.
const double tOld =
static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
double dB = static_cast<double>(writePos_) - posB_;
@@ -133,8 +118,8 @@ void PitchShifter::freezeTail() {
void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
if (window_ <= 1) return; // pass-through needs no priming
// Clamp to one window: the intended call primes exactly window() frames, and delay ==
// count must stay inside the safe band so the seed does not itself trigger a splice.
// Clamp to one window: delay == count must stay inside the safe band so the seed itself
// never triggers a splice.
if (count < 0) count = 0;
if (count > window_) count = window_;
std::fill(ring_.begin(), ring_.end(), 0.0f);
@@ -189,24 +174,21 @@ double PitchShifter::readTap(double pos) const {
}
void PitchShifter::splice(std::int64_t nominalJump, double delay) {
// Relocate the active tap by `nominalJump` frames of ADDED delay (+window_ = jump toward
// older content, -window_ = jump toward the writer), refined by a correlation search so
// the relocated read point is waveform-aligned with the outgoing tap's upcoming content.
// The search is coarse (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best,
// then a parabolic sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_
// multiply-adds, once per splice.
// Relocate the active tap by `nominalJump` frames of added delay (+window_ = toward older
// content, -window_ = toward the writer), refined by a correlation search so the relocated
// read point is waveform-aligned with the outgoing tap's upcoming content. Search is coarse
// (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best, then a parabolic
// sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_ multiply-adds, once
// per splice.
const std::int64_t d = static_cast<std::int64_t>(delay);
// GA2 onset fix: an up-jump may only relocate into VALID history. The deepest slot the
// 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 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.
// An up-jump may only relocate into valid history. The deepest slot the search (plus the
// parabola's +/-1 probe and the interpolator's read-ahead) can touch is d + jump + maxLag + 2,
// so the cap is filled_ - d - maxLag_ - 1 (one sample looser than that derived bound, not
// extra margin — ring indexing wraps via modulo everywhere regardless). In steady state
// (filled_ == ringLen_) this exceeds window_ and the nominal jump is untouched; near a primed
// onset it shrinks the jump to what real history exists. The floor of 1 only fires on the
// degenerate reset-without-prime path.
std::int64_t jump = nominalJump;
if (jump > 0) {
const std::int64_t maxJump = filled_ - d - maxLag_ - 1;
@@ -233,12 +215,10 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
if (++ia >= ringLen_) ia = 0;
if (++ic >= ringLen_) ic = 0;
}
// NORMALIZED cross-correlation (standard SOLA): a raw dot product is biased toward
// the higher-energy lag, so on a decaying tail every up-splice would prefer the
// loudest candidate over the best-ALIGNED one — a small level step per splice that
// the amplitude-complementary fade cannot hide. The reference segment's energy is
// constant across lags, so dividing by sqrt(Ec) alone ranks identically to the full
// normalized form. A zero-energy candidate scores 0 (splicing into silence is benign).
// Normalized cross-correlation: a raw dot product biases toward the higher-energy lag,
// so on a decaying tail every up-splice would prefer the loudest candidate over the
// best-aligned one. The reference segment's energy is constant across lags, so dividing
// by sqrt(Ec) alone ranks identically to the full normalized form.
return ec > 0.0 ? s / std::sqrt(ec) : 0.0;
};
@@ -261,13 +241,11 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
}
}
// SUB-SAMPLE peak (GA2 alias fix): the integer-lag best leaves a residual misalignment of
// up to half a sample; at the splice cadence that residual phase-modulates a pure tone
// into a ~-59 dB sideband comb (the DAW spectrogram "alias lines"). A parabola through
// the scores at bestLag-1/bestLag/bestLag+1 locates the correlation peak to a fraction of
// a sample; readTap()'s linear interpolation realizes the fractional tap position. The
// denominator is negative at a genuine peak — anything else (flat correlation: DC or
// silence) keeps the integer lag, which is already benign there.
// Sub-sample peak: the integer-lag best leaves a residual misalignment of up to half a
// sample, which at the splice cadence phase-modulates a pure tone into an audible sideband
// comb. A parabola through the scores at bestLag-1/bestLag/bestLag+1 locates the peak to a
// fraction of a sample; readTap()'s linear interpolation realizes it. The denominator is
// negative at a genuine peak — flat correlation (DC/silence) keeps the integer lag, benign.
double frac = 0.0;
{
const double sM = scoreAt(bestLag - 1);
@@ -287,22 +265,17 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
while (p < 0.0) p += len;
while (p >= len) p -= len;
posA_ = p;
// RATIO-SCALED fade length. At an up-splice the OUTGOING tap starts at ~dLow_ delay and
// keeps draining toward the writer at (ratio - 1) per output frame; the nominal window/4
// fade only keeps it behind the writer for ratios up to 2. Beyond that (e.g. +24 st =
// ratio 4, an ordinary sampler transposition) it would cross mid-fade and play stale
// read-ahead data at substantial gain — a periodic seam. So cap the live fade at the
// frames of drain headroom actually available, minus 2 (1 for the trigger's sub-dLow_
// undershoot, 1 for the interpolator's read-ahead). Ratios <= ~2 keep the full nominal
// fade; ratio 4 gets ~window/12 — shorter but still a smooth burst. Down-shifts grow the
// outgoing delay at (1 - ratio) < 1 per frame and cannot reach the ring end within
// window/4 frames, so they always keep the full fade. A pitch-envelope ratio slew
// mid-fade is covered by the same margin for any realistic per-frame bias.
// Ratio-scaled fade length. At an up-splice the outgoing tap keeps draining toward the
// writer at (ratio - 1) per frame; the nominal window/4 fade only keeps it behind the
// writer for ratios up to 2 — beyond that (e.g. +24 st = ratio 4) it would cross mid-fade
// and play stale read-ahead data. Cap the live fade at the drain headroom actually
// available, minus 2 (trigger undershoot + interpolator read-ahead margin). Down-shifts
// drain at (1 - ratio) < 1 per frame and can't reach the ring end within window/4 frames,
// so they always keep the full fade.
//
// TAIL-FROZEN (GA3): with the writer parked, the outgoing tap closes on it at the FULL
// ratio (there is no retreating write head), in EITHER shift direction — so the drain
// rate is ratio_ instead of (ratio_ - 1), and the cap applies at every ratio (unity
// included: splices fire in the frozen tail because the delay now drains at unity too).
// Tail-frozen: with the writer parked, the outgoing tap closes on it at the full ratio in
// either shift direction, so the drain rate is ratio_ instead of (ratio_ - 1) and the cap
// applies at every ratio (including unity, since delay now drains at unity too).
fadeLen_ = fadeFrames_;
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0);
if (drainRate > 0.0) {
@@ -316,16 +289,15 @@ 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.
// Record the decision for a linked follower channel — applied verbatim there so both
// channels share one lag and one splice schedule.
lastSplice_ = SpliceEvent{true, jump, bestLag, frac, fadeLen_};
}
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.
// Follower half of the linked lag: relocate + fade with the master's decision, no
// correlation search of our own — the master's jump/fade derive from shared geometry +
// ratio, which match ours by the lockstep contract (identical configure/prime/ratio history).
posB_ = posA_;
double p = posA_ - static_cast<double>(ev.jump) + static_cast<double>(ev.lag) + ev.frac;
const double len = static_cast<double>(ringLen_);
@@ -347,24 +319,21 @@ AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& maste
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).
// Copy the linked decision before clearing lastSplice_ (guards a self-aliased pointer).
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
// NOTHING (the ring keeps its all-real final two windows) and hold the write head;
// the read/splice/fade machinery below runs unchanged over the frozen content.
// Tail-frozen: the source is exhausted, `in` is padding, not stream — write nothing (the
// ring keeps its all-real final two windows) and hold the write head; read/splice/fade
// below run unchanged over the frozen content.
if (!tailFrozen_) {
ring_[static_cast<std::size_t>(writePos_)] = in;
if (filled_ < ringLen_) ++filled_;
}
// 2. Read the active tap; while a splice fade is live, crossfade against the outgoing tap.
// Raised-cosine COMPLEMENTARY gains (gNew + gOld == 1): correlation-aligned content is
// in phase, so the sum holds unity amplitude through the fade (equal-power would bulge).
// Read the active tap; while a splice fade is live, crossfade against the outgoing tap.
// Raised-cosine complementary gains (gNew + gOld == 1): correlation-aligned content is in
// phase, so the sum holds unity amplitude through the fade (equal-power would bulge).
double out = readTap(posA_);
if (fading_) {
const double t = static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
@@ -372,22 +341,17 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
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).
// Follower: no trigger test, no search — splice exactly when and how the master did
// this frame (lockstep means our own trigger would have fired the same frame anyway).
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.
// Self-healing fallback: if the processor ever renders a mono block mid-note, this
// follower is skipped for that block while the master keeps advancing, and could
// never resync via the `linkedEv.fired` path alone. So also check this follower's
// own tap distance against the safe band and splice via its own search when it has
// left [dLow_, dHigh_] — never triggers in the normal (non-mono-block) case, since
// the master's trigger always fires first.
double d = static_cast<double>(writePos_) - posA_;
const double len = static_cast<double>(ringLen_);
while (d < 0.0) d += len;
@@ -399,10 +363,10 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
}
}
} 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-
// shifts grow it toward the ring length -> jump one window TOWARD the writer. At
// unity the delay is frozen at window/2 and neither trigger ever fires.
// Splice scheduling: relocate when the active tap's delay leaves the safe band.
// Up-shifts drain the delay toward 0 -> jump one window older; down-shifts grow it
// toward the ring length -> jump one window toward the writer. At unity the delay is
// frozen at window/2 and neither trigger ever fires.
double d = static_cast<double>(writePos_) - posA_;
const double len = static_cast<double>(ringLen_);
while (d < 0.0) d += len;
@@ -414,8 +378,7 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
}
}
// 4. Advance heads: write head one frame (source rate; parked while tail-frozen),
// tap(s) by the shift ratio.
// Advance heads: write head one frame (parked while tail-frozen), tap(s) by the shift ratio.
if (!tailFrozen_) {
++writePos_;
if (writePos_ >= ringLen_) writePos_ = 0;
+86 -144
View File
@@ -1,66 +1,34 @@
#pragma once
// pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve"
// engine's DSP core. Time-domain delay-line shifter with CORRELATION-ALIGNED SPLICES
// (SOLA-style): one active read tap chases the write head at the shift ratio; when it drifts
// out of its safe delay band it is relocated by a nominal window jump REFINED BY A
// CROSS-CORRELATION SEARCH so the new read point is waveform-aligned, then the old and new
// taps are crossfaded (raised-cosine, amplitude-complementary). Source is consumed 1:1 and
// output produced 1:1 (duration held); only the PITCH changes — an octave up plays the same
// wall-clock length as the root note, unlike the Varispeed `readPos_ += ratio_` resample path.
// pitch_shift — per-voice, duration-preserving pitch shifter (the Preserve engine's DSP core).
// Time-domain delay-line with correlation-aligned splices (SOLA-style): one active read tap
// chases the write head at the shift ratio; when it drifts out of its safe delay band it is
// relocated by a nominal window jump, refined by a cross-correlation search so the new read
// point is waveform-aligned, then old/new taps crossfade (raised-cosine). Source and output are
// both consumed/produced 1:1 — only pitch changes, duration is held (unlike the Varispeed
// `readPos_ += ratio_` resample path).
//
// WHY CORRELATED SPLICES (GA-Preserve fix, 2026-07). The first S16 implementation was the
// naive two-tap OLA: taps hard-locked half a window apart, Hann-crossfaded by write-head
// distance. Its taps read the same stream at delays differing by exactly w/2, so their outputs
// carried a FIXED relative phase of 2*pi*f_src*(w/2) — arbitrary and source-frequency-
// dependent. Near anti-phase (roughly half of all frequencies) every crossfade midpoint
// nearly CANCELLED: deep periodic AM + phase slew = strong sidebands. A repitched pure sine
// came out mangled ("multiple partials" on a spectrogram) while the root stayed clean (unity
// freezes the crossfade). The fix is structural: splices must be PHASE-ALIGNED, so each jump
// is snapped to the best waveform match within a bounded lag search — a pure sine's jump
// lands on an integer period count and the output stays a single shifted tone.
// Regression history — do not revert any of these:
// - Correlated splices, vs. the original two-tap OLA (taps hard-locked w/2 apart, Hann
// crossfaded by write-head distance): that fixed offset gave the two taps a fixed relative
// phase, so near-anti-phase source frequencies (roughly half of them) nearly cancelled at
// every crossfade midpoint — a repitched pure sine came out mangled while unity stayed clean.
// Splices must be phase-aligned (snapped to the best waveform match), not just distance-fired.
// - Hand-rolled, not WDL_SimplePitchShifter: its include chain pulls <windows.h> unconditionally,
// which cannot enter this REAPER/VST3-free core (sampler_core_tests links neither SDK). Swap
// to WDL, if ever wanted, happens at the shell, never in this pure core.
// - prime() fills the ring with real upcoming source before streaming starts, not silence: a
// silence-warmed ring made every early splice land in zeros — burst/gap/burst stutter at
// note onset. Since the caller owns the whole decoded sample up front, prime() can know the
// future and gives output frame 0 == source frame 0 with zero structural latency at any ratio.
// - freezeTail() parks the write head once the source is exhausted instead of feeding the last
// real sample as a DC plateau: splices against a flat plateau are unalignable and produced
// ring-modulation-like troughs near the note end. Freezing keeps late splices aligned against
// the ring's real frozen tail.
//
// WHY A HAND-ROLLED PURE MODULE, NOT WDL (S16-F2, decided at build). The spec's lean was
// route (a) `WDL_SimplePitchShifter`. But its include chain
// (simple_pitchshift.h -> queue.h -> heapbuf.h -> wdltypes.h) does `#ifdef _WIN32 ->
// #include <windows.h>` 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_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.
//
// WHY PRIME WITH REAL CONTENT (GA2-Preserve onset fix, 2026-07). Splices RELOCATE the tap
// into ring HISTORY — at note onset a silence-warmed ring has none, so every early splice
// jumped into zeros: a burst/gap/burst stutter for the first ~2 windows of every off-root
// note (the DAW "zero-sample gaps in the first few ms"; at +48 st the ~300 Hz gap cadence
// reads as a square-ish buzz). But this engine is NOT a streaming context: the caller owns
// the whole decoded sample, so the FUTURE of the stream is known at note-on. `prime()`
// pre-fills the ring with the actual first window of upcoming source and parks the tap on
// its oldest frame — output frame 0 IS source frame 0 (zero structural latency at every
// ratio), and `splice()` clamps its jump to the really-filled span so no splice can ever
// land in unwritten silence.
//
// WHY FREEZE THE TAIL (GA3-Preserve tail fix, 2026-07). GA2's prime fixed the ONSET; the
// mirror problem lived at the note END. When the source ran out, the caller held the LAST
// REAL SAMPLE as the feed — a DC plateau with no waveform for the correlation to align on.
// Splices landing in or referenced against it were unalignable, so the tap alternated
// real-tone / dead-DC at the splice cadence, the dead fraction growing as the plateau
// displaced real ring history (the DAW report: periodic troughs "almost like ring
// modulation", ~1:20 tone-to-silence at the very end). freezeTail() removes the padding at
// the source: the WRITER parks, the ring keeps its all-real final two windows, and the
// aligned-splice machinery recycles that frozen tail — a continuous tone until the caller's
// own note end. See freezeTail() below.
//
// 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_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
// (bounded, allocation-free — safe on the audio thread at note-on). `process()` does
// NO allocation and NO locks — it reads/writes the pre-sized ring only. The splice-time
// correlation search is a bounded burst of multiply-adds (coarse+refine over a fixed lag
// range) that fires once per splice cadence (window / |ratio-1| frames), never per frame.
// RT discipline: configure() sizes the ring once, off the audio thread. prime()/warm() only
// copy into the pre-sized ring (bounded, allocation-free). process() does no allocation and no
// locks; the correlation search is a bounded burst that fires once per splice cadence
// (window / |ratio-1| frames), never per frame.
#include <cstddef>
#include <cstdint>
@@ -72,100 +40,76 @@ 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
// 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.
// The splice decision made by the most recent process()/processLinked() call — the linked-lag
// stereo contract. 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 — independent per-channel searches drew an inter-channel
// offset of up to +/-maxLag at every splice, causing stereo image wander and comb coloration on
// a 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 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, LINKED: channel 0 is the master, channel 1 follows its splice
// decisions via processLinked() (see SpliceEvent above) so the two rings stay sample-aligned.
// A per-channel time-domain splice-aligned pitch shifter. A stereo voice owns two, linked:
// channel 0 is the master, channel 1 follows its splice decisions via processLinked() so the
// two rings stay sample-aligned.
//
// 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
// byte-identical to the pre-S16 engine.
// Default-constructed is inert: with no configure() it passes input through unchanged (ratio
// 1.0, empty ring), so a Varispeed voice that never touches it sees no behavior change.
class PitchShifter {
public:
// Size the delay ring for `windowFrames` (the nominal splice-jump length; the ring is 2x
// that for splice/search headroom) and derive the fade/search geometry. `windowFrames`
// <= 1 degrades to pass-through (no ring), so a degenerate configure never divides by
// zero or wraps a zero span. Called OFF the audio thread (allocates). Resets all running
// state. A larger window = fewer splices and a deeper alignment search; a PRIMED shifter
// has no added latency regardless (see prime()); the shell picks it from kPreserveWindowMs.
// Sizes the delay ring for `windowFrames` (the ring is 2x that for splice/search headroom).
// <= 1 degrades to pass-through. Off the audio thread (allocates); resets all running state.
// A larger window means fewer splices and a deeper alignment search; a primed shifter has no
// added latency regardless of window size (see prime()).
void configure(std::int64_t windowFrames);
// Pre-fill the ring with the first `count` frames of the UPCOMING source stream and park
// 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()].
// 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.
// Pre-fills the ring with the first `count` frames of the upcoming source stream and parks
// the tap on src[0]; the caller then feeds process() the stream continuing at src[count].
// `count` is clamped to [0, window()]. If the playable source is shorter than one window,
// prime only the real span and call freezeTail() immediately after — never pad with silence
// and declare it valid; padded zeros are splice targets and reintroduce the onset gap.
// RT-safe: bounded copy, no allocation. No-op when unconfigured; 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
// declare that window of silence as valid history. Kept for callers with no access to the
// upcoming stream (a silence-primed up-shift plays ~a window of silence before speaking
// the pre-GA2 onset; the Voice path uses prime() instead). At unity a warmed shifter is a
// bit-exact window() delay. No-op when unconfigured.
// Silence-prime: zero the ring, park the tap one window behind the writer, declare that
// window silence as valid history. Kept for callers with no access to the upcoming stream
// (a silence-primed up-shift plays ~a window of silence before speaking; the Voice path uses
// prime() instead). At unity a warmed shifter is a bit-exact window() delay.
void warm();
// The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias.
// 1.0 = no shift (pass-through-equivalent output, no splices ever fire). Set per frame is
// fine (cheap); the tap advance simply uses the current value. Values <= 0 are ignored
// (kept at the last valid ratio) so a bad input never runs the tap backward or stalls it.
// 2^((note - root)/12) plus any per-frame pitch-envelope bias; 1.0 = no shift, no splices
// ever fire. Cheap enough to set per frame. Values <= 0 are ignored (kept at the last valid
// ratio) so a bad input never runs the tap backward or stalls it.
void setShiftRatio(double ratio);
// Transform ONE input frame into ONE output frame (duration-preserving: 1 in, 1 out).
// RT-safe: reads/writes the pre-sized ring only, no allocation, no lock. When unconfigured
// (window <= 1) returns `in` unchanged (pass-through). Otherwise writes `in` at the write
// head, reads the active tap (crossfading against the outgoing tap while a splice fade is
// live), then advances the write head by one and the tap(s) by the shift ratio. When the
// active tap leaves its safe delay band, a correlation-aligned splice is scheduled.
// Transforms one input frame into one output frame (1 in, 1 out). RT-safe: reads/writes the
// pre-sized ring only, no allocation, no lock. Unconfigured returns `in` unchanged. Otherwise
// writes `in` at the write head, reads the active tap (crossfading against the outgoing tap
// during a splice fade), then advances the write head and tap(s). When the 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().
// Follower-mode process: identical to process() except the splice decision isn't computed
// here — when `master.fired` is true this frame splices with exactly the master's
// jump/lag/frac/fadeLen. The caller must process the master channel first each frame and
// pass its lastSplice() here; both shifters must be configured/primed/ratio'd identically so
// their ring state advances in lockstep. 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
// the ring's frozen (all-real) final two windows. WHY: the pre-GA3 tail held the last
// real sample as the feed — a DC plateau with no waveform to correlate on. Splices
// landing in or referenced against it were unalignable, so the tap alternated real-tone /
// dead-DC at the splice cadence (the DAW "ring modulation" troughs, growing toward the
// note end as the plateau displaced real history). With the writer frozen the padding
// never enters the ring: every splice stays waveform-aligned against real content and
// the output remains a continuous tone — the final <= one window recycles the frozen
// tail (correlation-aligned, crossfaded) instead of decaying into chopped DC, and the
// caller's own note end (its output-frame anchor) bounds how long that lasts. Idempotent;
// RT-safe (flag + bounded arithmetic, no allocation); cleared by reset()/prime()/warm().
// Call once the source stream is exhausted — no real frame remains to feed process().
// Freezes the write head: subsequent process() calls ignore input and write nothing, but
// read/splice/crossfade as before over the ring's frozen (all-real) final two windows, so
// every late splice stays waveform-aligned against real content instead of a DC plateau.
// Idempotent; RT-safe (flag + bounded arithmetic); cleared by reset()/prime()/warm().
void freezeTail();
bool tailFrozen() const { return tailFrozen_; }
@@ -188,8 +132,8 @@ private:
// 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_.
// Applies a master channel's already-computed splice decision verbatim (no search) —
// the follower half of the 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).
@@ -210,21 +154,19 @@ private:
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4)
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so
// the reference read forward from the tap stays behind
// the writer BY CONSTRUCTION at an up-splice)
// the writer by construction at an up-splice)
std::int64_t dLow_ = 0; // splice trigger: active-tap delay below this (up-shift)
std::int64_t dHigh_ = 0; // splice trigger: active-tap delay above this (down-shift)
std::int64_t filled_ = 0; // frames of VALID history behind the writer (prime count
// + frames streamed, capped at ringLen_). splice() clamps
// its up-jump to this so no splice lands in unwritten
// silence — the GA2 onset-gap fix.
std::int64_t filled_ = 0; // frames of valid history behind the writer; splice()
// clamps its up-jump to this so it never lands in
// unwritten silence
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
// at ratio_ (not ratio_-1) per frame — splice() scales the
// live fade by that rate.
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame; cleared
// at the top of every frame, set on a splice
bool tailFrozen_ = false; // writer frozen (source exhausted); tap recycles the
// frozen real tail, drains toward the writer at ratio_
// (not ratio_-1) per frame — splice() scales the fade
// by that rate
};
} // namespace reasampler::instrument::engine
+104 -152
View File
@@ -1,16 +1,11 @@
// 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.
// sampler_core — pure sampler engine implementation. See sampler_core.h for the contract.
//
// 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).
// Documented hot-path exception to the ~600-line file ceiling: this TU deliberately stays
// whole. AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called
// per-voice-per-sample from Voice::advanceFrame, called per-sample from VoiceEngine::render
// — same-TU definition is what lets the compiler inline that stack (no LTO configured). A
// by-class TU split would put the hottest inner loop across TU boundaries. Do not split
// this file further; the header is split instead (zone_params.h carries the value structs).
#include "core/instrument/engine/sampler_core.h"
@@ -28,11 +23,8 @@ double pitchRatio(int note, int rootNote) {
}
double keyTrackedRatio(int note, int rootNote, double keyTrack) {
// Scale the semitone offset by keyTrack before the ET conversion. keyTrack == 1.0 yields
// (note-root)*1.0, which is EXACT in IEEE-754 for an integer-valued double, so the argument
// to std::pow is bit-identical to pitchRatio(note, rootNote) — the 100% default is byte-for-
// byte unchanged from the pre-S-VIEW-6 engine. keyTrack == 0.0 -> offset 0 -> ratio 1.0 on
// every key (no tracking); keyTrack == 2.0 -> doubled offset. Root note stays unity always.
// keyTrack == 1.0 yields (note-root)*1.0, exact in IEEE-754 for an integer-valued double,
// so the argument to std::pow is bit-identical to pitchRatio(note, rootNote).
const double semis = static_cast<double>(note - rootNote) * keyTrack;
return std::pow(2.0, semis / 12.0);
}
@@ -105,8 +97,7 @@ double AdsrEnvelope::tick() {
const double out = level_;
++framesInStage_;
if (framesInStage_ >= params_.attackFrames) {
// S15: Attack -> Hold (holds 1.0 for holdFrames). holdFrames == 0 falls straight
// through Hold on the next tick to Decay, which is EXACTLY the pre-S15 A->D path.
// holdFrames == 0 falls straight through Hold on the next tick to Decay.
stage_ = Stage::Hold;
framesInStage_ = 0;
level_ = 1.0;
@@ -115,16 +106,13 @@ double AdsrEnvelope::tick() {
}
case Stage::Hold: {
// S15 hold stage: level pinned at 1.0 for holdFrames. holdFrames <= 0 leaves the
// stage on this same tick (no frame consumed at 1.0 beyond what Attack already
// emitted), so hold=0 is byte-identical to the pre-S15 envelope.
// holdFrames <= 0 leaves the stage on this same tick (no frame consumed at 1.0
// beyond what Attack already emitted) so a zero-length hold emits no extra sample.
if (params_.holdFrames <= 0) {
stage_ = Stage::Decay;
framesInStage_ = 0;
// Fall through to Decay this frame so no extra unity sample is emitted for a
// zero-length hold (preserving the exact pre-S15 sample-for-sample shape).
level_ = 1.0;
// Single re-dispatch into Decay (bounded: HoldDecay only; not a general recursion).
// Single re-dispatch into Decay (bounded: Hold->Decay only, not general recursion).
return tick();
}
level_ = 1.0;
@@ -183,7 +171,7 @@ double AdsrEnvelope::tick() {
}
// ---------------------------------------------------------------------------
// TriggerEnvelope (S15) — a time-boxed fade-in/hold/fade-out amplitude function.
// TriggerEnvelope — a time-boxed fade-in/hold/fade-out amplitude function.
// ---------------------------------------------------------------------------
void TriggerEnvelope::configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
@@ -233,7 +221,7 @@ double TriggerEnvelope::amplitudeAt(double sourceOffset) {
}
// ---------------------------------------------------------------------------
// PitchEnvelope (S16) — AD pitch offset in semitones, off when disabled.
// PitchEnvelope — AD pitch offset in semitones, off when disabled.
// ---------------------------------------------------------------------------
double PitchEnvelope::tick() {
@@ -263,10 +251,10 @@ double PitchEnvelope::tick() {
// ---------------------------------------------------------------------------
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
// OFF the audio thread (allocates). Both channels are sized so a stereo Preserve voice needs
// no allocation at note-on; a mono Preserve voice simply never process()es shiftR_. The
// prime scratch (one window, reused per channel) is sized here for the same reason: start()
// assembles the first window of the upcoming source stream into it with zero allocation.
// Off the audio thread (allocates). Both channels are sized so a stereo Preserve voice
// needs no allocation at note-on; a mono voice simply never process()es shiftR_. The
// prime scratch is sized here for the same reason: start() assembles the first window
// of the upcoming source into it with zero allocation.
shiftL_.configure(windowFrames);
shiftR_.configure(windowFrames);
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
@@ -282,20 +270,16 @@ bool Voice::sustainLoopUsable() const {
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
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
// start is a takeover/steal of a SOUNDING voice and the caller opted in. The ramp itself
// is seeded on the FIRST frame rendered after the restart, from the DIFFERENCE between
// this reference and the new voice's raw output that frame (seedDeclick), so the
// boundary frame reproduces the old level EXACTLY — whatever the new envelope does
// (Gate attack, zero attack, Trigger's no-fade-in instant-unity onset) and whatever
// value the new sample starts on. [Rev 1 seeded the OLD value here and gated the add by
// (1 newAmp) in the epilogue: every restart whose new amplitude was instantly ~1 got
// ZERO compensation and kept the full click — exactly the DAW-reported mono-retrig case
// on Trigger / zero-attack zones.] A fresh start (idle voice) clears the declick state —
// no phantom ramp. lastOut{L,R}_ are deliberately NOT zeroed here: a SECOND same-block
// takeover (two steals of this voice with no frame rendered between) must record the
// same pre-cut reference, not a phantom 0. The next rendered frame overwrites lastOut.
// Before any state reset, record the pre-cut reference (last rendered output) and mark
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
// the difference between this reference and the new voice's raw output that frame
// (seedDeclick), so the boundary frame reproduces the old level exactly regardless of the
// new envelope's first value. (An earlier revision gated the add by (1 - newAmp): any
// restart whose new amplitude was instantly ~1 got zero compensation and kept the full
// click.) A fresh start (idle voice) clears the declick state. lastOut{L,R}_ are
// deliberately not zeroed here: a second same-block takeover (two steals with no frame
// rendered between) must record the same pre-cut reference, not a phantom 0.
if (declickTakeover && active_) {
// Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing.
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
@@ -313,14 +297,11 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
releasing_ = false;
amplitudeDone_ = false;
note_ = note;
// S-VIEW-9: the velocity->amp transfer curve maps MIDI velocity to gain, ONCE at note-on (the
// per-frame render just multiplies the cached velocityGain_ — no new process-thread work). The
// clamp lives inside eval (velocity box-clamped to [0,127]). Replaces the pre-r10 linear
// velocity/127; the default flat y=1 curve (R10-F1 Option A) plays every velocity at unity.
// Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
// velocityGain_.
velocityGain_ = velocityCurve.eval(static_cast<double>(velocity));
// S-VIEW-6: the key-tracked repitch ratio feeds BOTH engines through baseRatio_ (Varispeed
// read-rate bias and Preserve shift amount both derive from it below). keyTrack == 1.0 is
// the pre-S-VIEW-6 pitchRatio bit-for-bit.
// Feeds both engines through baseRatio_ (Varispeed read-rate bias and Preserve shift
// amount both derive from it below).
baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
sample_ = &sample;
@@ -328,25 +309,17 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
playMode_ = p.playMode;
pitchEngine_ = p.pitchEngine;
// Initial read position honors the sample's start-point offset (S11), in BOTH modes. Clamp
// into [0, frames): a start at or past the end degrades to 0 (play from the top) rather than
// starting a voice already off the end. A negative start (shouldn't occur) is pinned to 0.
// Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top)
// rather than starting a voice already off the end.
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
std::int64_t start = sample.startFrame;
if (start < 0 || start >= frameCount) start = 0;
readPos_ = static_cast<double>(start);
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
// --- Amplitude envelope: Gate = AHDSR (fully per-zone: A/H/D/S/R all read from the zone's
// play.adsr); Trigger = the time-boxed fade-in/out over the % play length.
//
// All five AHDSR fields come from sample.play.adsr (in FRAMES), resolved by
// buildTier0Keymap / buildZonedKeymap at reload time from the stored SECONDS against
// the live sample rate.
//
// Back-compat invariant: a zone whose stored ADSR seconds carry the tier-0 defaults
// (resolved to frames at the live sample rate) sounds identical to the pre-S12 build at
// every DAW rate — now trivially true, since the times are wall-clock seconds. ---
// Amplitude envelope: Gate = AHDSR (all five fields read from the zone's play.adsr,
// resolved to frames from stored seconds at reload time); Trigger = the time-boxed
// fade-in/out over the % play length.
if (playMode_ == PlayMode::Gate) {
env_.configure(p.adsr);
env_.noteOn();
@@ -366,38 +339,34 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
kDefaultFadeCurve);
}
// --- Pitch envelope (S16): per-voice AD, off by default (offset always 0). ---
pitchEnv_.configure(p.pitchEnv);
pitchEnv_.noteOn();
// --- Preserve engine (S16, GA2 onset fix): PRIME the ALREADY-SIZED per-channel shifters
// with the first window of the ACTUAL upcoming source stream (loop-unrolled under the
// sustain-loop wrap rule, silence past the sample end — that silence IS the true
// stream there). The tap parks on source frame `start`, so the voice speaks on output
// frame 0 at EVERY ratio (no ring-fill silence), and every splice has a full window
// of real history to land in — the fix for the DAW onset zero-gaps (a silence-warmed
// ring made every early splice jump into zeros). The rings and the prime scratch were
// allocated off-thread by presizePreserveShifters (the engine calls it at
// construction); this path is a bounded copy — NO allocation here. Varispeed voices
// never touch the shifters (advanceFrame checks configured()), so a Varispeed
// instrument is byte-identical to pre-S16 and pays no per-frame shifter cost. ---
// Prime the already-sized per-channel shifters with the first window of the actual
// upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
// the sample end, since that silence is the true stream there). The tap parks on source
// frame `start`, so the voice speaks on output frame 0 at every ratio, and every splice
// has a full window of real history to land in — a silence-warmed ring instead makes
// every early splice jump into zeros (burst/gap onset). The rings and prime scratch were
// allocated off-thread by presizePreserveShifters; this path is a bounded copy, no
// allocation. Varispeed voices never touch the shifters, so a Varispeed instrument pays
// no per-frame shifter cost.
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
const std::int64_t w = shiftL_.window();
const bool loopWrap = sustainLoopUsable();
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).
// 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 — but a full window bounded only by frameCount would let a Trigger
// ring hold 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 would get
// zero padding declared as valid history (splices landing in silence). 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 that machinery then recycles the
// real short tail. 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;
@@ -422,11 +391,11 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
(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` (== the feed bound when the prime exhausted the
// playable span — advanceFrame's own exhaustion test then holds from frame 0).
// Per-frame feed continues at `p` (the feed bound when the prime exhausted the
// playable span).
feedPos_ = p;
if (!loopWrap && primeCount < w) {
// Sub-window playable span: the source is ALREADY exhausted at prime time.
// Sub-window playable span: the source is already exhausted at prime time.
shiftL_.freezeTail();
if (stereoSample) shiftR_.freezeTail();
}
@@ -447,28 +416,26 @@ void Voice::retune(int note, int rootNote, double keyTrack) {
void Voice::release() {
if (!active_) return;
// TRIGGER ignores note-off entirely (S15): the one-shot plays through to its play length.
if (playMode_ == PlayMode::Trigger) return;
if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
releasing_ = true;
env_.noteOff();
}
void Voice::hardStop() {
// CC 120 (All Sounds Off): immediate silence regardless of play mode. Stops Trigger one-shots
// that ignore release(), and short-circuits Gate release tails. RT-safe: no allocation.
// Immediate silence regardless of play mode: stops Trigger one-shots that ignore
// release(), and short-circuits Gate release tails. RT-safe: no allocation.
active_ = false;
}
double Voice::tickAmplitude() {
double amp;
if (playMode_ == PlayMode::Gate) {
// AHDSR is wall-clock (one tick per output frame), independent of the read rate.
amp = env_.tick();
if (env_.finished()) amplitudeDone_ = true;
} else {
// Trigger fade shape anchored to the SOURCE offset (readPos - startFrame), so the fades
// land on the same source frames under either engine's read rate. The voice ALSO frees on
// readPos_ >= playEnd_ in advanceFrame; finished() here is the belt to that suspenders.
// Anchored to the source offset so fades land on the same source frames under either
// engine's read rate. The voice also frees on readPos_ >= playEnd_ in advanceFrame;
// finished() here is the belt to that suspenders.
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
if (trigEnv_.finished()) amplitudeDone_ = true;
}
@@ -476,34 +443,26 @@ double Voice::tickAmplitude() {
}
void Voice::seedDeclick(double newOutL, double newOutR) {
// First frame after a takeover restart: ARM the bounded blend. The weight starts at 1.0
// First frame after a takeover restart: arm the bounded blend. The weight starts at 1.0
// so this frame's output is `out*(1-1) + ref*1 == ref` — exact boundary identity whatever
// the new envelope's first value. Each subsequent frame adds `w*(ref outCurrent)` then
// decays w, so output is provably bounded by max(|ref|, |outCurrent|) — mid-ramp overshoot
// is impossible even if outCurrent rises while the weight is still significant.
// [Rev 1 stored the frozen difference (ref x₀); if outₙ rose while that residue was
// still large the sum could exceed full scale. The ±2.0 clamp there was the only guard
// and it silently broke the boundary identity when |x₀| > 1. The bounded blend removes
// both the overshoot hole and the need for a clamp on the stored value.]
// newOutL/R are used only to decide whether an active ramp exists (the seed is purely
// the weight 1.0; ref was clamped to ±1 at start()). The ±2 clamp on the difference is
// gone: the blend formula keeps every output within max(|ref|,|outₙ|) by construction.
// is impossible even if outCurrent rises while the weight is still significant. (An
// earlier revision stored the frozen difference (ref x₀), which could exceed full scale
// if outₙ rose while that residue was still large.)
(void)newOutL; (void)newOutR; // consumed only for the floor guard below
declickPending_ = false;
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.
declickWeight_ = 1.0; // one weight for both channels
// ref is already clamped to ±1.0 at start(). Activate only when it's above the floor —
// if ref ≈ 0 there is nothing to blend.
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
}
AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
// Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap,
// bracketing indices, interpolation partner) is computed ONCE and applied identically to
// every channel — only the PCM value read differs. The amplitude + pitch envelopes tick ONCE
// per frame and scale all channels equally (a voice is one envelope). The head advances by
// exactly one source-frame step per call, so mono and stereo consume the sample at one rate.
// Shared read/advance for the mono and stereo paths: the read-head geometry is computed
// once and applied identically to every channel — only the PCM value read differs. The
// amplitude + pitch envelopes tick once per frame and scale all channels equally.
if (!active_ || sample_ == nullptr) {
if (stereo) outR = 0.0f;
return 0.0f;
@@ -516,10 +475,10 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
const bool haveR = stereo && sample_->channelCount() == 2;
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
// Loop-aware sustain (GATE only — Trigger is a one-shot with no sustain loop, S15). If a
// valid, non-zero-length loop exists and the read head has advanced past the loop end, wrap
// it back into [start, end). A zero-length loop is treated as "no loop". Under Preserve the
// loop is over the SOURCE read (loop the source, shift the output — S15×S16 contract).
// Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). A valid,
// non-zero-length loop wraps the read head back into [start, end); a zero-length loop is
// "no loop". Under Preserve the loop is over the source read (loop the source, shift the
// output).
const SampleLoop& loop = sample_->loop;
const bool loopUsable = sustainLoopUsable();
if (loopUsable) {
@@ -529,16 +488,15 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
}
}
// TRIGGER end: the voice frees once the read head reaches playEnd (source-frame stop). The
// trigger envelope also finishes at the same frame count; either latches the voice idle.
// Trigger frees once the read head reaches playEnd; the envelope also finishes at the
// same count, either latches idle.
const bool triggerRanOff =
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
// Ran off the sample end with no usable loop -> voice is done. Peer path of the
// epilogue: an in-flight takeover declick RINGS OUT here instead of hard-cutting —
// dropping it would re-introduce a step on exactly the path the ramp exists for (a
// restart whose new play span ends within the ~4 ms ramp). The voice stays active only
// until the ramp floors; with no declick (the common case, and the entire opt-out
// baseline) this is byte-identical to the plain idle-out.
// Ran off the sample end with no usable loop -> voice is done, except an in-flight
// takeover declick rings out here instead of hard-cutting — dropping it would
// re-introduce a step on exactly the path the ramp exists for (a restart whose new play
// span ends within the ramp). With no declick (the common case) this is byte-identical
// to the plain idle-out.
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
if (declickPending_) seedDeclick(0.0, 0.0); // the new output here is silence
if (declickActive_) {
@@ -561,41 +519,35 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
return 0.0f;
}
// Envelopes tick once per output frame. Pitch envelope biases pitch under EITHER engine.
// Envelopes tick once per output frame. Pitch envelope biases pitch under either engine.
const double amp = tickAmplitude();
const double gain = amp * velocityGain_;
const double pitchEnvSemis = pitchEnv_.tick();
// The pitch-envelope bias factor 2^(semis/12). When the envelope is off (semis exactly 0)
// this is 1.0 and we skip the pow entirely — the Varispeed-off path stays a bare ratio read
// (no per-frame transcendental), byte-identical to pre-S16.
// 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the pow
// entirely — no per-frame transcendental on the common path.
const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
double outL, outRlocal = 0.0;
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
// PRESERVE: feed the shifters the SOURCE stream at unity rate (duration held) and
// TRANSPOSE the output by 2^((note-root + pitchEnvSemis)/12). Pitch envelope adds to
// the shift amount, not the read rate — pitch bends, duration unchanged (S16
// contract). The feed runs one window AHEAD of readPos_ (the rings were primed with
// that window at start()), under the SAME sustain-loop wrap rule as the anchor, and
// reads integer source frames (readPos_ advances by exactly 1.0 under Preserve, so
// there is nothing to interpolate). Past the last real frame the shifter's writer is
// FROZEN (GA3 wind-down below) — it recycles the real tail it already holds.
// Feed the shifters the source stream at unity rate (duration held) and transpose the
// output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the shift
// amount, not the read rate. The feed runs one window ahead of readPos_ (the rings
// were primed with that window at start()), under the same sustain-loop wrap rule,
// reading integer source frames (nothing to interpolate). Past the last real frame
// the shifter's writer is frozen — it recycles the real tail it already holds.
if (loopUsable) {
const std::int64_t loopLen = loop.end - loop.start;
while (feedPos_ >= loop.end) feedPos_ -= loopLen;
}
// GA3 tail wind-down (supersedes the GA2 hold-last-sample clamp). feedPos_ runs one
// window AHEAD of readPos_; the last real source frame is playEnd_-1 for Trigger (the
// user's chosen stop) or frameCount-1 for Gate (the sample's own end). Once feedPos_
// reaches that bound the source is EXHAUSTED — GA2 fed the held last sample from here,
// a DC plateau the splice correlation cannot align on (the DAW tail chop: periodic
// troughs at the splice cadence, growing toward the note end as the plateau displaced
// real ring history). Instead FREEZE the shifter's writer: no padding ever enters the
// ring, and the splice machinery keeps recycling the frozen all-real tail, every jump
// still waveform-aligned — a continuous tone through the final window and the release,
// bounded by the voice's own end (readPos_ >= frameCount / playEnd_ frees it). The
// sustain-loop path never gets here: the wrap above keeps feedPos_ < loop.end forever.
// feedPos_ runs one window ahead of readPos_; the last real source frame is
// playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
// the source is exhausted — feeding the held last sample instead would give the
// splice correlation a DC plateau it can't align on (periodic troughs at the splice
// cadence, growing toward the note end). Freezing the shifter's writer means no
// padding ever enters the ring, so the splice machinery keeps recycling the frozen
// all-real tail — a continuous tone through the voice's own end. The sustain-loop
// path never gets here: the wrap above keeps feedPos_ < loop.end forever.
const std::int64_t feedBound =
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
? playEnd_ : frameCount;
+226 -346
View File
@@ -1,142 +1,101 @@
#pragma once
// sampler_core — the HEART of the Phase S MIDI-playback instrument (D3), deliberately
// free of any VST3 *and* any REAPER type so it compiles and unit-tests OUTSIDE the DAW
// and outside any plugin host. It owns the pure sampler engine: polyphonic voice
// allocation with 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.
// sampler_core — the polyphonic voice engine: bounded-stealing allocation, an ADSR
// amplitude envelope, a key/velocity keymap resolving (note, velocity) -> zone, and
// repitch/interpolation from a root note with loop-point-aware sustain.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO VST3 types, NO REAPER types, NO SWELL,
// NO vendor/ includes, no include from either SDK. Standard library only. The VST3 shell
// (src/vst/reasampler_processor.cpp) marshals MIDI events + audio buffers to and from
// this core; the core never sees a VST3 ProcessData or a REAPER MediaTrack. Enforced
// 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_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.
// Shares the `AudioSample` float alias from peaks. Seam fields (root note, loop points)
// enter as plain int/frame-index inputs; the core does no file I/O.
#include <array>
#include <cstddef>
#include <cstdint>
#include <vector>
#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)
#include "core/audio/peaks.h"
#include "core/instrument/engine/zone_params.h"
#include "core/instrument/engine/pitch_shift.h"
#include "core/instrument/engine/velocity_curve.h"
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 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
// to at most one zone; a zone names which SampleData to play and the root note to
// repitch from. Tier-0 degenerate case: a single zone spanning [0,127] with the
// sample's own root. Tier-1: several zones, each a key range with its own root.
// Keymap — the performance map. A note+velocity resolves to at most one zone; a zone
// names which SampleData to play and the root note to repitch from. Tier-0 degenerate
// case: a single zone spanning [0,127] with the sample's own root. Tier-1: several
// zones, each a key range with its own root.
//
// TIER-2 EXTENSION (velocity layers / round-robin) — designed for, not built:
// resolution returns a zone; a zone today owns one sampleIndex. Tier 2 makes a zone
// own a *list* of (velocity-range, sampleIndex) layers (and round-robin sets), and
// resolve() gains the velocity dimension it already receives but currently ignores
// for selection. The (note, velocity) signature and the "resolve to a zone, then a
// sample within it" shape are already in place — Tier 2 fills in the second step
// without changing callers or the voice engine. See the report note.
// ---------------------------------------------------------------------------
// Tier-2 extension (velocity layers/round-robin) — designed for, not built: a zone
// today owns one sampleIndex; Tier 2 would make it own a list of (velocity-range,
// sampleIndex) layers, and resolve() would gain the velocity dimension it already
// receives but currently ignores for selection — no signature change needed.
// A key range [lowNote, highNote] (inclusive both ends) mapping to one sample, with
// the root note to repitch from (defaults to the sample's own root, overridable in
// the performance map per S5). velocityLow/High reserved for Tier-2 layers; today a
// zone accepts the full 1..127 velocity range (0 is note-off by MIDI convention).
// A key range [lowNote, highNote] (inclusive) mapping to one sample, with the root
// note to repitch from (defaults to the sample's own root, overridable per zone).
// velocityLow/High reserved for Tier-2 layers; today a zone accepts the full 1..127
// velocity range (0 is note-off by MIDI convention).
struct KeyZone {
int lowNote = 0;
int highNote = 127;
int rootNote = 60; // repitch reference for this zone
// S-VIEW-6 key-tracking scalar: how far keyboard pitch tracks the root. 1.0 (100%) is
// standard 12-tone-ET (default; bit-identical to pre-S-VIEW-6); 0.0 = no tracking (every
// key plays root pitch); 2.0 = double-rate tracking. Scales the (note-root) semitone offset
// in the repitch math (keyTrackedRatio); rides BOTH engines via the voice's baseRatio_.
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 =
// no tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root)
// semitone offset in keyTrackedRatio; rides both engines via the voice's baseRatio_.
double keyTrack = 1.0;
// S-VIEW-9 velocity->amp transfer curve: maps the note-on velocity (0..127) to the voice's amp
// gain, replacing the fixed linear velocity/127. A per-zone performance characteristic (mirror
// 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.
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
// (never per frame). Default flat y=1 — every velocity plays at unity.
VelocityCurve velocityCurve = VelocityCurve::flat();
std::size_t sampleIndex = 0; // index into Keymap::samples
};
// Result of resolving a (note, velocity). `matched == false` means the note falls in
// no zone (out-of-zone) — a defined no-play result, NOT an error and NOT voice 0.
// `matched == false` means the note falls in no zone — a defined no-play result, not an
// error and not voice 0.
struct ZoneResolution {
bool matched = false;
std::size_t zoneIndex = 0; // valid only when matched
};
// The keymap: the decoded samples plus the zones that map keys onto them. Owns
// resolution. Pure: no host types. Zones are tested first-match in order, so an
// earlier zone wins an overlap (deterministic, documented).
// Decoded samples plus the zones that map keys onto them. Zones are tested first-match
// in order, so an earlier zone wins an overlap (deterministic, documented).
struct Keymap {
std::vector<SampleData> samples;
std::vector<KeyZone> zones;
// Resolves (note, velocity) to a zone. First zone (in order) whose [low,high]
// contains `note` wins. velocity is accepted now (Tier-2 seam) but does not
// affect zone choice at Tier 0-1. Returns {matched=false} when no zone contains
// the note.
// First zone (in order) whose [low,high] contains `note` wins. velocity is accepted
// (Tier-2 seam) but doesn't affect zone choice at Tier 0-1.
ZoneResolution resolve(int note, int velocity) const;
// Convenience: build the Tier-0 degenerate keymap one sample mapped
// chromatically across the whole keyboard from its own root note.
// The Tier-0 degenerate keymap: one sample mapped chromatically across the whole
// keyboard from its own root note.
static Keymap singleSampleChromatic(SampleData sample);
};
// The chromatic pitch ratio to play `note` given a sample recorded at `rootNote`:
// 2^((note - rootNote) / 12). note == rootNote -> 1.0 (unity). One octave up -> 2.0,
// one octave down -> 0.5. Pure equal-temperament; no reference-frequency needed.
// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal-temperament; no
// reference-frequency needed.
double pitchRatio(int note, int rootNote);
// The key-tracked pitch ratio (S-VIEW-6): 2^(((note - rootNote) * keyTrack) / 12). The
// keyTrack scalar scales the semitone offset before the ET conversion, so it governs how
// far playback pitch tracks the keyboard around the root:
// keyTrack == 1.0 -> standard 12-tone-ET (BIT-IDENTICAL to pitchRatio(note, rootNote) —
// (note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call).
// keyTrack == 0.0 -> no tracking: every key plays the root pitch (ratio 1.0 for all notes).
// keyTrack == 2.0 -> double-rate tracking: each key is twice as far from the root in pitch.
// At the root note the offset is 0 regardless of keyTrack, so the root always plays at unity.
// Pure; both repitch engines (Varispeed read-rate, Preserve shift-amount) derive from it via
// the voice's baseRatio_.
// 2^(((note - rootNote) * keyTrack) / 12) — keyTrack scales the semitone offset before
// the ET conversion. keyTrack == 1.0 is bit-identical to pitchRatio(note, rootNote)
// ((note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call); 0.0 means every
// key plays the root pitch; 2.0 doubles the tracking rate. At the root note the offset is
// 0 regardless of keyTrack. Both repitch engines derive from it via the voice's baseRatio_.
double keyTrackedRatio(int note, int rootNote, double keyTrack);
// ---------------------------------------------------------------------------
// AHDSR amplitude envelope (S15 grows the S3 ADSR with a HOLD stage). Sample-based
// (times in frames), linear segments. A gate: noteOn() enters Attack; noteOff() enters
// Release from wherever it is. Asserted against a known signal in the tests (mirror of peaks).
// AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate:
// noteOn() enters Attack; noteOff() enters Release from wherever it is.
//
// Segment math (all linear ramps):
// Segment math:
// Attack: 0 -> 1 over attackFrames
// Hold: hold 1 over holdFrames (S15: NEW stage between A and D)
// Hold: hold 1 over holdFrames
// Decay: 1 -> sustainLevel over decayFrames
// Sustain: hold sustainLevel until noteOff
// Release: currentLevel -> 0 over releaseFrames
// A zero-length attack jumps straight to 1 on the first frame; HOLDFRAMES == 0 skips Hold
// entirely, which is EXACTLY the pre-S15 ADSR (back-compat — existing Gate play is unchanged);
// zero decay jumps to sustain; a noteOff during attack/hold/decay (release-before-sustain)
// releases from the current partial level, not from sustainLevel. AdsrParams is defined above
// (with the other per-zone value structs); this section holds only the per-frame evaluator.
// ---------------------------------------------------------------------------
// A zero-length attack jumps straight to 1 on the first frame; holdFrames == 0 skips Hold
// entirely (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff
// during attack/hold/decay releases from the current partial level, not from sustainLevel.
class AdsrEnvelope {
public:
@@ -167,30 +126,22 @@ private:
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
};
// ---------------------------------------------------------------------------
// S15 Trigger amplitude envelope (per-frame evaluator). The PlayMode / TriggerParams /
// FadeCurve value structs are defined above with the other per-zone params.
// ---------------------------------------------------------------------------
// Trigger amplitude envelope: a stateless-shape amplitude function over the play span, evaluated
// at a SOURCE-frame offset into the span. Anchoring the fades to SOURCE frames (not output
// frames) is what makes S15 compose with S16: under Preserve the read advances at source rate so
// output and source frames coincide, but under Varispeed a transposed voice consumes source
// faster — driving the fades off the read position keeps the fade-in/out anchored to the SAME
// source frames regardless of engine (the play-length end is a source-frame fact, S15×S16). The
// voice reports the read offset; this maps it to amplitude. Distinct from AHDSR — time-boxed by
// the play length and note-off-immune. Reports finished() once the offset reaches the play length.
// A stateless-shape amplitude function over the play span, evaluated at a source-frame
// offset into the span (not output frames): under Varispeed a transposed voice consumes
// source faster than output, so driving the fades off the read position keeps fade-in/out
// anchored to the same source frames regardless of engine. Distinct from AHDSR —
// time-boxed by the play length and note-off-immune.
class TriggerEnvelope {
public:
// Configure from the play span + fades. `playLengthFrames` is (playEnd - startFrame): the
// SOURCE-frame length of the play span. Fades are clamped so fadeIn + fadeOut <= playLength
// (fadeOut anchored to the end). A zero/negative play length finishes immediately.
// `playLengthFrames` is (playEnd - startFrame). Fades are clamped so
// fadeIn + fadeOut <= playLength (fadeOut anchored to the end). A zero/negative play
// length finishes immediately.
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve);
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame) source frames into the play
// span. Latches finished() once the offset reaches the play length (>= playLength). Pure over
// the offset (no internal advance) so it composes with either pitch engine's read rate.
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame). Latches finished() at
// or past playLength. Pure over the offset so it composes with either pitch engine's
// read rate.
double amplitudeAt(double sourceOffset);
bool finished() const { return finished_; }
@@ -203,21 +154,14 @@ private:
bool finished_ = false;
};
// ---------------------------------------------------------------------------
// S16 pitch envelope (per-frame evaluator). The PitchEngine / PitchEnvParams value structs
// and the kDefaultPitchEngine / kPreserveWindowMs constants are defined above.
// ---------------------------------------------------------------------------
// Per-frame AD pitch-envelope evaluator. tick() returns the CURRENT pitch offset in semitones
// (0 when disabled or past attack+decay), advancing one frame. The voice converts the semitone
// offset to a ratio multiply (Varispeed) or a shift-amount add (Preserve). Pure, unit-tested
// for offset at t=0, peak at t=attack, and 0 at t=attack+decay.
// tick() returns the current pitch offset in semitones (0 when disabled or past
// attack+decay), advancing one frame. The voice converts it to a ratio multiply
// (Varispeed) or a shift-amount add (Preserve).
class PitchEnvelope {
public:
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
void noteOn() { pos_ = 0; }
// Advance one frame, return this frame's pitch offset in semitones.
double tick();
private:
@@ -225,29 +169,19 @@ private:
std::int64_t pos_ = 0;
};
// Takeover declick (Phase S GA fix, rev 2 — audible click when a sounding voice is
// restarted). A takeover restart HARD-CUTS the sounding tone: the read head and envelope
// restart in one frame, a step discontinuity that clicks. This is the same physics on EVERY
// restart-of-a-sounding-voice path — the MONO Retrigger takeover/fallback, the mono
// cross-sample legato restart, and the POLY at-cap voice steal (the editor's preview is a
// plain engine noteOn since the PreviewCard retirement, so a preview re-fire at cap is just
// an at-cap steal). When the caller opts in (start()'s declickTakeover; the engine passes
// it on all of those restart paths when constructed with takeoverDeclick),
// the restart smooths the ACTUAL output discontinuity: start() records the last rendered
// output as the pre-cut reference, and the FIRST frame rendered after the restart seeds a
// compensation equal to (reference that frame's raw new output). The compensation is
// summed into the output UNGATED and decays by kDeclickDecay per frame, so the boundary
// frame reproduces the old level EXACTLY — zero step whatever the new envelope's first
// value (Gate attack, zero attack, or Trigger's no-fade-in instant-unity onset) and
// whatever value the new sample starts on — and the residue fades in ~2-4 ms to the -80 dB
// floor across 44.1-96 kHz (a per-FRAME DSP micro-ramp, not a stored wall-clock quantity).
// [Rev 1 decayed the OLD output gated by (1 newAmp): any restart whose new amplitude was
// instantly ~1 — a Trigger zone with no fade-in, a zero-attack Gate — got ZERO compensation
// and kept the full click. The difference seed has no such hole and needs no gate: when old
// and new levels already match, the seed is ~0 and nothing is added, so the +6 dB sum the
// gate defended against is structurally impossible.] OFF by default so the bare core stays
// byte-identical to the pre-fix engine (the regression baseline); the processor shell opts
// in for the engine, mirroring the kDefaultPitchEngine layering.
// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback, a
// cross-sample legato restart, or a poly at-cap steal) hard-cuts the old tone in one
// frame a step discontinuity that clicks. When the caller opts in (start()'s
// declickTakeover), start() records the last rendered output as a pre-cut reference, and
// the first frame after the restart seeds a compensation equal to
// (reference - that frame's raw new output), summed in ungated and decaying by
// kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless
// of the new envelope's first value, and the residue fades to the -80 dB floor in a few ms.
// An earlier revision gated the compensation by (1 - newAmp): any restart whose new
// amplitude was instantly ~1 (Trigger with no fade-in, zero-attack Gate) got zero
// compensation and kept the full click — the difference-seed has no such hole. Off by
// default so the bare core stays byte-identical to the pre-fix engine; the processor
// shell opts in.
inline constexpr double kDeclickDecay = 0.95; // per-frame decay of the compensation
inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~ -80 dB)
@@ -259,119 +193,95 @@ inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~
class Voice {
public:
// Starts this voice on `note` at `velocity`, playing `sample` (a stable reference
// the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched
// from `rootNote`. All five AHDSR fields (A/H/D/S/R) are read directly from
// sample.play.adsr — the per-zone values (in FRAMES) resolved from the stored seconds by
// buildTier0Keymap / buildZonedKeymap against the live sample rate. The S15 play MODE +
// Trigger params and the S16 pitch ENGINE + pitch envelope are read from `sample.play`.
// The Preserve shifters MUST already be pre-sized (presizePreserveShifters, off-thread) —
// start() only reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio
// thread inside process(). The warm silence pass settles the OLA taps before the first
// output frame (no cold-start click). Byte-identical to the pre-S15 engine when sample.play
// is default (Gate + Varispeed + no pitch env).
// `keyTrack` (S-VIEW-6) scales the (note-root) semitone offset feeding the repitch ratio;
// 1.0 (the default) is standard 12-tone-ET, bit-identical to the pre-S-VIEW-6 baseRatio_.
// `velocityCurve` (S-VIEW-9) maps the note-on velocity to the voice's amp gain, evaluated ONCE
// here (off the per-frame path); defaults to flat y=1 (R10-F1) — every velocity plays at unity.
// `declickTakeover` (Phase S GA fix): when TRUE and this voice is currently ACTIVE (a
// takeover/steal restart, not a fresh start), smooth the restart's output discontinuity —
// the pre-cut output is recorded here and the difference-seeded compensation is armed on
// the first frame rendered after the restart (see the takeover-declick block above
// kDeclickDecay). A fresh start never declicks.
// Plays `sample` (a stable reference the caller must keep alive — the Keymap owns it),
// repitched from `rootNote`. AHDSR/play-mode/pitch-engine params are read from
// sample.play (frames, resolved from stored seconds at keymap build). Preserve shifters
// must already be pre-sized (presizePreserveShifters, off-thread) — start() only
// reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio thread
// inside process(); the warm silence pass settles the OLA taps before the first output
// frame. Byte-identical to the bare engine when sample.play is default.
// `keyTrack` scales the (note-root) semitone offset feeding the repitch ratio; 1.0 is
// standard 12-tone-ET. `velocityCurve` maps note-on velocity to amp gain, evaluated once
// here (off the per-frame path); defaults to flat y=1. `declickTakeover`: when true and
// this voice is currently active (a takeover/steal restart, not a fresh start), arms the
// difference-seeded declick compensation on the first frame after the restart (see
// kDeclickDecay above). A fresh start never declicks.
void start(int note, int velocity, const SampleData& sample, int rootNote,
double keyTrack = 1.0,
const VelocityCurve& velocityCurve = VelocityCurve::flat(),
bool declickTakeover = false);
// MONO LEGATO takeover (Phase S): re-pitch this ACTIVE voice to `note` without touching the
// amplitude envelope, the read position, or the shifter state — pitch moves, no re-attack.
// Both engines pick the new baseRatio_ up on the next frame (Varispeed via the read rate,
// Preserve via the per-frame setShiftRatio). No-op on an idle voice. The caller guarantees
// the voice is playing the SAME SampleData the (note-resolved) zone names — a cross-sample
// takeover must restart the voice instead (see MonoTrigger).
// Mono legato takeover: re-pitch this active voice to `note` without touching the
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
// engines pick the new baseRatio_ up on the next frame. No-op on an idle voice. Caller
// guarantees the voice is playing the same SampleData the resolved zone names — a
// cross-sample takeover must restart the voice instead.
void retune(int note, int rootNote, double keyTrack = 1.0);
// Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in
// TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
// Gate off. In Gate mode enters the AHDSR release; in Trigger mode a no-op (Trigger
// ignores note-off and plays through to its play length).
void release();
// HARD STOP — CC 120 (All Sounds Off) semantics. Immediately silences this voice regardless
// of play mode: sets active_ = false with no release ramp. Stops a ringing Trigger one-shot
// instantly (which release() cannot do). RT-safe: no allocation, no lock.
// Hard stop (CC 120 semantics): immediately silences this voice regardless of play mode,
// no release ramp. Stops a ringing Trigger one-shot instantly (release() cannot).
// RT-safe: no allocation, no lock.
void hardStop();
// True while this voice is producing (or about to produce) sound (including any
// declick ring-out tail past the note's playable span).
// True while producing (or about to produce) sound, including any declick ring-out
// tail past the note's playable span.
bool active() const { return active_; }
// True while this voice is sounding a PLAYABLE NOTE — active AND the amplitude
// envelope has not yet finished. A voice whose note has run to its end but is still
// ringing out a declick tail is active() but NOT soundingNote(). Use this to
// distinguish "note is alive" (active) from "note occupies a voice slot" (soundingNote)
// for the Preserve-cap count and the mono-Legato takeover predicate — both must ignore
// a ramp-only past-end voice or a new note-on can be dropped / silently muted.
// True while sounding a playable note — active and the amplitude envelope hasn't
// finished. A voice ringing out a declick tail past note end is active() but not
// soundingNote(); the Preserve-cap count and the mono-legato takeover predicate must
// ignore a ramp-only past-end voice or a new note-on could be dropped/silently muted.
bool soundingNote() const { return active_ && !amplitudeDone_; }
// The note this voice was started on (for note-off routing). Meaningless if idle.
int note() const { return note_; }
// Monotonic age counter — higher = started earlier relative to others. The voice
// engine uses this for its stealing policy (oldest first). Set by the engine.
// Monotonic age counter for the engine's oldest-first stealing policy. Set by the engine.
std::uint64_t startOrder() const { return startOrder_; }
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
bool releasing() const { return releasing_; }
// The S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only
// meaningful while active(). NOTE: the FA1 unity-shift demotion to Varispeed is GONE —
// it was scoped to the retired PreviewCard, and since GA2 the primed shifter speaks on
// frame 0 at every ratio, so a Preserve voice keeps its shifter at every note (one code
// path, uniform onset across the keyboard).
// The pitch engine this voice is running (for the engine's Preserve-voice tally). Only
// meaningful while active().
PitchEngine pitchEngine() const { return pitchEngine_; }
// The SampleData this voice is playing (nullptr when never started). The engine's mono
// legato path compares it against the new note's resolved sample — a same-sample takeover
// retunes; a cross-sample one restarts. Identity only; callers never mutate through it.
// Identity only, never mutated through; the engine's mono legato path compares it
// against the new note's resolved sample to decide retune vs. restart.
const SampleData* playingSample() const { return sample_; }
// Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the
// audio thread (this allocates; also sizes the prime scratch buffer). The engine calls it
// once at construction so start() — which runs on the audio thread inside process() — never
// allocates: start() only prime()s the already-sized rings with the first window of source
// (a bounded copy). `windowFrames` <= 1 leaves the shifters as pass-through (Varispeed
// instruments pay no ring cost). Idempotent: a re-presize to the same window is a cheap
// no-op in the underlying vector.
// Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off
// the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
// runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
// Idempotent: a re-presize to the same window is a cheap no-op.
void presizePreserveShifters(std::int64_t windowFrames);
// Renders one frame's contribution, advancing the read head and envelope by one
// output frame. Returns 0.0 (and goes idle) once the envelope finishes or the
// sample runs out with no loop. The value is already velocity- and
// envelope-scaled — the engine sums voices directly. This is the MONO path (channel
// 0 only) — byte-identical to the pre-S7 engine, so mono play is unchanged.
// Renders one frame's contribution, advancing the read head and envelope by one output
// frame. Returns 0.0 (and goes idle) once the envelope finishes or the sample runs out
// with no loop. Already velocity- and envelope-scaled — the engine sums voices directly.
// Mono path (channel 0 only).
AudioSample renderFrame();
// STEREO render: writes THIS frame's per-channel contribution into `l`/`r` and advances
// the read head + envelope by exactly one frame (the same single advance the mono path
// performs — the envelope ticks ONCE per frame, shared across both channels). For a mono
// sample (channelCount()==1) both `l` and `r` receive the same value (dual-mono / centered).
// Both outputs are already velocity- and envelope-scaled. Goes idle on the same conditions
// as the mono path (envelope finished / sample exhausted with no loop) writing 0 to both.
// Writes this frame's per-channel contribution into `l`/`r` and advances the read head +
// envelope by exactly one frame (the envelope ticks once per frame, shared across both
// channels). A mono sample writes the same value to both (dual-mono/centered). Goes idle
// on the same conditions as the mono path, writing 0 to both.
void renderFrameStereo(AudioSample& l, AudioSample& r);
private:
// Shared read/advance for both render paths: computes the interpolated per-channel
// value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
// the pitch engine (Varispeed read-rate bias OR Preserve shift), advances the head, and
// latches idle on exhaustion. `stereo` selects whether the second channel is read (and
// returned in `outR`); when false `outR` is left untouched. Returns the channel-0 value.
// the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects
// whether the second channel is read (into `outR`). Returns the channel-0 value.
AudioSample advanceFrame(bool stereo, AudioSample& outR);
// This frame's amplitude in [0,1] from the active envelope. GATE: the AHDSR ticks once per
// output frame (independent of the read rate — envelope time is wall-clock). TRIGGER: the
// fade shape is evaluated at the SOURCE offset (readPos - startFrame) so the fades anchor to
// source frames and compose with either pitch engine. Sets amplitudeDone_ when the envelope
// finishes (Gate: release complete; Trigger: play length reached) so advanceFrame frees the voice.
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
// output frame (envelope time is wall-clock, independent of read rate). Trigger: fade
// shape is evaluated at the source offset (readPos - startFrame) so fades anchor to
// source frames regardless of pitch engine. Sets amplitudeDone_ on finish so
// advanceFrame frees the voice.
double tickAmplitude();
// True when the sustain loop applies to this voice: GATE mode with a valid, non-empty loop
// inside the sample (S15 — Trigger one-shots never loop). The single source of truth for
// the wrap rule shared by the output anchor (readPos_), the Preserve feed (feedPos_), and
// the start()-time ring prime.
// True when the sustain loop applies: Gate mode with a valid, non-empty loop inside the
// sample (Trigger one-shots never loop). Single source of truth for the wrap rule shared
// by the output anchor, the Preserve feed, and the start()-time ring prime.
bool sustainLoopUsable() const;
bool active_ = false;
@@ -379,13 +289,13 @@ private:
int note_ = 0;
double velocityGain_ = 1.0;
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio
double ratio_ = 1.0; // fractional SOURCE frames advanced per output frame (this frame)
double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame)
double readPos_ = 0.0; // fractional frame index into the sample
const SampleData* sample_ = nullptr;
// S15 play mode + amplitude envelopes. Gate uses env_ (AHDSR); Trigger uses trigEnv_. Only
// one is active per voice (selected by playMode_ at start). playEnd_ is Trigger's source-frame
// stop (the voice frees when readPos_ >= playEnd_, mirroring the run-off-end idle).
// Gate uses env_ (AHDSR); Trigger uses trigEnv_ — only one active per voice (selected by
// playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when
// readPos_ >= playEnd_).
PlayMode playMode_ = PlayMode::Gate;
AdsrEnvelope env_;
TriggerEnvelope trigEnv_;
@@ -393,20 +303,17 @@ private:
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
// S16 pitch engine + pitch envelope. pitchEngine_ selects Varispeed (ratio bias) vs Preserve
// (source-rate read + shifter). shiftL_/shiftR_ transpose the Preserve output per channel
// (one read head, per-channel shift — S7 compose). pitchEnv_ rides EITHER engine.
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
//
// GA2 onset fix: the shifter rings are PRIMED at start() with the first window of the
// actual upcoming source (loop-unrolled, silence past the end) — output frame 0 is source
// frame `start`, no ring-fill silence, and splices always land in real history. feedPos_
// is the integer SOURCE frame the shifters are fed next; it runs exactly one window AHEAD
// of readPos_ (the wall-clock output anchor) under the same sustain-loop wrap rule.
// GA3 tail wind-down: once feedPos_ passes the last real frame (Gate: sample end;
// Trigger: playEnd_) the shifters' writers are FROZEN — no padding enters the rings and
// the splice machinery recycles the frozen real tail through the note end (see
// advanceFrame). primeBuf_ is the presized scratch the prime stream is assembled into
// (never touched outside start()).
// The shifter rings are primed at start() with the first window of the actual upcoming
// source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
// silence, and splices always land in real history. feedPos_ is the integer source frame
// fed to the shifters next; it runs exactly one window ahead of readPos_ under the same
// sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end;
// Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
// splice machinery recycles the frozen real tail through the note end (see advanceFrame).
// primeBuf_ is the presized scratch the prime stream is assembled into.
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
PitchEnvelope pitchEnv_;
PitchShifter shiftL_;
@@ -414,27 +321,24 @@ private:
std::int64_t feedPos_ = 0;
std::vector<AudioSample> primeBuf_;
// Seeds the takeover compensation on the FIRST frame after a restart: the ramp is the
// ACTUAL discontinuity — (pre-cut reference the new voice's raw output this frame) —
// applied ungated so the boundary frame reproduces the old level exactly. See the
// takeover-declick block above kDeclickDecay.
// Seeds the takeover compensation on the first frame after a restart: the ramp is the
// actual discontinuity — (pre-cut reference - the new voice's raw output this frame) —
// applied ungated so the boundary frame reproduces the old level exactly.
void seedDeclick(double newOutL, double newOutR);
// Takeover declick state (see kDeclickDecay above). lastOut{L,R}_ track the voice's most
// 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ₙ*(1w) + 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.
// [Rev 1 stored the frozen difference (ref x₀); when outₙ rose while that residue
// was still large the sum could exceed full scale by up to ~+3.8 dB.]
// lastOut is NOT zeroed by start() — a second same-block takeover (no frame rendered
// between) must record the same pre-cut reference, not a phantom 0.
// The whole declick state is cleared on a fresh (non-takeover) start.
// lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
// records them as declickRef{L,R}_ and sets declickPending_; the first frame after the
// restart calls seedDeclick to arm the bounded blend:
// outₙ = outₙ*(1w) + ref*w, w = declickWeight_ (one weight, shared by both channels so
// L/R can never diverge), starting at 1.0 and decaying by kDeclickDecay each frame.
// 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. (An earlier revision stored the frozen difference
// (ref x₀); when outₙ rose while that residue was still large, the sum could exceed
// full scale by several dB.)
// lastOut is not zeroed by start() — a second same-block takeover (no frame rendered
// between) must record the same pre-cut reference, not a phantom 0. The whole declick
// state is cleared on a fresh (non-takeover) start.
bool declickPending_ = false;
bool declickActive_ = false;
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
@@ -446,50 +350,40 @@ private:
std::uint64_t startOrder_ = 0;
};
// ---------------------------------------------------------------------------
// The polyphonic voice engine: a fixed pool of voices, note-on allocation with
// bounded voice stealing, note-off routing, and block rendering (sum of voices).
// The polyphonic voice engine: a fixed pool of voices, note-on allocation with bounded
// voice stealing, note-off routing, and block rendering (sum of voices).
//
// VOICE-STEALING POLICY (deterministic, documented): when all voices are busy and a
// new note-on arrives, steal in this priority order:
// 1. the oldest voice already in RELEASE (finishing anyway — cheapest to cut),
// Voice-stealing policy (deterministic, documented): when all voices are busy and a new
// note-on arrives, steal in this priority order:
// 1. the oldest voice already in release (finishing anyway — cheapest to cut),
// 2. else the oldest voice overall (longest-held note gives way to the new one).
// "Oldest" = smallest startOrder (assigned monotonically at note-on). This is the
// standard hardware-sampler policy: prefer to sacrifice a dying tail, and failing
// that, the note that has already had the most time.
// ---------------------------------------------------------------------------
// "Oldest" = smallest startOrder (assigned monotonically at note-on) — the standard
// hardware-sampler policy.
class VoiceEngine {
public:
// Builds an engine with `maxVoices` voices (the polyphony bound) playing from
// `keymap`. The keymap must outlive the engine (the engine holds a reference — it
// reads zones and sample data through it, never copies PCM). Every AHDSR field (A/H/D/S/R)
// + play mode + pitch engine rides on each zone's SampleData::play (in FRAMES, resolved
// from the stored seconds at keymap build); the engine holds no instrument-wide ADSR.
// `preserveVoiceCap` (S16) bounds how many Preserve-engine voices may sound at once (the
// Builds an engine with `maxVoices` voices playing from `keymap` (must outlive the
// engine — held by reference, never copies PCM). Play params ride on each zone's
// SampleData::play; the engine holds no instrument-wide ADSR.
// `preserveVoiceCap` bounds how many Preserve-engine voices may sound at once (the
// shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is
// dropped rather than glitching; 0 means "no separate Preserve cap" (bounded only by
// maxVoices). `preserveWindowFrames` is the OLA window (in OUTPUT frames) every voice's
// Preserve pitch shifters are PRE-SIZED to at construction (OFF the audio thread), so
// note-on (which runs in process()) never allocates; 0 leaves them pass-through (a
// Varispeed-only instrument pays no ring cost). The processor derives it from the host
// sample rate (kPreserveWindowMs). Defaulted so existing callers (and the pure-core tests)
// are unaffected.
// dropped rather than glitching; 0 means no separate cap (bounded only by maxVoices).
// `preserveWindowFrames` is the OLA window every voice's Preserve shifters are
// pre-sized to at construction (off the audio thread), so note-on never allocates; 0
// leaves them pass-through. The processor derives it from the host sample rate.
//
// `voiceMode` (Phase S): POLY is the pool-with-stealing engine above; MONO drives a single
// voice (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger`
// (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a same-sample
// takeover without a re-attack). Both default to today's behavior (Poly / Retrigger). The
// engine's config is immutable — a mode/count change rebuilds the engine off-thread through
// the processor's drain-slot reload, so ringing tails survive the swap.
// `voiceMode`: POLY is the pool-with-stealing engine above; MONO drives a single voice
// (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger`
// (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a
// same-sample takeover without a re-attack). The engine's config is immutable — a
// mode/count change rebuilds the engine off-thread through the processor's drain-slot
// reload, so ringing tails survive the swap.
//
// `takeoverDeclick` (Phase S GA fix): when TRUE, every RESTART of a SOUNDING voice —
// the MONO Retrigger takeover, the retrigger fallback on note-off, the cross-sample
// legato restart, and the POLY at-cap voice STEAL — seeds the per-voice declick ramp
// (see kDeclickDecay) so the hard cut of the old tone does not click. start() self-gates
// on the voice being active, so a fresh start (free voice) never ramps. Default FALSE
// keeps the bare core byte-identical to the pre-fix engine (regression baseline); the
// processor shell opts in — the same layering as the kDefaultPitchEngine product default.
// `takeoverDeclick`: when true, every restart of a sounding voice (mono retrigger
// takeover/fallback, cross-sample legato restart, poly at-cap steal) seeds the
// per-voice declick ramp (see kDeclickDecay) so the hard cut doesn't click. start()
// self-gates on the voice being active, so a fresh start never ramps. Default false
// keeps the bare core byte-identical to the pre-fix engine; the processor shell opts in.
VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
VoiceMode voiceMode = VoiceMode::Poly,
@@ -507,42 +401,32 @@ public:
// the older tail to ring — matches hardware behavior). No-op if none match.
void noteOff(int note);
// CC 123 — MIDI All-Notes-Off: clears the MONO held stack and RELEASES every active voice
// (Gate voices enter their AHDSR release tail; Trigger one-shots ignore release and play
// through their bounded play length). This is the mono stack's ONLY reset path — a phantom
// entry left by a lost note-off would otherwise be resurrected by the fallback and sustain
// forever with no key held. RT-safe (no allocation, bounded by maxVoices).
// CC 123 (All-Notes-Off): clears the mono held stack and releases every active voice
// (Gate enters AHDSR release; Trigger ignores release and plays through). The mono
// stack's only reset path — a phantom entry left by a lost note-off would otherwise be
// resurrected by the fallback and sustain forever with no key held. RT-safe.
void allNotesOff();
// CC 120 — MIDI All-Sounds-Off: hard-stops EVERY voice immediately (active_ = false, no
// release ramp), clears the MONO held stack, and silences even Trigger one-shots that would
// ignore a release. Use for panic; CC 123 for the softer "let gates release" behavior.
// RT-safe (no allocation, bounded by maxVoices); callable from the audio thread.
// CC 120 (All-Sounds-Off): hard-stops every voice immediately, clears the mono held
// stack, silences even Trigger one-shots that would ignore a release. Panic; CC 123 is
// the softer "let gates release." RT-safe, callable from the audio thread.
void allSoundsOff();
// REAL-TIME render (S4): sums all active voices into the caller-provided buffer
// `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes —
// this never touches memory it does not own and NEVER allocates). This is the
// audio-thread entry point: the VST3 process callback passes the host's own output
// channel buffer, so no allocation, resize, or heap traffic happens under process.
// Voices that finish mid-block go idle and stop contributing. `out` must point at
// at least `frameCount` writable samples; a null `out` or zero count is a no-op.
// Sums all active voices into the caller-provided buffer `out[0..frameCount)`, adding
// to whatever is there — never allocates (the audio-thread entry point; the VST3
// process callback passes the host's own output buffer). Voices that finish mid-block
// go idle. `out` must point at least `frameCount` writable samples; null/zero is a no-op.
void render(AudioSample* out, std::size_t frameCount);
// REAL-TIME stereo render (S7): sums all active voices per-channel into the caller's two
// buffers `left`/`right` (each `frameCount` writable samples), ADDING to whatever is there
// (the caller clears/mixes). Same RT discipline as the mono overload — no allocation, no
// resize, no lock. A mono sample plays dual-mono (same value to both channels, centered);
// a stereo sample plays its two channels. A null buffer or zero count is a no-op. The mono
// and stereo render paths are independent output shapes over the SAME voice pool; the active
// channel mode (mono vs stereo bus) picks which one the process callback drives per block.
// Stereo overload: sums per-channel into `left`/`right`, same RT discipline. A mono
// sample plays dual-mono (same value both channels); a stereo sample plays its two
// channels. Mono and stereo render are independent output shapes over the same voice
// pool — the active channel mode picks which one the process callback drives per block.
void render(AudioSample* left, AudioSample* right, std::size_t frameCount);
// TEST / off-thread convenience: appends `frameCount` summed frames to `out`
// (grows it — DO NOT call on the audio thread; it allocates). Delegates to the
// real-time overload after sizing the buffer, so both paths share one mix loop.
// Does not clear existing contents — appends, matching the pre-S4 contract the
// unit tests rely on.
// Test/off-thread convenience: appends `frameCount` summed frames to `out` (grows it —
// do not call on the audio thread). Delegates to the real-time overload after sizing
// the buffer. Does not clear existing contents — appends.
void render(std::vector<AudioSample>& out, std::size_t frameCount);
// Count of currently active voices (for tests / diagnostics).
@@ -557,49 +441,45 @@ private:
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
std::size_t allocateVoice();
// Count of active Preserve-engine voices (for the S16 Preserve cap). Rescanned per note-on
// Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
std::size_t activePreserveVoices() const;
// --- MONO mode (Phase S): last-note priority over a held-note stack ------------
// The stack holds every currently-held, ZONE-RESOLVING note in press order (top = most
// recent = the sounding note while the voice is gated). An out-of-zone note never joins
// (it cannot sound, so it must not later take the voice back on a fallback). Re-pressing
// a held note moves it to the top. Fixed-capacity (128 distinct MIDI notes) — no
// allocation on the audio thread. Velocity is kept per held note so a RETRIGGER fallback
// re-strikes the fallen-back-to note at ITS original velocity.
// Mono mode: last-note priority over a held-note stack. The stack holds every
// currently-held, zone-resolving note in press order (top = most recent = the sounding
// note). An out-of-zone note never joins (it cannot sound, so it must not later take
// the voice back on a fallback). Re-pressing a held note moves it to the top.
// Fixed-capacity (128 distinct MIDI notes) — no allocation on the audio thread.
// Velocity is kept per held note so a retrigger fallback re-strikes at its original
// velocity.
struct HeldNote { std::uint8_t note; std::uint8_t velocity; };
// Mono note-on: push to the stack and take the voice over (legato retune on a same-sample
// takeover, else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone
// or out-of-range (note outside [0,127] — rejected BEFORE the stack, which stores uint8).
// The S16 Preserve cap is NOT applied in mono — a single voice runs at most one shifter,
// inherently within any cap; applying it would wrongly drop a Preserve->Preserve takeover.
// Push to the stack and take the voice over (legato retune on a same-sample takeover,
// else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone or
// out-of-range (rejected before the stack, which stores uint8). The Preserve cap is
// not applied in mono — a single voice runs at most one shifter, inherently within any
// cap; applying it would wrongly drop a Preserve->Preserve takeover.
std::size_t monoNoteOn(int note, int velocity);
// Mono note-off: pop from the stack; if the released note was sounding, fall back to the
// most-recent still-held note (retrigger or legato per monoTrigger_), else release.
// Pop from the stack; if the released note was sounding, fall back to the most-recent
// still-held note (retrigger or legato per monoTrigger_), else release.
void monoNoteOff(int note);
// Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent.
void removeHeld(int note);
std::vector<Voice> voices_;
const Keymap& keymap_;
std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap)
std::size_t preserveVoiceCap_ = 0; // max simultaneous Preserve voices (0 = no separate cap)
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
VoiceMode voiceMode_ = VoiceMode::Poly;
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
bool takeoverDeclick_ = false; // GA fix: declick every restart/steal of a sounding voice
bool takeoverDeclick_ = false; // declick every restart/steal of a sounding voice
std::array<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1
std::size_t heldCount_ = 0;
};
// NOTE (preview redesign): the Phase S PreviewCard — a dedicated preview voice isolated
// from the MIDI pool — is RETIRED. The editor's preview trigger is now a synthetic note-on
// at the loaded capture's root note through the SAME VoiceEngine host MIDI drives, so a
// preview is a real voice: it counts against the voice count, can steal / be stolen, and
// respects Poly/Mono + Retrigger/Legato (a deliberate reversal of the earlier isolation
// decision). The FA1 unity-Varispeed demotion in Voice::start went with it — since the GA2
// prime fix the shifter speaks on frame 0 at every ratio, so the demotion bought nothing
// but a second code path.
// The editor's preview trigger is a synthetic note-on at the loaded capture's root note
// through the same VoiceEngine host MIDI drives, so preview is a real voice: it counts
// against the voice count, can steal/be stolen, and respects Poly/Mono + Retrigger/Legato.
// There is no dedicated preview voice isolated from the MIDI pool.
} // namespace reasampler
+28 -56
View File
@@ -2,20 +2,19 @@
#include "core/instrument/engine/velocity_curve.h"
#include <algorithm> // std::max, std::min, std::abs, std::stable_sort
#include <cmath> // std::fabs
#include <utility> // std::move
#include <algorithm>
#include <cmath>
#include <utility>
namespace reasampler::instrument::engine {
namespace {
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).
// X spans the width for [0,127]; Y spans (height-1) rows for amp [0,1] with amp 1 at the TOP
// (pixel y increases downward, so this axis is inverted relative to amp).
double velPerPixel(const VelocityCurve::Box& box) {
const int w = std::max(0, box.width);
if (w <= 0) return 0.0;
@@ -35,7 +34,6 @@ int velToX(const VelocityCurve::Box& box, double velocity) {
int ampToY(const VelocityCurve::Box& box, double amp) {
const int h = std::max(0, box.height);
if (h <= 1) return box.top;
// amp 1 at top (box.top), amp 0 at bottom (box.top + h - 1).
const double frac = (clampAmp(amp) - kAmpMin) / (kAmpMax - kAmpMin);
return box.top + static_cast<int>((1.0 - frac) * static_cast<double>(h - 1) + 0.5);
}
@@ -44,19 +42,19 @@ int ampToY(const VelocityCurve::Box& box, double amp) {
VelocityCurve VelocityCurve::flat() {
VelocityCurve c;
c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}}; // y = 1 everywhere (R10-F1 Option A)
c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}};
return c;
}
VelocityCurve VelocityCurve::linear() {
VelocityCurve c;
c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}}; // y = velocity/127
c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}};
return c;
}
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
// Box-clamp every point, then stable-sort by velocity (X-order; stable so coincident-X points
// keep their wire order). A stable sort keeps the eval well-defined for duplicate-X knots.
// Stable sort so coincident-X points keep their wire order (eval stays well-defined for
// duplicate-X knots).
for (VelocityPoint& p : pts) {
p.velocity = clampVelocity(p.velocity);
p.amp = clampAmp(p.amp);
@@ -65,18 +63,16 @@ VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
[](const VelocityPoint& a, const VelocityPoint& b) {
return a.velocity < b.velocity;
});
// Fewer than 2 usable points -> can't span [0,127] as a function; fall back to the flat default.
if (pts.size() < 2) return flat();
// Force endpoints present at velocity 0 and 127 (they must exist for eval to be total).
if (pts.front().velocity > kVelMin) {
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().amp});
} else {
pts.front().velocity = kVelMin; // snap a near-0 first point exactly onto the endpoint
pts.front().velocity = kVelMin;
}
if (pts.back().velocity < kVelMax) {
pts.push_back(VelocityPoint{kVelMax, pts.back().amp});
} else {
pts.back().velocity = kVelMax; // snap a near-127 last point exactly onto the endpoint
pts.back().velocity = kVelMax;
}
VelocityCurve c;
c.points_ = std::move(pts);
@@ -85,19 +81,12 @@ VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
namespace {
// FritschCarlson monotone-cubic tangent for one interior knot i, given the secant slopes of the
// two adjacent segments (dPrev = secant into knot i, dNext = secant out of knot i). Returns the
// limited tangent that keeps the cubic Hermite piece monotone and inside the data range.
//
// The rule: a tangent whose adjacent secants have opposite signs (or either is flat) is a local
// extremum — pin the tangent to 0 so the curve does not overshoot past the knot. Otherwise use the
// weighted-harmonic-mean tangent (FritschCarlson eq. 4), which for COLLINEAR knots (dPrev==dNext)
// reduces to that common secant — so collinear control points reproduce the straight line to within
// floating-point rounding (~1e-15), preserving the Option-B / null-response contract for linear().
// Fritsch-Carlson monotone-cubic tangent: a sign change (or flat) neighbour is a local extremum,
// so the tangent pins to 0 to avoid overshoot; otherwise the weighted-harmonic-mean tangent,
// which for collinear knots (dPrev==dNext) reduces exactly to the shared secant — this is what
// makes the spline reproduce a straight line to ~1e-15 for linear()-style input.
double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) {
if (dPrev * dNext <= 0.0) return 0.0; // sign change or a flat neighbour -> local extremum
// Weighted harmonic mean of the two secants (weights = the two segment widths). Collinear case:
// dPrev==dNext==d makes this (w1+w2)*d / ((w1+w2)/... ) collapse to d exactly.
if (dPrev * dNext <= 0.0) return 0.0;
const double w1 = 2.0 * spanNext + spanPrev;
const double w2 = spanNext + 2.0 * spanPrev;
return (w1 + w2) / (w1 / dPrev + w2 / dNext);
@@ -106,33 +95,23 @@ double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double
} // namespace
double VelocityCurve::eval(double velocity) const {
if (points_.empty()) return kAmpMax; // degenerate (shouldn't occur) -> flat unity
if (points_.size() == 1) return clampAmp(points_[0].amp); // 1-point -> that point's amp
if (points_.empty()) return kAmpMax;
if (points_.size() == 1) return clampAmp(points_[0].amp);
const double v = clampVelocity(velocity);
// At or before the first point / at or after the last, read the endpoint amp (the endpoints are
// at 0 and 127, so this only fires exactly at the ends for an in-range velocity).
if (v <= points_.front().velocity) return clampAmp(points_.front().amp);
if (v >= points_.back().velocity) return clampAmp(points_.back().amp);
// Find the segment [points_[i], points_[i+1]] containing v (X-ordered, so a linear scan).
for (std::size_t i = 0; i + 1 < points_.size(); ++i) {
const VelocityPoint& a = points_[i];
const VelocityPoint& b = points_[i + 1];
if (v >= a.velocity && v <= b.velocity) {
const double span = b.velocity - a.velocity;
// Coincident-X neighbours (a step): jump straight to the later point's amp — the segment
// has zero width so there is no interior to blend.
// Coincident-X neighbours (a step): zero-width segment, no interior to blend.
if (span <= 0.0) return clampAmp(b.amp);
// --- Monotone cubic Hermite (FritschCarlson) interpolation on segment [a,b] ---------
// Curved (spline) response, not straight lines. The interpolant provably stays within
// [a.amp, b.amp] between the two knots (no bulge below 0 / above 1), and for collinear
// control points its tangents reduce to the secant slope — so it reproduces the straight
// line to within floating-point rounding (~1e-15), preserving linear()'s null-response
// contract (y = velocity/127 to ~1e-15; the test tolerance of 1e-12 is appropriate).
const double d = (b.amp - a.amp) / span; // secant of THIS segment
// Monotone cubic Hermite (Fritsch-Carlson): provably stays within [a.amp, b.amp]
// between the two knots (no overshoot), reproducing a straight line for collinear input.
const double d = (b.amp - a.amp) / span;
// Tangent at a: 0 if a is the first knot (endpoint), else the FC-limited tangent using
// the previous segment's secant. Same for the tangent at b (0 at the last knot).
double mA = d;
if (i > 0) {
const VelocityPoint& prev = points_[i - 1];
@@ -141,7 +120,7 @@ double VelocityCurve::eval(double velocity) const {
const double dPrev = (a.amp - prev.amp) / spanPrev;
mA = fritschCarlsonTangent(dPrev, d, spanPrev, span);
} else {
mA = 0.0; // coincident-X predecessor (a step at a) -> flat tangent
mA = 0.0;
}
}
double mB = d;
@@ -152,13 +131,10 @@ double VelocityCurve::eval(double velocity) const {
const double dNext = (next.amp - b.amp) / spanNext;
mB = fritschCarlsonTangent(d, dNext, span, spanNext);
} else {
mB = 0.0; // coincident-X successor (a step at b) -> flat tangent
mB = 0.0;
}
}
// Cubic Hermite basis on the normalized position t across [a,b]. For collinear knots
// mA==mB==d, so h00*a + (h10*span)*d + h01*b + (h11*span)*d collapses to the straight
// line to within floating-point rounding (~1e-15).
const double t = (v - a.velocity) / span;
const double t2 = t * t;
const double t3 = t2 * t;
@@ -175,8 +151,7 @@ double VelocityCurve::eval(double velocity) const {
std::size_t VelocityCurve::addPoint(double velocity, double amp) {
const VelocityPoint p{clampVelocity(velocity), clampAmp(amp)};
// Insert keeping X-order: first index whose velocity is STRICTLY greater than the new one, so a
// duplicate-X point lands immediately after the existing one (a later move can separate them).
// First index strictly greater, so a duplicate-X point lands immediately after the existing one.
std::size_t i = 0;
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p);
@@ -191,11 +166,10 @@ VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, doubl
double newAmp = clampAmp(amp);
double newVel;
if (isFirst) {
newVel = kVelMin; // endpoint pinned in X at 0 — only amp moves
newVel = kVelMin;
} else if (isLast) {
newVel = kVelMax; // endpoint pinned in X at 127 — only amp moves
newVel = kVelMax;
} else {
// 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 = std::clamp(clampVelocity(velocity), lo, hi);
@@ -216,9 +190,7 @@ VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box, const Ve
}
VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) {
// The exact inverse of velToX/ampToY (within the one-pixel rounding quantum). Degenerate
// dimensions collapse the same way the forward map does: velToX pins to box.left (velocity 0),
// ampToY pins to box.top (amp 1).
// Exact inverse of velToX/ampToY (within one pixel); degenerate dims collapse the same way.
VelocityPoint p;
const int w = std::max(0, box.width);
const int h = std::max(0, box.height);
+39 -105
View File
@@ -1,43 +1,13 @@
// velocity_curve.h — PURE velocity->amp transfer curve (S-VIEW-9, r10). NO VST3, NO REAPER, NO
// SWELL/LICE, NO vendor/ includes at the boundary. The mirror of envelope_edit / card_drag: the
// eval + the clamp/order/inverse-map arithmetic live here, unit-tested outside the DAW; the future
// editor shell (reasampler_editor.cpp, S-VIEW-10) draws the box + node handles and feeds each move's
// pixel delta back through here, committing the result to the zone through the same off-audio-thread
// path a slider edit uses.
//
// WHAT IT IS. A monotonic-in-x transfer function mapping MIDI velocity (X: 0..127) to an amp scalar
// (Y: 0..1), authored as an ordered list of control points. eval(velocity) is called ONCE per
// note-on in Voice::start() (never per frame) to set the voice's velocityGain_, replacing the fixed
// linear velocity/127 map. The curve is a per-PerformanceZone performance characteristic (D-B) — a
// sibling of the AHDSR envelope, pitch engine, and keyTrack scalar — so it varies per sound, stored
// on PerformanceZone and resolved onto the KeyZone at keymap build (mirror of keyTrack).
//
// DEFAULT — flat y=1 (fork R10-F1 Option A, Daniel 2026-07-27). VelocityCurve::flat() is the seeded
// default: EVERY velocity plays at unity amp. This is a DELIBERATE, Daniel-approved behavior change
// vs. the shipped linear velocity/127 map — soft hits are now full level until a curve is drawn.
// NOT bit-identical to the pre-r10 engine, by design; do not "preserve" the linear response.
//
// THE INVARIANT (mirror of envelope_edit's S-VIEW-F2). A drag/edit can NEVER produce a curve eval
// couldn't handle:
// * X-ORDERED — a point clamps between its predecessor's and successor's velocity, so control
// points never cross in X. This is what makes eval a well-defined FUNCTION (one amp per
// velocity): each X falls in exactly one [p_i, p_{i+1}] segment.
// * BOX-CLAMPED — velocity clamps to [0,127], amp clamps to [0,1] (the drawn box).
// Both endpoints (velocity 0 and 127) are always present so eval is total over [0,127]; delete
// refuses to remove them, and the constructors seed them.
// velocity_curve.h — velocity->amp transfer curve. eval(velocity) is called once per note-on
// in Voice::start(), never per frame. Editor hit-test/inverse-map take an explicit pixel Box
// rather than a Rect: this module sits below sampler_core in the link graph and must not gain
// a transitive dependency on editor-layout types.
#pragma once
#include <cstdint>
#include <vector>
// DELIBERATELY dependency-free at the boundary (no editor_geometry / Rect). This module sits BELOW
// sampler_core in the link graph (KeyZone carries a VelocityCurve; Voice::start calls eval), and the
// engine must not gain a transitive dependency on the editor's layout types. The editor hit-test /
// inverse-map therefore takes an explicit pixel box (boxLeft/boxTop/boxWidth/boxHeight) rather than a
// 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::instrument::engine {
// The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into.
@@ -46,81 +16,56 @@ inline constexpr double kVelMax = 127.0;
inline constexpr double kAmpMin = 0.0;
inline constexpr double kAmpMax = 1.0;
// One control point: a (velocity, amp) knot the curve passes through. Both fields are box-clamped
// by the mutators; a raw-constructed point is NOT auto-clamped (the mutators own the invariant), so
// build curves through the named constructors / addPoint rather than pushing raw points.
// A raw-constructed point is NOT auto-clamped (the mutators own that invariant) — build curves
// through the named constructors / addPoint rather than pushing raw points.
struct VelocityPoint {
double velocity = 0.0; // X, [0,127]
double amp = 0.0; // Y, [0,1]
};
// The pick radius (px) around a node's drawn point for the editor hit-test. Mirrors
// envelope_edit::kNodeGrabRadius / waveform_view::kMarkerGrabWidth.
// Pick radius (px) around a node's drawn point for the editor hit-test.
inline constexpr int kCurveNodeGrabRadius = 6;
// A velocity->amp transfer curve: an X-ORDERED list of control points spanning [0,127], evaluated by
// a MONOTONE cubic Hermite spline (FritschCarlson slope limiting) through the knots — a genuine
// curved response (Daniel 2026-07-27: "straight lines sound like shit"), not a polyline. Each
// velocity still maps to exactly one amp: the interpolant is single-valued and provably stays within
// each segment's amp range, so the curve never overshoots below 0 or above 1. For COLLINEAR knots the
// FritschCarlson tangents reduce to the secant slope, so the spline reproduces the straight line to
// within floating-point rounding (~1e-15) — that preserves linear()'s null-response contract
// (y = velocity/127 to ~1e-15; the 1e-12 test tolerance is deliberately conservative). The two endpoints
// (velocity 0 and 127) are load-bearing: they keep eval total and are never deletable.
// An X-ordered list of control points spanning [0,127], evaluated by a monotone cubic Hermite
// spline (Fritsch-Carlson slope limiting) — a genuine curve, not a polyline, that provably never
// overshoots a segment's amp range. For collinear knots the tangents reduce to the secant slope,
// so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints (velocity
// 0 and 127) are load-bearing: they keep eval total over the domain and are never deletable.
class VelocityCurve {
public:
// R10-F1 default (Option A): flat y=1 — endpoints (0,1) and (127,1); every velocity -> unity.
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the default — see
// velocity_curve in the directory CLAUDE.md for why this isn't bit-identical to the
// pre-existing linear() response.
static VelocityCurve flat();
// The classic linear ramp y = velocity/127 — endpoints (0,0) and (127,1). Retained for tests
// and as the Option-B seed; NOT the default (see R10-F1).
static VelocityCurve linear();
// Rebuild a curve from a deserialized point list, REPAIRING the invariant defensively (the
// deserialization seam, sample_map's zones-payload v7). Each point is box-clamped; the list is
// stable-sorted by velocity (X-ordered); endpoints at velocity 0 and 127 are forced present
// (an absent endpoint is synthesized at the nearest interior amp, or unity for an empty list).
// A list with fewer than 2 usable points falls back to flat(). Never trusts the wire blindly —
// a corrupt/truncated blob yields a well-formed curve, never an invariant-violating one.
// Rebuilds from a deserialized point list, repairing the invariant defensively: box-clamps
// each point, stable-sorts by velocity, forces both endpoints present (synthesized if
// missing), falls back to flat() if fewer than 2 usable points remain. A corrupt/truncated
// blob yields a well-formed curve, never an invariant-violating one.
static VelocityCurve fromPoints(std::vector<VelocityPoint> pts);
// The control points, X-ordered, first at velocity 0 and last at velocity 127 (invariant).
const std::vector<VelocityPoint>& points() const { return points_; }
std::size_t size() const { return points_.size(); }
// Evaluate the curve at `velocity` -> amp in [0,1]. Velocity is box-clamped to [0,127] first,
// so an out-of-range note (shouldn't occur) reads the nearest endpoint. Between two adjacent
// points the amp follows a MONOTONE cubic Hermite spline (FritschCarlson slope limiting) — a
// true curve that provably stays within the two knots' amp range (no overshoot below 0 / above
// 1) and reproduces the straight line to within floating-point rounding (~1e-15) for collinear
// knots. Single-valued / monotonic in X.
// Degenerate cases (shouldn't occur post-construction): an EMPTY curve returns kAmpMax (flat
// unity); a ONE-point curve returns that point's amp.
// Degenerate cases (shouldn't occur post-construction): empty curve returns kAmpMax; a
// one-point curve returns that point's amp.
double eval(double velocity) const;
// --- Editing (for the S-VIEW-10 editor UI) --------------------------------------------------
// Insert a new control point, box-clamped, keeping the list X-ordered by velocity. Returns the
// index of the inserted point. A new point at a velocity that duplicates an existing one is
// inserted immediately AFTER it (so a subsequent move can separate them); the endpoints are not
// special-cased on insert (a point at exactly 0 or 127 inserts adjacent to that endpoint).
// Inserted at a velocity duplicating an existing point lands immediately after it, so a
// subsequent move can separate them. Returns the inserted index.
std::size_t addPoint(double velocity, double amp);
// Move point `index` to (velocity, amp), box-clamped AND X-clamped between its immediate
// neighbours so it cannot cross them (monotonic-X grammar). The two ENDPOINTS are pinned in X
// (index 0 stays at velocity 0, the last stays at 127) — only their AMP moves; their velocity
// argument is ignored. An out-of-range index is a no-op. Returns the (possibly clamped)
// resulting point.
// Box-clamped and X-clamped between immediate neighbours (monotonic-X grammar). The two
// endpoints are pinned in X (only their amp moves); out-of-range index is a no-op.
VelocityPoint movePoint(std::size_t index, double velocity, double amp);
// Delete point `index`. The two endpoints (index 0 and the last) are NOT deletable — a request
// to remove either, or an out-of-range index, is a no-op returning false. Returns true iff a
// point was removed.
// Endpoints (index 0 and last) are not deletable; that or an out-of-range index is a no-op
// returning false.
bool deletePoint(std::size_t index);
// --- Editor hit-test + inverse map (mirror of envelope_edit) --------------------------------
// The drawn box, in pixels: origin (boxLeft, boxTop), `boxWidth` px wide, `boxHeight` px tall.
// X = velocity across the width (0 at boxLeft, 127 at boxLeft+boxWidth); Y = amp UP the height
// (amp 1 at boxTop, amp 0 at boxTop+boxHeight-1). Passed explicitly (not a Rect) so this module
// stays free of editor-layout types — see the header preamble.
// The drawn box, in pixels: X = velocity across the width, Y = amp UP the height (amp 1 at
// top). Passed explicitly rather than a Rect — see header preamble.
struct Box {
int left = 0;
int top = 0;
@@ -128,43 +73,32 @@ public:
int height = 0;
};
// Which control point a grab at (x,y) lands on, given the drawn `box`. Returns the index of the
// first point within the pick radius in BOTH axes, or -1 for a miss. First-match in point order
// for determinism (mirror of nodeAtPoint).
// Index of the first point within the pick radius on both axes, or -1 for a miss. First-match
// in point order for determinism.
int pointAtPixel(const Box& box, int x, int y) const;
// A node's drawn pixel position (S-VIEW-10). The ONE point->pixel mapping — the same mapping
// pointAtPixel hit-tests against — exposed so the editor shell draws the trace + node handles
// at exactly the coordinates the hit-test expects (draw and grab can never drift).
// The one point->pixel mapping, exposed so drawing and hit-testing can never drift apart.
struct CurvePixel {
int x = 0;
int y = 0;
};
static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p);
// The absolute pixel -> (velocity, amp) inverse (S-VIEW-10): where an empty-space click lands
// as a NEW control point, box-clamped. The exact inverse of pixelFromPoint's mapping (within
// the one-pixel quantum), so an added point appears under the cursor. Degenerate box: a
// zero-width box reads velocity 0; a height <= 1 box reads amp 1 (the top row), mirroring
// pixelFromPoint's degenerate collapse.
// Exact inverse of pixelFromPoint (within the one-pixel quantum) — where an empty-space click
// lands as a new point. Degenerate box: zero-width reads velocity 0; height <= 1 reads amp 1.
static VelocityPoint pointFromPixel(const Box& box, int x, int y);
// Resolve a drag of point `index` by a pixel delta since grab, given the curve AS OF GRAB TIME
// (`grabCurve` — the shell snapshots it on mouse-down so the delta is absolute) and the box.
// Maps the pixel delta to a (velocity, amp) delta over the box, then applies movePoint's clamp
// (box + neighbour X + endpoint X-pin). A zero-width/height box or out-of-range index returns
// `grabCurve` unchanged. Pure — mirror of resolveNodeDrag.
// `grabCurve` is the curve as of mouse-down (shell snapshots it so the delta is absolute).
// Maps the pixel delta to velocity/amp over the box, then applies movePoint's clamp. Zero
// width/height box or out-of-range index returns grabCurve unchanged.
static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
const Box& box, int dxPixels, int dyPixels);
// Equality (for tests + round-trip assertions): same point count + each point equal within a
// tight epsilon.
bool equals(const VelocityCurve& other, double eps = 1e-9) const;
private:
// Points are always X-ordered with an endpoint at 0 and 127. Constructed only through the named
// constructors + deserialize (see sample_map), which establish that invariant; the mutators
// preserve it.
// Always X-ordered with an endpoint at 0 and 127; constructors + deserialize establish the
// invariant, mutators preserve it.
std::vector<VelocityPoint> points_;
};
+72 -122
View File
@@ -1,122 +1,88 @@
#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.
// zone_params.h — per-zone play-parameter value structs + per-instance mode enums shared by
// the engine, sample_map, the ComponentState codec, and the editor. Split out of sampler_core.h
// so a UI/codec TU reading a param struct doesn't recompile when a Voice/VoiceEngine member
// changes. The per-frame evaluator classes (AdsrEnvelope/TriggerEnvelope/PitchEnvelope) and the
// engine (Keymap/Voice/VoiceEngine) stay in sampler_core.h.
#include <cstdint>
#include <vector>
#include "core/audio/peaks.h" // AudioSample (float)
#include "core/audio/peaks.h"
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.
// Decode-side downmix policy (see root CLAUDE.md — the output bus itself is permanently
// stereo; this only picks mono-downmix vs dual-mono at decode). Never written to the bank.
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.
// POLY is the fixed-pool engine with bounded stealing; MONO is a single voice with last-note
// priority over a held-note stack (a new note takes over; releasing the top note falls back to
// the most-recent still-held one). Never a bank fact. Default Poly.
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.
// How a MONO takeover treats the envelopes. RETRIGGER restarts amp/pitch envelopes on every new
// mono note. LEGATO keeps the envelope running across a takeover (pitch moves without a
// re-attack) but only for a SAME-SAMPLE takeover — one read head can't glide between two PCM
// streams, so crossing into a different sample always restarts the voice. Meaningless in Poly.
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.
// Shared range so the engine, the component-state codec, and the editor control can't drift.
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.
// AHDSR amplitude envelope. holdFrames == 0 is exactly the pre-hold-stage ADSR (back-compat).
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 holdFrames = 0;
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.
// GATE = classic held note (AHDSR + sustain loop + note-off release). TRIGGER = one-shot:
// note-off-immune, no sustain loop, plays a % of sample length shaped by fade-in/out. Both
// honor the start point. Per-zone; default Gate so an instrument with no params set 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.
// Playback covers [startFrame, playEnd), playEnd = startFrame +
// round(lengthFraction*(frames - startFrame)). 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.
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
std::int64_t fadeInFrames = 0;
std::int64_t fadeOutFrames = 0;
};
// 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.
// EQUAL_POWER (constant-power sin/cos) is the click-free default for Trigger's ramps; LINEAR is
// the build-time residual.
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).
// VARISPEED: readPos_ += ratio_, pitch and duration coupled (an octave up plays half as long).
// PRESERVE: 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.
// Product default is Preserve, but applied at the state boundary (sample_map deserialize /
// editor zone-creation) for new/absent zones, NOT here: ZonePlayParams.pitchEngine itself
// defaults to Varispeed so "no params == the bare engine" holds for the core's own regression
// tests (an octave up still halves duration with no params set).
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.
// OLA window for the Preserve PitchShifter, in ms at the voice's sample rate; 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.
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 (+/-).
// AD pitch-modulation envelope, off by default (enabled=false -> offset always 0 -> bit-identical
// to the un-modulated engine). At note-on the offset rises to peakSemitones over attackFrames,
// then falls to 0 over decayFrames; a zero attack gives a pure percussive pitch drop.
struct PitchEnvParams {
bool enabled = false;
std::int64_t attackFrames = 0;
@@ -124,69 +90,53 @@ struct PitchEnvParams {
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.
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
// Varispeed, pitch envelope off) — core regression tests rely on this; the Preserve product
// default is layered on at (de)serialization, see kDefaultPitchEngine.
struct ZonePlayParams {
PlayMode playMode = PlayMode::Gate;
AdsrParams adsr; // Gate: the AHDSR envelope
TriggerParams trigger; // Trigger: %-length + fades
AdsrParams adsr;
TriggerParams trigger;
PitchEngine pitchEngine = PitchEngine::Varispeed;
PitchEnvParams pitchEnv; // AD pitch modulation, off by default
PitchEnvParams pitchEnv;
};
// ---------------------------------------------------------------------------
// 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.
// ---------------------------------------------------------------------------
// Sample data the core plays: plain decoded PCM + the 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.
// [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.
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
std::int64_t start = 0;
std::int64_t end = 0;
};
// 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.
// Deinterleaved per-channel: `frames` is channel 0 (always present), `framesR` is channel 1
// (present only for a stereo sample). Stereo iff `framesR` is non-empty and the same length as
// `frames`; a mismatched length is treated as absent (mono) rather than half-playing. Both
// channels share `readPos_`/`rootNote`/`loop`, so repitch/loop stay per-frame identical across
// channels. `rootNote` is the MIDI note the file was recorded at — plays at unity ratio there.
struct SampleData {
std::vector<AudioSample> frames; // channel 0 PCM (mono, or L of a stereo sample)
std::vector<AudioSample> 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::vector<AudioSample> frames;
std::vector<AudioSample> framesR; // empty for a mono sample
int sampleRate = 0; // ratio math is note-relative, so rate cancels for
// repitch; still, 0 is invalid — every consumer must
// receive a real rate before use.
int rootNote = 60;
SampleLoop loop;
// Frame offset a voice starts playback at; frame 0 default is the pre-existing behavior.
// Clamped into [0, frames) at note-on — a start >= sample length is a no-op (starts at 0).
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.
// A framesR of a different length than frames is treated as absent — a malformed pair
// never half-plays.
int channelCount() const {
return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1;
}
};
} // namespace reasampler
+7 -16
View File
@@ -10,20 +10,14 @@
namespace reasampler::instrument::map {
std::int64_t parseBankGeneration(const std::string& raw) {
// 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.
// Whole-string, non-negative decimal parse, no exceptions/locale surprises (core/wire's
// guarded accumulate). Leading sign, non-digit, empty, or int64 overflow -> absent (0).
std::int64_t value = 0;
if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent;
return value;
}
std::string formatBankGeneration(std::int64_t generation) {
// Non-negative decimal; a negative (should never be produced by the writer) formats as
// its std::to_string form and would parse back to 0, so the writer's monotonic counter
// stays in the >= 0 domain by construction.
return std::to_string(generation);
}
@@ -37,23 +31,20 @@ AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& re
AssignConsumeDecision d;
d.consumedGeneration = lastConsumed; // default: nothing changes
// Rule 1: no request, or not newer than what we already consumed -> nothing new.
// Rule 1: no request, or not newer than what we already consumed.
if (!request) return d;
if (request->generation <= lastConsumed) return d;
// Rule 2: a new request, but this instance is not the target -> do not act, do NOT
// advance the marker (stay eligible if focus later lands here). No thundering herd.
// Rule 2: new but not our target -> don't advance the marker, stay eligible.
if (!isFocusedTarget) return d;
// The request is new AND we are the target: it will be consumed-as-seen either way, so
// advance the marker to its generation so it is never re-evaluated.
// New and our target: consumed-as-seen either way.
d.consumedGeneration = request->generation;
// Rule 3: unresolvable (bankId, sampleId) -> DROP silently (reader requirement): marker
// advanced above, but no selection change.
// Rule 3: unresolvable -> drop silently, marker already advanced above.
if (!resolves) return d;
// Rule 4: new, target, resolvable -> apply the selection.
// Rule 4: new, target, resolvable -> apply.
d.apply = true;
d.bankId = request->bankId;
d.sampleId = request->sampleId;
+27 -74
View File
@@ -1,21 +1,11 @@
#pragma once
// bank_sync — PURE decision logic for the S9 bank-generation change-detection and the
// S8 instrument-side assignment-request consume. NO VST3, NO REAPER, NO SWELL, NO
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the mirror of
// sample_map / bridge_marshal splitting the fiddly, testable arithmetic out of a
// host-facing shell.
//
// WHY IT EXISTS (S9/S8 reader seams). The instrument polls two "reasampler" ext-state
// keys off the audio thread: the S9 bank-generation counter (has the bank changed?) and
// the S8 assignment request (should I switch to a just-ingested sample?). The RAW string
// read crosses the bridge in the shell; every DECISION after — parse the generation
// stamp, decide whether it differs from what we last saw, decide whether a decoded
// assignment request is NEW-and-resolvable-and-worth-applying — is pure and lives here.
//
// The processor shell owns the cadence (a UI-thread timer, NEVER process) and the side
// effects (reloadInstrument, setSelectedSampleId); this module owns only the yes/no maths so
// the reader's rules are provable without a host. assignment_request.h owns the WIRE format
// (encode/decode); this module owns the CONSUME decision layered over a decoded request.
// bank_sync — decision logic for bank-generation change-detection and the instrument-side
// assignment-request consume. The instrument polls two "reasampler" ext-state keys off the
// audio thread: the bank-generation counter (has the bank changed?) and the assignment
// request (should I switch to a just-ingested sample?). The shell reads the raw strings and
// owns cadence (a UI-thread timer, never `process`) + side effects (reloadInstrument,
// setSelectedSampleId); this module owns only the yes/no decisions, so they're provable
// without a host. assignment_request.h owns the wire format; this owns the consume decision.
#include <cstdint>
#include <optional>
@@ -27,41 +17,26 @@ 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
// which parses to this, and the first real bump (>= 1) then reads as a change. Matches the
// writer's monotonic-from-1 counter (the extension bumps to 1 on the first mutation).
// Default for a project with no bank_generation key yet (pre-existing project); the first
// real bump (>= 1) then reads as a change against this.
inline constexpr std::int64_t kBankGenerationAbsent = 0;
// Parse the raw bank-generation ext-state value the bridge read. The writer stamps a
// non-negative decimal integer (formatBankGeneration). Absent / empty / malformed / negative
// / overflowing all yield kBankGenerationAbsent (0) — the reader treats any unreadable stamp
// as "generation 0", so a pre-S9 or corrupt value is a clean default, never a crash and never
// a spurious reload storm (0 vs a previously-seen 0 is no change). Whole-string parse: trailing
// garbage after the digits rejects the value (returns 0), so a torn/partial write is ignored
// until the next clean poll (the read tolerates staleness by design — it reloads on the NEXT
// poll once the value is clean).
// Absent / empty / malformed / negative / overflowing all yield kBankGenerationAbsent (0),
// never a crash or spurious reload. Whole-string parse: trailing garbage rejects the value,
// so a torn/partial write is ignored until the next clean poll.
std::int64_t parseBankGeneration(const std::string& raw);
// Format a bank-generation counter for the ext-state stamp. The inverse of
// parseBankGeneration for a non-negative value: a plain decimal, no sign, no padding, so
// the stamp is byte-stable across writes of the same value.
// Inverse of parseBankGeneration: plain decimal, no sign, no padding — byte-stable across
// writes of the same value.
std::string formatBankGeneration(std::int64_t generation);
// Has the bank generation changed since the reader last saw `seen`? True when `current`
// differs from `seen` — the reader then triggers a reload. Any difference counts (not just
// an increase): the writer is monotonic, but a project switch or reload can legitimately
// lower the value, and the reader should re-read the bank in that case too. `seen` starts at
// kBankGenerationAbsent so the first non-zero generation reads as a change (the pre-S9 /
// first-bump refresh the spec requires).
// True when `current` differs from `seen` (not just increases — a project switch/reload can
// legitimately lower the value, and the reader should still re-read the bank).
bool bankGenerationChanged(std::int64_t seen, std::int64_t current);
// The verdict of the S8 assignment-request consume decision (below). A pure value the
// processor shell acts on: apply the selection (or not) and advance the consumed marker
// (or not). Distinct booleans because the two are NOT the same event — a request may be
// consumed-as-seen (marker advances) without being applied (it named an unresolvable
// sample and was DROPPED per the reader requirement), so the shell must not re-evaluate it
// every poll.
// Verdict of the assignment-request consume decision below. apply and consumedGeneration
// advancing are NOT the same event — a request naming an unresolvable sample is dropped
// (consumed-as-seen) without applying, so the shell doesn't re-evaluate it every poll.
struct AssignConsumeDecision {
bool apply = false; // set this instance's selection to (bankId, sampleId) + reload
std::string bankId; // the request's bank (valid only when apply)
@@ -69,37 +44,15 @@ struct AssignConsumeDecision {
std::int64_t consumedGeneration = 0; // the marker to persist (== lastConsumed when nothing new)
};
// Decide whether to CONSUME a decoded assignment request (S8 instrument-side reader).
// `lastConsumed` persists across reopen so a request already applied and manually changed
// away from is not reapplied. `resolves` is whether (bankId, sampleId) exists in the live
// bank right now. `isFocusedTarget` gates thundering-herd (only the focused-editor instance
// applies; others neither apply nor advance their marker, staying eligible if focus moves).
//
// `request` — the decoded assignment request (nullopt when the assign_request key
// is absent / malformed — nothing pending).
// `lastConsumed` — the generation this instance last consumed (persisted in component
// state so a re-open does not re-apply a request the user already got,
// then manually changed away from). Defaults to 0 for a fresh instance.
// `resolves` — whether the request's (bankId, sampleId) resolves to an existing bank
// sample RIGHT NOW (the shell computed this against the live bank blob).
// `isFocusedTarget` — whether THIS instance is the assignment target under the shell's
// thundering-herd policy (e.g. only the focused-editor instance applies).
// The shell passes true when this instance should act; false suppresses
// consumption entirely so a non-target instance neither applies nor
// advances its marker (it stays eligible if it later becomes the target).
//
// RULES (all pure, order matters):
// 1. No request, or an OLDER/equal generation (<= lastConsumed): nothing new — do not
// apply, marker unchanged. (Covers the re-open case: the persisted marker == the
// request's generation, so it is not re-applied.)
// 2. A NEW request (generation > lastConsumed) but NOT this instance's target: do not
// apply and do NOT advance the marker — a non-target instance must stay able to consume
// the request if focus later lands on it. (No thundering herd: only the target acts.)
// 3. A NEW request, this instance IS the target, but the (bankId, sampleId) does NOT
// resolve: DROP it silently (assignment_request.h reader requirement) — do not apply,
// but DO advance the marker to the request's generation so a stale/unresolvable request
// is consumed-as-seen and never re-evaluated (no error state, no selection change).
// 4. A NEW request, target, and resolvable: APPLY (selection <- (bankId, sampleId)) and
// advance the marker to the request's generation.
//
// The shell then: if apply, setSelectedSampleId + reloadInstrument; always persist
// consumedGeneration into component state when it advanced.
// Rules, in order: (1) no request or generation <= lastConsumed -> no-op. (2) new but not
// the target -> no-op, marker unchanged (stays eligible later). (3) new, target, but doesn't
// resolve -> drop silently, marker still advances (consumed-as-seen, never re-evaluated).
// (4) new, target, resolves -> apply + advance marker.
AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request,
std::int64_t lastConsumed, bool resolves,
bool isFocusedTarget);
+2 -3
View File
@@ -6,9 +6,8 @@ namespace reasampler::instrument::map {
std::optional<std::string> decodeGetProjExtState(int apiReturn,
const std::string& buffer) {
// REAPER returns the length of the stored value; 0 means the key is absent. Guard
// both the return AND the buffer: a caller that reused a dirty buffer must not
// surface stale bytes as a value when the API reported nothing.
// 0 return means absent; guard the buffer too so a reused dirty buffer can't
// surface stale bytes as a value.
if (apiReturn <= 0 || buffer.empty()) return std::nullopt;
return buffer;
}
+6 -27
View File
@@ -1,21 +1,8 @@
// bridge_marshal.hPURE marshalling helper for the REAPER VST-host bridge read.
// NO VST3, NO REAPER types at the boundary.
//
// 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_codec splitting the arithmetic out
// of a REAPER-facing shell.
//
// The S1 spike ALSO carried a string-scan JSON reader (extractJsonStringField) as a
// stand-in until the instrument could parse the bank properly. S4 retired it: the
// instrument now parses the "reasampler" bank blob through the SHARED bank_book /
// bank_model JSON path (sample_map.cpp), so there is no second JSON parser. This module
// is back to its one honest job — the API-return decode.
// bridge_marshal — pure GetProjExtState result decode for the REAPER VST-host bridge read.
//
// Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
// int GetProjExtState (ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz);
// -- returns the length written (0 when the key is absent).
// int GetProjExtState(ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz)
// returns the length written (0 when the key is absent).
#pragma once
@@ -26,18 +13,10 @@
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
// non-empty result AND the buffer is non-empty — REAPER writes 0 and leaves the buffer
// untouched for an absent key, and we must not treat stale buffer contents as a hit.
//
// `apiReturn` is GetProjExtState's return; `buffer` is the NUL-terminated string it
// wrote (already truncated to the C string by the caller).
// Value only when the API reported non-empty AND the buffer is non-empty — REAPER
// writes 0 and leaves the buffer untouched for an absent key, so stale buffer
// contents must never read as a hit.
std::optional<std::string> decodeGetProjExtState(int apiReturn,
const std::string& buffer);
// 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
+103 -116
View File
@@ -1,8 +1,6 @@
// 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.
// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7).
// Every wire format is FROZEN — byte-identical across revisions.
#include "core/instrument/map/component_state_io.h"
@@ -28,13 +26,11 @@ 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<std::uint64_t>(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.
// 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 (marker +
// version + extended records: loop/start tail + full play-params tail in SECONDS); the
// marker precedes the zone count so any reader can detect record shape independent of the
// envelope version (see sample_map.h).
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
putLE(out, kZonesFormatMarker);
putLE(out, kZonesPayloadVersion);
@@ -49,7 +45,7 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
putLE(out,
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride)));
}
// S11 extension: loop override (hasLoop flag + start/end), then start point.
// 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);
@@ -59,9 +55,9 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
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.
// Play params (PAYLOAD v5): always present. 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
@@ -78,10 +74,10 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
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).
// PAYLOAD v6: 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).
// PAYLOAD v7: the per-zone velocity->amp transfer curve, appended last. 4-byte LE
// control-point count, then per point velocity + amp as doubles (endpoints included).
const std::vector<VelocityPoint>& pts = z.velocityCurve.points();
putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& p : pts) {
@@ -91,30 +87,30 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
}
}
// 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.
// Read a zones payload from `r` into `map`. Shared by the performance parse and the
// component parse. Detects the format marker: present -> PAYLOAD v2+ (extended records with
// the loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (no tail — clean
// back-compat lift, overrides 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 seconds at the read boundary: seconds = frames
// / projectRate. Must be > 0 (callers guard). v5+ 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
bool extended = false; // v2+: the 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 legacyV3Play = (pv == 3); // legacy 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 bool keyTrackTail = (pv >= 6); // v6+: per-zone keyTrack scalar
const bool curveTail = (pv >= 7); // v7+: 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).
// z.play defaults to the product defaults (Gate + Preserve + tier-0 AHDSR seconds).
// A v1/v2 payload (no play tail) lifts every zone to those defaults.
PerformanceZone z;
const std::uint32_t idLen = r.u32();
z.sampleId = r.str(idLen);
@@ -135,10 +131,10 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
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.
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv
// A/D) were written as frames -> divide by `projectRate` to reach seconds.
// 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;
@@ -169,21 +165,20 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
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.
// PAYLOAD v6: 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.
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.
// PAYLOAD v7: velocity->amp transfer curve, appended after the v6 keyTrack. A pre-v7
// payload (no field) leaves the PerformanceZone default (VelocityCurve::flat(),
// Daniel-approved), the deliberate NON-back-compat behavior change for already-saved
// zones. fromPoints repairs the X-order/endpoint invariant defensively; a truncated
// read leaves the flat default and the mid-zone break below drops the rest.
if (curveTail) {
const std::uint32_t ptCount = r.u32();
std::vector<VelocityPoint> 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.
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge
// count can't trigger a giant allocation before the bounded reads fail.
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) {
@@ -211,15 +206,14 @@ std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& 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.
// projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
// v5+. The assert inside readZonesPayload fires if a v3 blob has an invalid rate.
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,
// BACK-COMPAT: a v1 blob is the original 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);
@@ -238,33 +232,30 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
return map;
}
// --- Combined component state (v3, S10) --------------------------------------
// --- Combined component state --------------------------------------
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
std::vector<std::uint8_t> out;
putLE(out, kComponentStateVersion);
// v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body.
// v4 addition: 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).
// v5 addition: last-consumed assignment generation, 8-byte LE two's-complement, follows
// the mode byte so a v4 reader stopping there 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).
// v6 addition: preview-trigger velocity, 1 byte (MIDI 1..127) — a v5 blob is a strict
// prefix 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).
// v7 addition: voice count (1..32), voice mode (0 Poly/1 Mono), mono trigger
// (0 Retrigger/1 Legato) — one byte each, a v6 blob is a strict prefix up to here.
const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount
: state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount
: state.voiceCount;
out.push_back(static_cast<std::uint8_t>(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.
// v8 addition: post-mixer LINEAR gain as a double (bit-cast to u64 LE) — a v7 blob is a
// strict prefix up to here. The WRITER never emits out-of-range: non-finite/negative
// falls back to unity; above the +24 dB cap clamps to the cap.
{
double g = state.masterGainLinear;
const double maxLin = masterGainMaxLinear();
@@ -272,16 +263,14 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
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).
// v9 addition: channel-mode-EXPLICIT flag, 1 byte — a v8 blob is a strict prefix up to
// here. 0 = implicit (shell may auto-default from the loaded capture's channel count);
// 1 = 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).
// v10 addition: the instance-owned sample-refs table — a v9 blob is a strict prefix up
// to here. 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<std::uint32_t>(state.sampleRefs.size()));
for (const SampleRefEntry& e : state.sampleRefs) {
putLE(out, static_cast<std::uint32_t>(e.sampleId.size()));
@@ -312,17 +301,17 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& 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.
// projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
// v5+. 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)
if (!r.ok) return out; // no version tag -> empty (the 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
// * v1 (original 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 (zones-only): restore {"", zones} — that instance had zones but no separate
// single-capture selection.
if (version == kSelectionStateVersion) {
out.selectionId = deserializeSelection(bytes);
@@ -337,20 +326,20 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
}
if (version == kPerformanceStateVersion) {
readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag
return out; // channelMode stays Mono (pre-S7)
return out; // channelMode stays Mono
}
// 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).
// BACK-COMPAT: a v3 blob ({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)
return out; // channelMode stays Mono, marker stays 0
}
// 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.
// BACK-COMPAT: a v4 blob ({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)
@@ -359,12 +348,12 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
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)
return out; // marker stays 0
}
// 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.
// BACK-COMPAT: a v5 blob ({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 (construction default), so an
// already-saved 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)
@@ -375,7 +364,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
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)
return out; // previewVelocity stays at the mid default
}
if (version != kComponentStateVersion &&
version != kSelectionZonesRefsV10Version &&
@@ -386,10 +375,9 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
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.
// v6..v10 shared prefix: channel-mode byte, 8-byte consumed-assignment marker, 1-byte
// preview velocity, precede the v3 body. A non-{0,1} mode byte treats 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;
@@ -402,8 +390,8 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
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.
// v7+: the three voice-system bytes. A v6 blob skips them — the construction defaults
// {16, Poly, Retrigger} hold, reproducing pre-voice-system behavior.
if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) {
const std::uint8_t vc = r.u8();
const std::uint8_t vm = r.u8();
@@ -417,9 +405,9 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
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.
// v8+: the master-gain LINEAR double. A v7 blob skips it — the construction default
// (unity) holds. A non-finite, negative, or above-cap value 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
@@ -429,18 +417,18 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
? 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.
// v9: 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 untouched 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).
// v10: 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) {
@@ -449,11 +437,10 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
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).
// 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. An
// out-of-MIDI-range root falls back to the middle-C default distill() uses; a
// negative channel count falls back to 0 = unknown (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);
@@ -468,8 +455,8 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
}
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.
// v11: 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);
+188 -224
View File
@@ -1,20 +1,15 @@
#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.
// ReaSampler 9000 instrument. Split out of sample_map so both artifacts can share it: the
// instrument's processor reads/writes it at setState/getState, and the extension's
// instrument-drop path serializes the identical bytes into a transient .vstpreset, so the
// payload and the instrument's reader can never drift — without the extension having to
// link the whole voice engine (sampler_core + pitch_shift) just to serialize one preset
// blob. Its own links are velocity_curve + master_gain (wire value validation), never the
// engine.
//
// 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.
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, zones
// payload v1..v7) must be preserved exactly.
#include <cstdint>
#include <string>
@@ -26,108 +21,86 @@ 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).
// The performance map is the instrument's OWN state, serialized to the VST3 component-state
// IBStream — never written to the "reasampler" bank ext-state. Versioned binary, tolerant
// of truncation/wrong-version (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.
// ZONES-PAYLOAD FORMAT VERSIONING is self-describing and envelope-independent: the payload
// carries its OWN version, so the per-zone record can grow without bumping the envelope
// version. Zone-record extensions and envelope-field additions stay on independent axes
// that can never collide on one version number.
// * v1 (original, no marker): 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, 4-byte LE
// rootOverride (iff hasRootOverride). A payload starting with a small u32 (zone count)
// is v1.
// * v2: 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone count can
// equal) + 4-byte LE payload version (== 2), then the v1 body PLUS, per zone record
// after rootOverride: 1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE
// loop.start + loop.end (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint
// (int64). The marker lets the reader detect record shape independent of the envelope.
// * v3 (LEGACY — exists in Daniel's beta projects): marker + version (== 3), v2 body PLUS
// a per-zone play-params tail (always present): 1 byte playMode (0 Gate/1 Trigger);
// 8-byte LE adsr.holdFrames (int64, FRAMES at 44.1k nominal); 8-byte LE
// trigger.lengthFraction (double); 8-byte LE trigger.fadeInFrames + fadeOutFrames
// (int64); 1 byte pitchEngine (0 Varispeed/1 Preserve); 1 byte pitchEnv.enabled; 8-byte
// LE pitchEnv.attackFrames + 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, pitch env disabled) — deliberate for
// already-saved instruments. A truncated mid-v3-tail record keeps the zones that parsed.
// LEGACY-READ CONVERSION: the v3 wall-clock frame counts (hold, pitchEnv A/D) were
// always written as nominal frames at a baked-in rate; convert to seconds by dividing by
// the PROJECT sample rate threaded into the v3 lift path at read time (a parameter, no
// baked constant). Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R
// absent in v3 -> tier-0 seconds defaults (0.003/0/1.0/0.060).
// * v5 (CURRENT WRITE FORMAT): marker + version (== 5), v2 body PLUS, per zone record, the
// full play params with WALL-CLOCK TIMES AS SECONDS (rate-free doubles): 1 byte
// playMode; 8-byte LE adsr.holdSeconds; 8-byte LE trigger.lengthFraction; 8-byte LE
// trigger.fadeInFrames + fadeOutFrames (int64, unchanged — source-timeline facts); 1
// byte pitchEngine; 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackSeconds +
// decaySeconds + peakSemitones; 8-byte LE adsr.attackSeconds + decaySeconds +
// sustainLevel + releaseSeconds. v4 (a branch-only frames-tail) was never shipped and is
// intentionally not read. Keymap builders resolve 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 original single-selection format: version tag 1 + id
// bytes) lifts to a single full-keyboard zone playing that id (no override). 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.
// These two functions serialize the ZONES only; the instrument's full component state is
// {single-capture selection id, zones} — see ComponentState / serializeComponentState below.
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 v1v5 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 v1v6 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
// The zones-payload format version and its detection marker. serializePerformance and
// serializeComponentState both emit the CURRENT payload version (v7: marker + version +
// records with the loop/start tail, the full play-params tail in SECONDS, the v6 keyTrack
// scalar, and the v7 velocity->amp curve) so overrides round-trip through EITHER envelope.
// Readers accept v1 (no marker), v2 (marker + version 2, no play tail), and v3 (legacy play
// tail, 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 no legitimate zone count
// (bounded by 128 MIDI zones, always tiny) can ever collide with.
// * PAYLOAD v6: identical to v5, PLUS one field appended to each zone record after the
// full v5 play-params tail: 8-byte LE keyTrack (double) — the per-zone key-tracking
// scalar (1.0 = 100% ET). A v1-v5 payload (no keyTrack) lifts every zone to keyTrack =
// 1.0, so already-saved instances are BIT-IDENTICAL — the default reproduces the prior
// repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
// * PAYLOAD v7 (CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
// transfer curve appended after the v6 keyTrack field: 4-byte LE control-point count N,
// then per point 8-byte LE velocity + 8-byte LE amp (doubles). 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() (Daniel-approved).
// This is a DELIBERATE NON-back-compat behavior change: an already-saved zone's soft
// hits play LOUDER than under the old 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; // + 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.
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts
// convert to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a
// parameter (frames / projectRate = seconds) — the same rate 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<std::uint8_t> serializePerformance(const PerformanceMap& map);
@@ -139,152 +112,145 @@ std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
double projectRate);
// --- Combined component state (VST3 setState/getState, v3 — S10) -------------
// --- Combined component state (VST3 setState/getState, v3+) -------------
//
// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that
// 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.
// project restores an instance's pick AND its zones — and 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_<guid>" 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):
// Format (envelope v11): 4-byte LE version tag (== 11); 1-byte channel-mode field (0
// mono/1 stereo); 8-byte LE last-consumed-assignment generation; 1-byte preview-trigger
// velocity (MIDI 1..127); three voice-system bytes (1-byte voice count 1..32, 1-byte voice
// mode 0 Poly/1 Mono, 1-byte mono trigger 0 Retrigger/1 Legato); 8-byte LE master-gain
// LINEAR value (double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB);
// 1-byte channel-mode-EXPLICIT flag (0 implicit/auto-default, 1 = user deliberately
// toggled — see ComponentState::channelModeExplicit); the SAMPLE-REFS table (instance-owned
// path + intrinsics + display name per referenced sample; wire shape at
// kSelectionZonesRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes —
// the minted per-instance identity the usage publisher keys its "rsusage_<guid>" ext-state
// record under, see sample_usage.h); 4-byte LE selection-id length + id bytes; then the
// CURRENT zones payload (identical to serializePerformance's body — its own self-describing
// version). The instance guid is the only 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.
// BACK-COMPAT on read (every older blob lifts to channelMode = MONO,
// lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, voice
// defaults {16 voices, Poly, Retrigger}, unity master gain, channelModeExplicit = FALSE — a
// pre-v9 mode byte is treated as the untouched default so the auto-default may follow the
// loaded capture, and 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, 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).
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish).
// * v9 blob -> the v10 fields minus sampleRefs (empty tablebridge-resolve lift).
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: implicit mode.
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: unity master gain.
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: voice defaults.
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: no velocity byte.
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: no marker.
// * v3 blob -> {mono, 0, mid, selectionId, zones}: no channel mode.
// * v2 blob -> {mono, 0, mid, "", zones}: zones but no separate selection.
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: single-selection lift.
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the 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.
// WHY THE MARKER PERSISTS. The last-consumed assignment generation 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, never written
// to the bank — the extension owns the assign_request key; the instrument only tracks what
// it consumed. The preview-trigger velocity default is a mid MIDI velocity: an older blob
// with no velocity byte lifts to this, audible-but-not-hot.
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).
ChannelMode channelMode = ChannelMode::Mono; // decode mode; default mono
// 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::int64_t lastConsumedAssignGeneration = 0; // last assign_request generation consumed
// 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.
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.
// Voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-voice-system 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.
// Post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; up to
// ~15.849 = +24 dB — master_gain owns the dB taper). PER-INSTANCE output trim applied
// by process() AFTER the voice sum — never per voice, never a keymap fact. Default
// unity reproduces pre-master-gain output byte-identically.
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).
// Self-contained playback: 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_<guid>" 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).
// The minted per-instance identity the usage publisher keys its "rsusage_<guid>"
// ext-state record under (see sample_usage.h — the prune-protection seam). Persisted so
// the key is stable across sessions. 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).
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.
// 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).
// 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 + loop.end (int64, written
// regardless of hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName
// length + 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.
// Everything through the 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.
// 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.
// 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}.
// 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.
// 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}.
// 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}.
// Selection + zones, no channel mode. Retained 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).
@@ -300,18 +266,16 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
// --- 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.
// The instrument's OWN state is which bank sample it plays (a performance choice, held by
// the instrument, never written back to the bank) — a single string id. serialize/
// deserialize keep the on-the-wire form explicit and versioned so it can be extended
// 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.
// Format (v1): 4-byte LE version tag (== 1) followed by the id bytes — no length prefix
// needed, the id runs to end of stream. deserializeSelection tolerates a truncated/wrong-
// version/empty blob by returning "" (no 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.
inline constexpr std::uint32_t kSelectionStateVersion = 1;
+4 -6
View File
@@ -1,4 +1,4 @@
// note_entry.cpp — see note_entry.h. PURE text->MIDI-note parse for the S12 numeric entry.
// note_entry.cpp — see note_entry.h.
#include "core/instrument/map/note_entry.h"
@@ -40,8 +40,8 @@ int letterSemitone(char up) {
}
}
// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive). MIDI 0 == C-1, 60 == C4
// (the DAW convention the editor's noteLabel uses). Returns nullopt if it is not a note name.
// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive, DAW convention:
// MIDI 0 == C-1, 60 == C4). Returns nullopt if it is not a note name.
std::optional<int> parseNoteName(const std::string& s) {
if (s.empty()) return std::nullopt;
std::size_t i = 0;
@@ -49,10 +49,8 @@ std::optional<int> parseNoteName(const std::string& s) {
if (base < 0) return std::nullopt; // not a letter -> not a note name
++i;
int semitone = base;
// Optional accidental(s): # / b (or 's'/'f' are NOT accepted — keep it to the two glyphs).
// Optional accidental(s): # / b only (not 's'/'f').
while (i < s.size() && (s[i] == '#' || s[i] == 'b' || s[i] == 'B')) {
// A trailing 'b'/'B' could be a flat OR the start of nothing; here after a letter it is
// an accidental. '#' raises, 'b'/'B' lowers.
if (s[i] == '#') ++semitone;
else --semitone;
++i;
+6 -21
View File
@@ -1,21 +1,9 @@
// note_entry.h — PURE parse + clamp for the S12 direct numeric entry of a zone's
// low/high/root MIDI note. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The
// mirror of the other pure editor helpers: the fiddly text->note parse lives here, unit-
// tested outside the DAW, while the editor shell hosts the text field (a SWELL edit control
// or a LICE text-entry idiom) and feeds the committed string here on Enter.
// note_entry parse + clamp for direct numeric/note-name entry of a zone's low/high/root
// MIDI note (a drag on the keyboard strip can't hit a precise note reliably).
//
// WHY IT EXISTS (S12). Low/high/root are draggable on the keyboard strip, but a drag can't
// hit a precise note reliably. This adds a typed field: the user clicks the field, types a
// value, and presses Enter; the shell hands the raw string here to parse into a clamped MIDI
// note [0,127] and commits via the same off-thread reload as every other edit.
//
// ACCEPTED FORMS (both, so a musician OR a MIDI-number user is served):
// * a plain decimal integer ("60", " 127 ", "+5") — the raw MIDI note number; and
// * a note name ("C4", "f#3", "Bb-1") — parsed to its MIDI number under the DAW's C4==60
// convention (MIDI 0 == C-1, matching REAPER + the editor's noteLabel).
// A value out of [0,127] CLAMPS to the range (a typed 200 becomes 127) rather than
// rejecting — the least-surprising behavior for a nudge field. Unparseable input returns
// nullopt (the shell keeps the old value + may flash the field).
// Accepts a plain decimal integer ("60", "+5") or a note name ("C4", "f#3", "Bb-1", DAW
// convention: MIDI 0 == C-1, 60 == C4). Out-of-range CLAMPS to [0,127] rather than
// rejecting; unparseable input returns nullopt (shell keeps the old value).
#pragma once
@@ -24,10 +12,7 @@
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
// ignored. An in-range parse returns the note; an out-of-range numeric or note value clamps
// into [0,127]; empty or unparseable input returns nullopt (no change). Pure — no host types.
// Leading/trailing whitespace ignored. Empty or unparseable input returns nullopt.
std::optional<int> parseNoteEntry(const std::string& text);
} // namespace reasampler::instrument::map
+27 -46
View File
@@ -1,6 +1,5 @@
// 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_codec / sampler_core.
// sample_map — pure implementation (the resolution half; the ComponentState codec lives
// in component_state_io.cpp). See sample_map.h.
#include "core/instrument/map/sample_map.h"
@@ -12,9 +11,8 @@ namespace reasampler::instrument::map {
namespace {
// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank
// stores loop points as an optional LoopPoints (both-or-neither); the core wants a
// SampleLoop with an explicit hasLoop. Absent -> no loop.
// The bank stores loop points as an optional LoopPoints (both-or-neither); the core wants
// a SampleLoop with an explicit hasLoop. Absent -> no loop.
SampleLoop loopFromSample(const Sample& s) {
SampleLoop out;
if (s.loop) {
@@ -25,41 +23,33 @@ SampleLoop loopFromSample(const Sample& s) {
return out;
}
// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C
// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on
// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4).
// rootNote defaults to middle C (60) when the bank left the intrinsic empty — an
// un-rooted sample plays unity at C4 rather than failing to play.
SelectedSample distill(const Sample& s) {
SelectedSample out;
out.relativePath = s.relativePath;
out.rootNote = s.rootNote ? *s.rootNote : 60;
out.loop = loopFromSample(s);
out.channelCount = s.channelCount; // capture intrinsic; 0 = unknown (older entry)
out.channelCount = s.channelCount; // 0 = unknown (older entry)
return out;
}
// The ONE override-beats-intrinsic fold shared by the bank-side resolvePerformance and the
// refs-side resolvePerformanceFromRefs (pS): a zone's authored fields + the sample's
// intrinsics (already distilled — rootNote carries the middle-C default) -> ResolvedZone.
// Shared so the two resolution paths cannot drift.
// The ONE override-beats-intrinsic fold shared by resolvePerformance and
// resolvePerformanceFromRefs, so the two resolution paths cannot drift.
ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) {
ResolvedZone rz;
rz.relativePath = ref.relativePath;
rz.lowNote = z.lowNote;
rz.highNote = z.highNote;
// Effective root: override beats intrinsic (distill already defaulted an empty
// intrinsic to middle C).
rz.rootNote = z.rootOverride ? *z.rootOverride : ref.rootNote;
// S-VIEW-6/S-VIEW-9: key tracking + the velocity->amp curve are instrument state —
// carried straight through and applied at play time.
// Key tracking + velocity curve are instrument state — carried straight through.
rz.keyTrack = z.keyTrack;
rz.velocityCurve = z.velocityCurve;
// Effective loop / start (S11): the per-zone override wins over the intrinsic; absent
// -> the intrinsic (loop) / frame 0 (start). The bank is never mutated (D-B).
// Per-zone override wins over the intrinsic; absent -> intrinsic (loop) / frame 0
// (start). The bank is never mutated.
rz.loop = z.loopOverride ? *z.loopOverride : ref.loop;
rz.startFrame = z.startPoint ? *z.startPoint : 0;
// S15/S16 per-zone play params (SECONDS) carry through unchanged; buildZonedKeymap
// resolves them to frames.
rz.play = z.play;
rz.play = z.play; // SECONDS; buildZonedKeymap resolves to frames
return rz;
}
@@ -67,22 +57,20 @@ ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) {
std::optional<SelectedSample> selectSample(const std::string& banksJson,
const std::string& sampleId) {
// POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short-
// circuit before parsing — no stored id resolves to nothing to play by design.
// An empty selection is SILENCE, not the first sample — by design.
if (sampleId.empty()) return std::nullopt;
if (banksJson.empty()) return std::nullopt;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return std::nullopt; // malformed -> nothing to play (never throw)
// Search every bank (pool first, then named — banks() is ordinal order) for the
// stored id. A sample lives in exactly one bank, so first hit wins.
// Search every bank (ordinal order) for the stored id; a sample lives in exactly
// one bank, so first hit wins.
for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(sampleId)) {
return distill(*s);
}
}
// A stale stored id (no longer resolves) is SILENCE, not a substituted first sample:
// the editor reflects the missing pick with its empty state rather than masking it.
// A stale stored id is SILENCE too — the editor's empty state, not a substitution.
return std::nullopt;
}
@@ -92,7 +80,7 @@ ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplici
return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono;
}
// --- Instance-owned sample references (pS self-contained playback) -------------
// --- Instance-owned sample references (self-contained playback) -------------
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId) {
if (sampleId.empty()) return nullptr;
@@ -133,7 +121,7 @@ void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
for (SampleRefEntry& e : refs) {
if (e.sampleId == id) {
e.ref = distilled;
e.displayName = found->displayName; // rename sync rides the same refresh
e.displayName = found->displayName; // rename sync
updated = true;
break;
}
@@ -233,22 +221,18 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
out.sampleRate = sampleRate;
if (mode == ChannelMode::Mono) {
// MONO mode: the existing downmix policy (average all source channels), one channel out.
out.monoFrames = downmixToMono(interleaved, sourceChannels);
return out; // framesR stays empty
}
// STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0
// duplicated when the source is mono (dual-mono, centered). extractChannel clamps the
// out-of-range channel request to the last channel, so a mono source yields L == R.
// extractChannel clamps out-of-range, so a mono source yields L == R (dual-mono).
out.monoFrames = extractChannel(interleaved, sourceChannels, 0);
out.framesR = extractChannel(interleaved, sourceChannels, 1);
return out;
}
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
// seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R,
// pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry
// through untouched — they are already source frames / fractions. Non-time fields pass as-is.
// seconds -> frames at the LIVE rate; source-timeline quantities (trigger %-length +
// fades) carry through untouched, already frames/fractions.
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
const auto secToFrames = [sr](double sec) {
@@ -304,8 +288,7 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
if (!book) return out; // malformed -> nothing (never throw)
for (const PerformanceZone& z : map.zones) {
// Look the id up across every bank (pool + named) — a sample lives in exactly
// one bank, so first hit wins.
// A sample lives in exactly one bank, so first hit wins.
const Sample* found = nullptr;
for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(z.sampleId)) {
@@ -314,12 +297,11 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
}
}
if (!found) {
// STALE-ID POLICY: drop the zone cleanly, report the id (editor can prune).
out.droppedSampleIds.push_back(z.sampleId);
out.droppedSampleIds.push_back(z.sampleId); // stale: drop, report
continue;
}
// Distill the bank Sample to the same intrinsics shape the refs table carries, then
// run the SHARED fold — so the bank path and the refs path resolve identically.
// Distill to the same intrinsics shape the refs table carries, then run the SHARED
// fold — so the bank path and refs path resolve identically.
out.zones.push_back(foldZone(z, distill(*found)));
}
return out;
@@ -332,8 +314,7 @@ ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
if (const SelectedSample* r = findRef(refs, z.sampleId)) {
out.zones.push_back(foldZone(z, *r));
} else {
// No ref for this id (never copied, or a pre-v10 blob not yet lifted): drop the
// zone cleanly + report — the same shape as the bank path's stale-id policy.
// No ref for this id: drop + report, same shape as the bank path's stale-id policy.
out.droppedSampleIds.push_back(z.sampleId);
}
}
+163 -246
View File
@@ -1,22 +1,10 @@
#pragma once
// sample_map — PURE mapping logic for the S4 Tier-0 instrument: turn the live
// "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_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
// (the "banks" ext-state blob) and the audio over the file seam (the on-disk WAV).
// Both of those raw inputs cross the bridge/file boundary in the shell; everything
// after — parse the bank with the SHARED bank_model/bank_book JSON path (NOT a second
// parser; the S1 spike's string-scan reader is retired), pick the selected sample,
// 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_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.
// sample_map — turns the live "reasampler" bank ext-state + a decoded WAV into the plain
// data the sampler core plays, and (de)serializes the instance's zone/selection state.
// The bank is read over the live-state seam, audio over the file seam; both raw inputs
// cross the bridge/file boundary in the shell, everything after (bank parse via the shared
// bank_book JSON path, sample pick, mono downmix, keymap build) is pure and unit-tested
// here. Links bank_book, wav_codec, and sampler_core (all pure).
#include <cstdint>
#include <optional>
@@ -29,77 +17,56 @@
namespace reasampler::instrument::map {
// 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;
// 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.
// The bank sample this instance is bound to, distilled from the live "banks" blob: the
// project-relative WAV path the file seam resolves+decodes, plus the bank intrinsics the
// core repitches/loops by. A pure value — no host, no PCM yet.
struct SelectedSample {
std::string relativePath; // project-relative; the shell resolves it (M4 convention)
int rootNote = 60; // S2 intrinsic; defaults to middle C when the bank left it empty
SampleLoop loop; // S2 intrinsic; hasLoop=false when the bank left it empty
int channelCount = 0; // bank intrinsic (capture channel count); 0 = unknown (older
// bank entries) — the GA channel-mode auto-default skips it
std::string relativePath; // project-relative; the shell resolves it
int rootNote = 60; // defaults to middle C when the bank left it empty
SampleLoop loop; // hasLoop=false when the bank left it empty
int channelCount = 0; // capture channel count; 0 = unknown (older bank entries) —
// the GA channel-mode auto-default skips it
};
// Resolve the bound sample from the live bank blob. `banksJson` is the raw "banks"
// ext-state value the bridge read (may be empty / malformed — an unsaved or pre-bank
// project). `sampleId` is this instance's stored selection.
// `banksJson` is the raw "banks" ext-state value the bridge read (may be empty/malformed —
// an unsaved or pre-bank project); `sampleId` is this instance's stored selection.
//
// Precedence, all pure:
// * empty / malformed banksJson -> nullopt (nothing to play)
// * sampleId empty -> nullopt (NO selection -> silence)
// * sampleId names a sample in ANY bank -> that sample (searched pool + named)
// * sampleId set but not found (stale) -> nullopt (the sample was deleted/moved;
// the editor returns to the empty state)
//
// POLICY REVERSAL (S10, 2026-07-26 — supersedes the S4 first-sample fallback). A fresh
// instance with no stored selection resolves to nullopt (SILENCE), NOT the bank's first
// sample: the metric is time-to-first-note via an explicit pick, and a mystery auto-play
// of sample #1 was the anti-pattern. A stale stored id (no longer resolves) ALSO returns
// nullopt rather than silently substituting a different sample — the editor reflects the
// missing selection with its "pick a capture" empty state instead of masking it.
// Precedence: empty/malformed banksJson -> nullopt. Empty sampleId -> nullopt (no selection
// is SILENCE, not the bank's first sample — deliberate: the metric is time-to-first-note via
// an explicit pick, and mystery auto-play of sample #1 was the anti-pattern). sampleId found
// in any bank -> that sample. sampleId set but not found (stale) -> nullopt, same as no
// selection — the editor shows its "pick a capture" empty state rather than masking it.
std::optional<SelectedSample> selectSample(const std::string& banksJson,
const std::string& sampleId);
// GA auto-default rule (pure, tested): given the capture's requested channel count, the
// instance's current mode, and whether the user has explicitly toggled the mode, return
// the mode to apply. Explicit choice is never overridden. An unknown channelCount (0)
// leaves the current mode unchanged. Used by reloadInstrument in the single-capture path.
// * isExplicit == true -> current (user's choice stands)
// * channelCount == 0 -> current (unknown, skip)
// * channelCount >= 2 -> Stereo
// * channelCount == 1 -> Mono
// Auto-default rule: given the capture's channel count, current mode, and whether the user
// explicitly toggled it, return the mode to apply. Explicit choice is never overridden;
// channelCount == 0 (unknown) leaves the current mode; >= 2 -> Stereo; == 1 -> Mono.
ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit);
// --- Instance-owned sample references (pS self-contained playback) -------------
// --- Instance-owned sample references (self-contained playback) -------------
//
// THE ARCHITECTURE CORRECTION: the instrument must never go silent because the extension's
// ext-state has not parsed yet (or the extension is absent). So the instance persists, in
// its OWN component state, a small table of everything it needs to PLAY each referenced
// bank sample: the project-relative WAV path + the decode intrinsics (root note, loop,
// channel count) — exactly a SelectedSample, keyed by the bank sample id. On load the
// shell decodes straight from these refs; the bank blob is a BROWSER SOURCE that also
// refreshes this table opportunistically when readable (recapture/root edits stay live),
// never a runtime lifeline.
// The instrument must never go silent just because the extension's ext-state hasn't parsed
// yet (or the extension is absent). So the instance persists, in its OWN component state, a
// table of everything needed to PLAY each referenced bank sample: path + decode intrinsics
// (root, loop, channel count), keyed by bank sample id. The shell decodes straight from
// these refs; the bank blob is a browser source that refreshes the table opportunistically
// when readable, never a runtime lifeline.
//
// POLICY (follows from ownership): a sample deleted from the BANK no longer silences an
// instance that carries its ref — the instance keeps playing while the FILE exists (normal
// sampler behavior; prune deleting the file yields the defined no-play). This deliberately
// supersedes the S10 stale-id-silence rule, which was an artifact of bank-side resolution.
struct PerformanceMap; // defined below (Tier 1); referencedSampleIds spans both tiers
// Consequence: a sample deleted from the bank no longer silences an instance that carries
// its ref — it keeps playing while the file exists (normal sampler behavior; prune deleting
// the file yields the defined no-play).
struct PerformanceMap; // defined below; referencedSampleIds spans both selection + zones
struct SampleRefEntry {
std::string sampleId; // the bank sample id this ref was copied from (the seam key)
SelectedSample ref; // path + intrinsics, sufficient to decode + play without a bank
// The sample's bank display name at copy time — DISPLAY ONLY (the editor's label falls
// back to it when the bank snapshot is unavailable, mirroring the waveform/loop ref
// fallback); never consulted by resolution. Empty for a table written before the field
// existed in-session (it back-fills on the next bank refresh).
// Bank display name at copy time — DISPLAY ONLY (editor label fallback when the bank
// snapshot is unavailable); never consulted by resolution.
std::string displayName;
};
using SampleRefs = std::vector<SampleRefEntry>;
@@ -112,43 +79,32 @@ const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleI
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
const PerformanceMap& map);
// Upsert a ref for each id in `ids` that resolves in the live bank blob (the same
// distillation selectSample performs), copying the bank display name alongside the decode
// intrinsics. A miss leaves any existing entry untouched — the instance owns its copy; a
// bank deletion never strips a ref. Empty/malformed blob -> no-op.
// Upsert a ref for each id in `ids` that resolves in the live bank blob, copying the display
// name alongside the decode intrinsics. A miss leaves any existing entry untouched — the
// instance owns its copy; a bank deletion never strips a ref. Empty/malformed blob -> no-op.
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
const std::vector<std::string>& ids);
// The pre-v10 LEGACY-LIFT terminating decision (pure, so the no-churn rule is provable
// without a host): can a refs lift MAKE PROGRESS against this bank blob for the ids the
// instance references?
// * Retry — the blob is absent/empty/unparseable: not readable YET, keep retrying (the
// project's ext-state may simply not have parsed).
// * Lift — the blob parses and at least one id resolves: a lift copies a ref in (the
// refs table then goes non-empty and the lift never re-fires).
// * Stale — the blob parses and NO id resolves (an empty `ids` included): the ids are
// PROVABLY stale — the bank is readable and does not know them — so there is nothing
// to lift, ever. The shell latches this and stops retrying (no per-tick churn).
// Legacy-lift terminating decision: can a refs lift make progress against this bank blob
// for the ids the instance references?
// * Retry — blob absent/empty/unparseable: not readable yet, keep retrying.
// * Lift — blob parses and at least one id resolves: copy a ref in (never re-fires once
// the refs table is non-empty).
// * Stale — blob parses and no id resolves: provably stale, nothing to lift, ever — the
// shell latches this and stops retrying (no per-tick churn).
enum class LegacyLiftDecision { Retry, Lift, Stale };
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids);
// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table tracks
// exactly what the instance currently plays, so it cannot grow with browsing history).
// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table cannot
// grow with browsing history).
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids);
// One entry in the capture browser's card list: the stable id + display name plus the S2
// intrinsics + bank the browser draws as a card (peak thumbnail + name + root/key badge,
// filterable by bank). Peaks are NOT here — they are computed shell-side from the decoded
// PCM (the `Sample` metadata carries no envelope; see reasampler_editor's thumbnail cache,
// the mirror of bank_panel::thumbnailFor). This carries only what the bank blob already
// holds: the metadata the card badge + bank filter need. Pure projection over the shared
// parse — the UI never parses JSON itself.
//
// - rootNote: the S2 rootNote intrinsic when the bank set it (nullopt otherwise — the
// badge shows "root: —" / no root, never a guessed value).
// - key: the optional human musical key label ("F#m"), when the bank set it.
// - bankId: the id of the bank this sample lives in (the bank filter matches on it).
// One entry in the capture browser's card list: stable id + display name + intrinsics +
// bank, for a card (peak thumbnail + name + root/key badge, filterable by bank). Peaks are
// NOT here — computed shell-side from the decoded PCM (reasampler_editor's thumbnail
// cache). rootNote is nullopt when the bank left it empty (badge shows no root, never a
// guessed value). Pure projection over the shared parse — the UI never parses JSON itself.
struct SampleChoice {
std::string id;
std::string displayName;
@@ -167,35 +123,27 @@ struct BankChoice {
};
std::vector<BankChoice> listBanks(const std::string& banksJson);
// 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
// their source channel count, so a stereo (or N-channel) capture is folded to a single
// mono stream here by an equal-weight average. Averaging (not "take L", not summing) is
// the least-surprising, no-clip default — a centered mono source stays unity, and a
// hard-panned source is attenuated rather than silenced or doubled. Empty / zero-stride
// in -> empty out. Pure.
// Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to the core's MONO contract
// by AVERAGING channels per frame (`channelCount` is the interleave stride, >= 1) — not
// "take L", not summing: a centered mono source stays unity, a hard-panned source is
// attenuated rather than silenced or doubled. Empty/zero-stride in -> empty out. Pure.
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
int channelCount);
// Deinterleave one channel (`which`, 0-based) out of interleaved frames. `channelCount` is
// the interleave stride (>= 1); `which` is clamped to a valid channel (a request past the
// source's last channel reads the last channel, so a mono source asked for channel 1 yields
// channel 0 again — the dual-mono building block). Empty / zero-stride in -> empty out. Pure.
// Deinterleave one channel (`which`, 0-based). `which` clamps to a valid channel (a request
// past the last channel reads the last channel, so a mono source asked for channel 1 yields
// channel 0 — the dual-mono building block). Empty/zero-stride in -> empty out. Pure.
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which);
// --- Stored (wall-clock SECONDS) per-zone play params -------------------------
//
// DOMAIN SPLIT (S12 remediation — Daniel's ruling: no hardcoded sample rate in the program).
// The instrument stores and edits WALL-CLOCK performance times as SECONDS, rate-free; the
// engine (sampler_core's ZonePlayParams, on SampleData) receives FRAMES resolved from the
// LIVE sample rate at keymap build. AHDSR (A/H/D/S/R) and the AD pitch envelope (attack/decay)
// are wall-clock — the voice advances them once per OUTPUT frame — so they live here in seconds.
// Quantities anchored to the source file's timeline (start point, loop points, Trigger %-length
// and its fades — the fades anchor to the source-frame read offset, PLAN.md §S15) stay in source
// frames / fractions and are carried through unchanged (TriggerParams is reused verbatim).
// Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
// instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
// SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
// keymap build. Quantities anchored to the source file's timeline (start point, loop
// points, Trigger %-length + fades) stay in source frames/fractions, carried through
// unchanged (TriggerParams reused verbatim).
//
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
struct AdsrSeconds {
@@ -214,61 +162,51 @@ struct PitchEnvSeconds {
double peakSemitones = 0.0; // signed depth at the peak
};
// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities in
// frames/fractions (TriggerParams). This is the instrument-owned (D-B), serialized, editor-facing
// representation — distinct from sampler_core's engine-facing ZonePlayParams (frames). The keymap
// builders resolve this to a frame-domain ZonePlayParams against the live sample rate.
// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities
// in frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing
// distinct from sampler_core's engine-facing ZonePlayParams (frames).
struct ZonePlaySeconds {
PlayMode playMode = PlayMode::Gate;
AdsrSeconds adsr; // Gate: AHDSR (seconds)
TriggerParams trigger; // Trigger: %-length + fades (source frames)
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve (S16-F1)
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
};
// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live
// sample rate (frames = round(seconds * rate)). Source-timeline fields (trigger, engine, mode,
// peak, enabled) carry through unchanged. `sampleRate` must be > 0 (the caller guards this).
// sample rate (frames = round(seconds * rate)). Source-timeline fields carry through
// unchanged. `sampleRate` must be > 0 (the caller guards this).
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate);
// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole
// keyboard, repitched from `rootNote`, looped per `loop`. The single-sample degenerate case
// (Keymap::singleSampleChromatic) with the S2 intrinsics threaded in. `frames` is channel 0
// (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono sample (the default),
// which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length
// mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad
// pair never half-plays. `sampleRate` is the WAV's rate.
// `play` carries the S15/S16 per-zone play params (SECONDS) for the single-capture path; it
// defaults to the PRODUCT defaults (Gate + tier-0 AHDSR seconds + Preserve engine, S16-F1) so a
// picked single capture plays under the same default engine as a zone would. This function
// resolves the wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
// keyboard, repitched from `rootNote`, looped per `loop` (Keymap::singleSampleChromatic).
// `frames` is channel 0 (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono
// sample. A `framesR` whose length mismatches `frames` is dropped (falls back to mono), so a
// bad pair never half-plays. `sampleRate` is the WAV's rate. `play` carries the per-zone play
// params (SECONDS); defaults to the product defaults (Gate + tier-0 AHDSR + Preserve) so a
// picked single capture plays under the same default engine as a zone would. Resolves the
// wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR = {},
const ZonePlaySeconds& play = ZonePlaySeconds{});
// --- Performance map (Tier 1, D-B: the instrument's OWN state) ---------------
// --- Performance map (the instrument's OWN state) ---------------
//
// The performance map is the keymap the user authors IN the instrument: several bank
// samples zoned across the keyboard, each with a key range and a root note. It is a
// PERFORMANCE CHOICE (D-B), so it lives in the instrument (VST3 component state), never
// written back to the bank. Root note per zone is SEEDED from the S2 bank intrinsic but
// OVERRIDABLE here — the override lives on the zone, never on `Sample`.
//
// Pure value type: it names bank samples by id (the stable seam key) and holds no PCM.
// The shell resolves each id's WAV over the file seam and decodes it; the pure zone-build
// stitches the decoded frames + this map into a sampler_core Keymap.
// samples zoned across the keyboard, each with a key range and a root note. A performance
// choice, so it lives in the instrument (VST3 component state), never written back to the
// bank. Pure value type: names bank samples by id (the stable seam key), holds no PCM — the
// shell resolves+decodes each id's WAV, and the pure zone-build stitches the decoded frames
// + this map into a sampler_core Keymap.
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range,
// with an optional root-note override. rootOverride absent -> repitch from the bank
// sample's own S2 rootNote intrinsic (or middle C when the bank left it empty).
//
// S11 loop/start overrides (instrument-owned, D-B — mirror of rootOverride): the sustain
// loop and the initial read position are FACTS about the file (S2 bank intrinsics), but the
// instrument may override them per zone WITHOUT writing back to the bank. loopOverride wins
// over the bank's S2 loop intrinsic when set; startPoint sets the voice's initial read frame
// (absent -> frame 0). Both are seeded from the bank intrinsic in the editor and stored here;
// resolvePerformance folds override-beats-intrinsic into the effective ResolvedZone.
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range.
// rootOverride absent -> repitch from the bank sample's own rootNote intrinsic (or middle C
// when empty). loopOverride/startPoint mirror rootOverride: the sustain loop and initial
// read position are facts about the file, but the instrument may override them per zone
// without writing back to the bank (loopOverride wins when set; startPoint sets the voice's
// initial read frame, absent -> 0). resolvePerformance folds override-beats-intrinsic into
// the effective ResolvedZone.
struct PerformanceZone {
std::string sampleId; // bank sample id this zone plays
int lowNote = 0; // inclusive
@@ -277,146 +215,125 @@ struct PerformanceZone {
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
// S-VIEW-6 key-tracking scalar (instrument-owned, D-B — mirror of rootOverride): how far
// playback pitch tracks the keyboard around the root. 1.0 (100%) is standard 12-tone-ET (the
// DEFAULT; a pre-S-VIEW-6 blob with no keyTrack tail lifts to exactly 1.0, so already-saved
// instances are bit-identical); 0.0 = no tracking (every key plays root pitch); 2.0 = double.
// NOT flag-gated — always present in the CURRENT payload (v6). Carried through to KeyZone by
// resolvePerformance and applied in keyTrackedRatio inside BOTH repitch engines.
// Key-tracking scalar: how far playback pitch tracks the keyboard around the root. 1.0
// (100%, standard 12-tone-ET) is the default — a blob predating this field lifts to
// exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every
// key plays root pitch); 2.0 = double. Applied in keyTrackedRatio inside both repitch
// engines.
double keyTrack = 1.0;
// S-VIEW-9 velocity->amp transfer curve (instrument-owned, D-B — mirror of keyTrack): maps the
// note-on MIDI velocity (0..127) to the voice's amp gain, replacing the fixed linear velocity/127.
// A per-sound performance characteristic, so it varies PER ZONE. DEFAULT = flat y=1 (R10-F1
// Option A, Daniel-approved): every velocity plays at unity. This is a DELIBERATE, non-back-compat
// behavior change — a pre-S-VIEW-9 blob (no velocityCurve field) lifts to flat y=1, so an
// 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).
// Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain,
// replacing the old fixed linear velocity/127. Per-zone. Default = flat y=1 (Daniel-
// approved): every velocity plays at unity. DELIBERATE non-back-compat behavior change —
// a blob predating this field lifts to flat y=1, so an already-saved zone's soft hits
// play LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd
// in Voice::start.
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
// loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build
// resolves them to frames at the live sample rate. Defaults to the PRODUCT defaults for a NEW
// zone: Gate play mode, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades,
// PRESERVE pitch engine (S16-F1), pitch env off. An older zone-payload blob (no S15/S16 tail)
// lifts to exactly these defaults on read (see the PAYLOAD versioning).
// Per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine +
// AD pitch envelope). Instrument-owned, never a bank fact. Wall-clock times stored in
// SECONDS (rate-free); keymap build resolves to frames at the live sample rate. Defaults
// for a NEW zone: Gate, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no
// fades, Preserve pitch engine, pitch env off. An older zone blob lacking this tail lifts
// to exactly these defaults on read.
ZonePlaySeconds play;
};
// The instrument's performance map: an ordered list of zones. Order is authoritative for
// overlap resolution (OVERLAP POLICY: first zone in order wins, mirroring the S3 core's
// first-match Keymap::resolve overlaps are neither rejected nor clamped, the earlier
// zone simply takes the contested keys; documented, deterministic).
// overlap resolution first zone in order wins (mirrors the core's first-match
// Keymap::resolve); overlaps are neither rejected nor clamped, deterministic by construction.
struct PerformanceMap {
std::vector<PerformanceZone> zones;
bool empty() const { return zones.empty(); }
};
// Single-capture ("Sample face") zone-lifecycle reconcile — the zone-bleed fix (issue 3a).
// Single-capture ("Sample face") zone-lifecycle reconcile — the zone-bleed fix.
//
// The Sample face materializes ONE full-range [0,127] zone for the loaded sample on first
// control edit (ensureSampleZone). Loading a different sample used to change only the
// selection id, leaving the previous sample's full-range zone in the map — and since zone
// resolution is FIRST-MATCH in order, that stale zone shadowed every later one forever: the
// engine kept playing the old sample while the editor drew the new one's zone (matched by
// sampleId, order-blind). This function is called at every selection-change site so the zone
// the editor draws is the zone the engine plays.
// control edit. Loading a different sample used to change only the selection id, leaving
// the previous sample's full-range zone in the map — and since zone resolution is
// first-match in order, that stale zone shadowed every later one forever: the engine kept
// playing the old sample while the editor drew the new one's zone. This function is called
// at every selection-change site so the zone the editor draws is the zone the engine plays.
//
// Rules (pure, order-preserving where it matters):
// * empty `selectedId` or empty map -> untouched, false.
// * ANY zone with an authored key range (not the full [0,127]) -> the map is Zone-view
// authorship; first-match order is load-bearing there — untouched, false. The Sample
// face never creates a narrow zone, so a narrow zone proves deliberate multi-zone intent.
// * else (every zone full-range — the map is purely Sample-face-shaped): keep only the
// first zone bound to `selectedId` (the selection's own params are not reset); drop
// the rest. A selection with no zone yet empties the map (the shell then plays the
// selection via the Tier-0 fast path with product defaults).
// Rules (order-preserving where it matters):
// * empty `selectedId` or empty map -> untouched, false.
// * ANY zone with an authored key range (not full [0,127]) -> Zone-view authorship,
// first-match order is load-bearing there — untouched, false (the Sample face never
// creates a narrow zone, so a narrow zone proves deliberate multi-zone intent).
// * else (every zone full-range) -> keep only the first zone bound to `selectedId`
// (params preserved); drop the rest. A selection with no zone yet empties the map.
// Returns true iff the map changed (the caller republishes + reloads on true).
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId);
// One resolved zone ready for the shell to decode + the pure build to stitch: the bank
// sample's project-relative WAV path (file seam), the EFFECTIVE root note (override beats
// bank intrinsic beats middle-C default), the loop intrinsic, and the key range. Distinct
// from PerformanceZone (which names an id) — this is the id resolved against the live bank.
// One resolved zone ready for the shell to decode + the pure build to stitch: project-
// relative WAV path (file seam), effective root note (override beats bank intrinsic beats
// middle-C default), loop intrinsic, key range. Distinct from PerformanceZone (which names
// an id) — this is the id resolved against the live bank.
struct ResolvedZone {
std::string relativePath; // project-relative; the shell resolves + decodes it
int lowNote = 0;
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)
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)
double keyTrack = 1.0; // carried from PerformanceZone (1.0 = 100% ET)
VelocityCurve velocityCurve = VelocityCurve::flat(); // carried from PerformanceZone
SampleLoop loop; // effective: loopOverride, else bank intrinsic
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0
ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
};
// The result of resolving a performance map against the live bank blob. `zones` are the
// zones whose sampleId still resolves to a bank sample, IN MAP ORDER (so overlap-order is
// preserved). `droppedSampleIds` are the ids that no longer resolve (STALE-ID POLICY: a
// zone naming a deleted/moved-out sample is DROPPED cleanly — not an error, not silence
// for the whole map — and its id is reported here so the editor can flag/prune it).
// `zones` are the zones whose sampleId still resolves, IN MAP ORDER (overlap-order
// preserved). `droppedSampleIds`: a zone naming a deleted/moved-out sample is dropped
// cleanly — not an error, not silence for the whole map — and reported here so the editor
// can flag/prune it.
struct ResolvedPerformance {
std::vector<ResolvedZone> zones;
std::vector<std::string> droppedSampleIds;
};
// Resolve a performance map against the live "banks" ext-state blob. Pure: shared
// bank_book parse, no host, no PCM. Each zone's sampleId is looked up across every bank
// (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride,
// else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends
// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result.
// Resolve a performance map against the live "banks" ext-state blob. Each zone's sampleId
// is looked up across every bank; a hit yields a ResolvedZone with the effective root note
// and loop intrinsic; a miss appends to droppedSampleIds. Empty/malformed blob or empty map
// -> empty result.
//
// NOT the live load path since pS: reloadInstrument resolves via resolvePerformanceFromRefs
// (the instance-owned refs). This bank-side resolver is retained as the TESTED REFERENCE
// the refs path is verified against (testResolveFromRefsMatchesBankResolve) — both share
// foldZone, so the drift test is what keeps the shared fold honest.
// NOT the live load path reloadInstrument resolves via resolvePerformanceFromRefs (the
// instance-owned refs). Retained as the TESTED REFERENCE the refs path is verified against
// (both share foldZone, so the drift test keeps the shared fold honest).
ResolvedPerformance resolvePerformance(const std::string& banksJson,
const PerformanceMap& map);
// Resolve a performance map against the INSTANCE-OWNED refs table (pS self-contained
// playback) — the bank-free mirror of resolvePerformance, sharing the same override-
// beats-intrinsic fold, so the two paths cannot drift. A zone whose sampleId has no ref
// is dropped + reported (same stale-id shape as the bank path). Pure.
// The bank-free mirror of resolvePerformance, against the INSTANCE-OWNED refs table
// shares the same override-beats-intrinsic fold, so the two paths cannot drift. A zone
// whose sampleId has no ref is dropped + reported (same stale-id shape as the bank path).
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
const PerformanceMap& map);
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` is the
// downmixed frames + sample rate for `zones[i]` (same length + order as `zones`). One
// SampleData per zone (Tier 1: one sample per key-region; a sample used by two zones is
// decoded twice — acceptable at this tier, the shell may dedup by path later). Zone order
// is preserved so first-match overlap resolution matches the map's authored order. A zone
// whose decoded frames are empty is SKIPPED (an unreadable WAV drops the zone, not the
// map). Empty zones in -> empty Keymap (silence).
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` matches
// `zones[i]` in length + order. One SampleData per zone (a sample used by two zones is
// decoded twice — acceptable here, the shell may dedup by path later). Zone order preserved
// so first-match overlap resolution matches authored order. A zone whose decoded frames are
// empty is SKIPPED (an unreadable WAV drops the zone, not the map).
struct DecodedZonePcm {
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
int sampleRate = 0; // 0 is explicitly invalid; every consumer must
// receive the WAV's real rate before use.
int sampleRate = 0; // 0 is explicitly invalid
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
};
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
const std::vector<DecodedZonePcm>& decoded);
// Apply the S7 cross-mode channel policy (D-E) to freshly-decoded interleaved PCM, yielding
// the 1- or 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's
// float frames (stride = `sourceChannels`); `mode` is the instance's channel mode.
// * MONO mode -> downmix to one channel (the existing policy: average all source
// channels). framesR EMPTY. A mono or stereo source both collapse.
// * STEREO mode, mono src -> DUAL-MONO: channel 0 duplicated into channel 1 (centered).
// * STEREO mode, stereo src -> channels 0 and 1 taken as-is (L/R). A source with >2 channels
// takes channels 0 and 1 (documented; the sampler's stereo image is
// the first two channels — no surround fold).
// Empty / zero-channel input -> a DecodedZonePcm with empty frames (the caller drops the zone
// or plays silence). Pure — the shell does the file I/O and hands the interleaved buffer here.
// Apply the cross-mode channel policy to freshly-decoded interleaved PCM, yielding the 1- or
// 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's float
// frames (stride = `sourceChannels`); `mode` is the instance's channel mode.
// * MONO mode -> downmix to one channel (average all source channels).
// * STEREO mode, mono src -> dual-mono: channel 0 duplicated into channel 1 (centered).
// * STEREO mode, stereo+ src -> channels 0 and 1 as-is (no surround fold on >2 channels).
// Empty/zero-channel input -> empty frames (caller drops the zone or plays silence).
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate);
// 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.
// The ComponentState envelope + zones-payload binary codec lives in component_state_io.h:
// it grows on every envelope bump and is consumed by the extension's preset-blob path too,
// so both artifacts share the codec while only the VST links the voice engine.
} // namespace reasampler::instrument::map
+1 -1
View File
@@ -1,4 +1,4 @@
// trigger_seam.cpp — PURE Trigger-mode frames↔fraction converter (see trigger_seam.h).
// trigger_seam.cpp — see trigger_seam.h.
#include "core/instrument/map/trigger_seam.h"
+12 -34
View File
@@ -1,25 +1,10 @@
// trigger_seam.hPURE Trigger-mode frames↔fraction converter for the S-VIEW-3 envelope seam.
// NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
// trigger_seam — converts Trigger fade lengths between the engine domain (TriggerParams:
// SOURCE FRAMES, anchored to the source-timeline read pointer) and the overlay domain
// (AmpEnvelope: FRACTIONS in [0,1] of the played span, so the drawn shape stays invariant
// across sample-rate changes). Owns the one shared pack/unpack formula so both directions
// stay consistent; reasampler_editor calls these from packEnvelope/unpackEnvelope.
//
// The TRIGGER SEAM (documented in envelope_overlay.h) converts between the two representations
// of Trigger fade lengths:
//
// ENGINE domain (TriggerParams / sampler_core): SOURCE FRAMES — int64_t absolute frame counts
// that anchor directly to the voice's source-timeline read pointer.
//
// OVERLAY domain (AmpEnvelope / envelope_overlay): FRACTIONS — doubles in [0,1] of the played
// span, where the played span is:
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
// The overlay stores fractions so the drawn shape stays invariant across sample-rate changes;
// the engine stores frames so the voice advances correctly at the live rate.
//
// This module owns the one shared formula so the pack (frames->fractions) and unpack
// (fractions->frames) paths are provably consistent and unit-tested independently of the shell.
// The shell (reasampler_editor.cpp) calls these two functions from packEnvelope / unpackEnvelope.
//
// S-VIEW-F2 safety: the fractions produced here are in [0,1] by construction; a caller that
// clamps the fractions to [0,1] before writing the AmpEnvelope preserves the slider-range
// invariant (a drag can never produce a value a slider couldn't reach).
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
#pragma once
@@ -27,25 +12,18 @@
namespace reasampler::instrument::map {
// The source-frame length of the Trigger played span:
// postStart = max(0, frameCount - startFrame)
// playLength = round(lengthFraction * postStart)
// `frameCount` is the total decoded sample length in source frames.
// `startFrame` is the effective start point (zone.startPoint, or 0 when absent).
// `lengthFraction` is TriggerParams::lengthFraction — (0,1], the fraction of the post-start span.
// Returns 0 when postStart == 0 or lengthFraction <= 0.
// postStart = max(0, frameCount - startFrame); playLength = round(lengthFraction * postStart).
// `startFrame` is the effective start point (0 when absent). Returns 0 when postStart == 0
// or lengthFraction <= 0.
std::int64_t triggerPlayLength(double lengthFraction,
std::int64_t frameCount,
std::int64_t startFrame);
// Convert a source-frame fade count to a fraction of the play span (PACK direction, draw path).
// Returns 0.0 when playLength == 0 (degenerate sample or zero %-length); the fraction is
// NOT clamped — the caller clamps to [0,1] when filling AmpEnvelope so the overlay clamp logic
// stays in envelope_edit, not here.
// PACK direction (draw path): frames -> fraction of play span. Not clamped here — the
// caller clamps to [0,1] when filling AmpEnvelope (envelope_edit owns that logic).
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength);
// Convert a fade fraction to a source-frame count (UNPACK direction, commit path).
// Rounds to nearest integer frame. Returns 0 when playLength == 0.
// UNPACK direction (commit path): fraction -> nearest source frame.
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength);
} // namespace reasampler::instrument::map
+7 -13
View File
@@ -1,9 +1,8 @@
// browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10
// capture_browser. No host types; only the shared Rect + BrowserLayout.
// browser_scroll.cpp — see browser_scroll.h. Pure scroll + search geometry; no host types.
#include "core/instrument/ui/browser_scroll.h"
#include "core/instrument/ui/editor_geometry.h" // kPad / kTitleHeight / kNavButtonWidth (Q-W2v hoist)
#include "core/instrument/ui/editor_geometry.h" // kPad / kTitleHeight / kNavButtonWidth
#include <algorithm>
#include <cctype>
@@ -50,11 +49,10 @@ VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int of
return vr;
}
if (offset < 0) offset = 0;
// First visible ROW: the topmost row whose bottom edge is below the offset. Floor so a row
// partially scrolled off the top still draws (its lower part is visible).
// First row: floored so a row partially scrolled off the top still draws. Last row:
// the row containing pixel (offset + gridH - 1), +1 for the exclusive end, so a row
// straddling the bottom edge still draws.
const int firstRow = offset / kBrowserCardHeight;
// Last visible ROW: the row containing the pixel (offset + gridH - 1), inclusive; +1 for
// the exclusive end. A row straddling the bottom edge still draws.
const int lastRow = (offset + gridH - 1) / kBrowserCardHeight + 1;
int first = firstRow * columns;
int last = lastRow * columns;
@@ -84,13 +82,11 @@ Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) {
const int trackLeft = trackRight - kScrollbarWidth;
const int trackTop = layout.grid.y;
// Thumb height proportional to the visible fraction, floored at a grabbable minimum but
// never taller than the track.
// Thumb height proportional to the visible fraction, floored/capped to the track.
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
thumbH = (std::max)(kMinThumbHeight, thumbH);
thumbH = (std::min)(thumbH, gridH);
// Thumb top proportional to the offset over the movable track span.
const int trackSpan = gridH - thumbH; // >= 0
int thumbTop = trackTop;
if (maxOff > 0 && trackSpan > 0) {
@@ -106,7 +102,7 @@ int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffse
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.
// Same thumb-height formula as scrollThumbRect.
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
thumbH = (std::max)(kMinThumbHeight, thumbH);
thumbH = (std::min)(thumbH, gridH);
@@ -157,8 +153,6 @@ std::vector<int> filterNameIndices(const std::vector<std::string>& 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;
+47 -76
View File
@@ -1,23 +1,16 @@
// browser_scroll.h — PURE scroll + type-to-filter geometry LAYERED over the S10
// capture_browser. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of
// capture_browser / editor_geometry: the fiddly scroll-window + scrollbar-thumb + search-box
// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws the
// clipped card window + the scrollbar + the search field and routes wheel/drag/keystrokes
// into these functions.
// browser_scroll.h — scroll + type-to-filter geometry layered over capture_browser. Mirror
// of capture_browser/editor_geometry; the shell draws the clipped card window, scrollbar,
// and search field, and routes wheel/drag/keystrokes into these functions.
//
// WHY IT EXISTS (S12). capture_browser (S10) lays out EVERY card top-down and the shell
// clips at the browser bottom — a bank longer than the panel runs off with no way to reach
// it (the S12 gap). This module adds the two things S12 layers over that stable geometry:
// * SCROLL — a vertical pixel offset into the card grid, with the max-offset clamp, the
// visible-row window, a scrollbar thumb rect, and the thumb-drag<->offset mapping so a
// wheel tick or a thumb drag reaches every card; and
// * SEARCH — a name-substring filter (case-insensitive) that narrows the drawn cards,
// COMPOSING with capture_browser's bank filter (the shell applies the bank filter first,
// then this search narrows within it) + the search-box rect the shell draws the field in.
// capture_browser lays out every card top-down and the shell clips at the browser bottom —
// a bank longer than the panel has no way to reach the rest. This module adds scroll (a
// vertical pixel offset with max-offset clamp, visible-row window, scrollbar thumb, and
// thumb-drag<->offset mapping) and search (a case-insensitive name-substring filter that
// composes with capture_browser's bank filter — the shell applies the bank filter first,
// then this search narrows within it).
//
// It holds NO card data and draws nothing — it knows only the browser layout (from
// capture_browser), COUNTS, and the scroll OFFSET the shell owns as transient UI state. It
// reuses capture_browser's BrowserLayout + the shared Rect (one geometry idiom).
// Holds no card data and draws nothing — knows only the browser layout, counts, and the
// scroll offset the shell owns as transient UI state.
#pragma once
@@ -28,96 +21,74 @@
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
// the shell and tests agree. When the content fits (no scroll needed) the scrollbar is
// suppressed (scrollThumbRect returns empty) and the shell may reclaim the gutter.
// Width of the vertical scrollbar gutter at the grid's right edge. When content fits (no
// scroll needed), scrollThumbRect returns empty and the shell may reclaim the gutter.
inline constexpr int kScrollbarWidth = 10;
// The height (px) of the type-to-filter search box the shell draws ABOVE the tab strip (a
// thin band spanning the browser width). Exposed so the shell reserves the band and tests
// agree. capture_browser's tab strip + grid sit BELOW this band (the shell offsets the
// BrowserLayout it feeds to capture_browser by kSearchBoxHeight).
// Height of the type-to-filter search box the shell draws above the tab strip.
// capture_browser's tab strip + grid sit below this band.
inline constexpr int kSearchBoxHeight = 22;
// The total pixel HEIGHT the card grid needs to draw all `cardCount` cards at `layout`'s
// column count: the number of ROWS (ceil(cardCount / columns)) times the fixed cell height.
// Zero cards -> 0. Pure — the content extent the scroll offset ranges over.
// Total pixel height the card grid needs for `cardCount` cards at `layout`'s column
// count: rows (ceil(cardCount / columns)) times the fixed cell height.
int scrollContentHeight(const BrowserLayout& layout, int cardCount);
// The maximum scroll offset (px): content height minus the visible grid height, floored at 0.
// When the content fits within the grid this is 0 (nothing to scroll). Pure — the clamp
// ceiling for every offset the shell tracks.
// Maximum scroll offset: content height minus visible grid height, floored at 0.
int scrollMaxOffset(const BrowserLayout& layout, int cardCount);
// Clamp a proposed scroll offset into [0, scrollMaxOffset]. The shell clamps after every wheel
// tick / thumb drag so an over-scroll pins to an edge rather than showing past the last card
// or above the first. Pure.
// Clamps a proposed scroll offset into [0, scrollMaxOffset].
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset);
// The half-open range of card INDICES [first, last) at least partially visible in the grid at
// scroll `offset`. The shell draws only these cards (the S12 clip window) rather than every
// card. `offset` is assumed pre-clamped (the shell clamps on input); a first past the last row
// yields an empty range (first==last==cardCount). Pure.
// Half-open range of card indices [first, last) at least partially visible at scroll
// `offset` (assumed pre-clamped). The shell draws only these cards.
struct VisibleRange {
int first = 0; // first card index drawn (inclusive)
int last = 0; // one past the last card index drawn (exclusive)
int first = 0;
int last = 0;
};
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset);
// The cell rect of card `index` SHIFTED UP by the scroll offset, ready to draw (the shell
// still adds the browser sub-area origin). Equivalent to capture_browser::cardCellRect with
// the offset subtracted from top/bottom. Pure — the one place the offset applies to a card.
// Cell rect of card `index` shifted up by the scroll offset (the shell still adds the
// browser sub-area origin).
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset);
// The vertical scrollbar THUMB rect within the grid's right-edge gutter, sized proportional to
// the visible fraction (grid height / content height) and positioned proportional to the
// scroll offset. Returns an EMPTY rect when the content fits (no scroll needed) — the shell
// suppresses the scrollbar then. A minimum thumb height keeps a tiny thumb grabbable on a very
// long bank. Pure — the geometry the shell draws + hit-tests the thumb grab against.
// Vertical scrollbar thumb rect within the grid's right-edge gutter, sized proportional
// to the visible fraction and positioned proportional to the scroll offset. Empty when
// the content fits. A minimum thumb height keeps a tiny thumb grabbable on a long bank.
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset);
// Map a thumb-drag to a scroll offset. Given the offset the thumb held at grab time
// (`startOffset`) and the vertical pixel delta since grab (`dyPixels`), returns the new
// (clamped) scroll offset: startOffset shifted by the delta scaled from thumb-track pixels to
// content pixels (a 1px thumb move covers content/track px of content). A degenerate track /
// fitting content pins to startOffset. Pure — the inverse of scrollThumbRect's position map.
// Maps a thumb-drag to a new (clamped) scroll offset: `startOffset` shifted by the pixel
// delta scaled from thumb-track pixels to content pixels. A degenerate track or fitting
// content pins to startOffset.
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels);
// The search-box rect: a full-width band of height kSearchBoxHeight at the TOP of the browser
// area (above where capture_browser's tab strip draws). `w` is the browser sub-area width;
// the shell adds its origin. A zero/negative width yields an empty rect. Pure.
// Search-box rect: full-width band of height kSearchBoxHeight at the top of the browser
// area. `w` is the browser sub-area width; the shell adds its origin.
Rect searchBoxRect(int w);
// True iff `name` contains `query` as a case-insensitive ASCII substring. An EMPTY query
// matches everything (the no-filter identity). Matching is ASCII case-folded (the display
// names are ASCII until the Phase L type kit lands, mirroring the editor's other ASCII-only
// text). Pure — the single match predicate the shell's search narrow is built from.
// True iff `name` contains `query` as a case-insensitive ASCII substring. An empty query
// matches everything.
bool nameMatchesQuery(const std::string& name, const std::string& query);
// Narrow a list of display `names` to the INDICES whose name matches `query`, preserving
// order. An EMPTY query returns every index [0, names.size()) (the composition base so "bank
// filter, no search" == today's browser). Kept name-only (indices, not card structs) so this
// module stays free of the sample_map/bank_book chain — the shell owns the SampleChoice list
// and applies the bank filter FIRST, then feeds the surviving display names here (search
// narrows within the bank). Pure.
// Narrows a list of display `names` to the indices whose name matches `query`, preserving
// order. An empty query returns every index. Kept name-only (indices, not card structs)
// so this module stays free of the sample_map/bank_book chain — the shell applies the
// bank filter first, then feeds the surviving display names here.
std::vector<int> filterNameIndices(const std::vector<std::string>& names,
const std::string& query);
// --- The Browse-modal (S-VIEW-5) top-level regions (Q-W2v hoist, T2-06) -------
// --- Browse-modal top-level regions --------------------------------------------
//
// 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.
// covers the full window. Homed here (not editor_geometry) because the search-box
// height feeds it.
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)
Rect back;
Rect search;
Rect content; // browser sub-area (tabs + grid) — layoutBrowser's origin
Rect cancel;
Rect confirm;
};
BrowseModal computeBrowseModal(int w, int h);
+3 -3
View File
@@ -8,9 +8,9 @@ namespace reasampler::instrument::ui {
namespace {
// The left edge of tab i in a strip of the given x-origin and width divided into `count`
// equal segments (mirror of mode_switch::segmentEdge). Every boundary derives from the same
// formula, so consecutive tabs share an exact edge and the last tab reaches x+width exactly.
// Left edge of tab i in a strip divided into `count` equal segments (mirror of
// mode_switch::segmentEdge). Same formula for every boundary so consecutive tabs share
// an exact edge.
int tabEdge(int x, int width, int i, int count) {
return x + (i * width) / count;
}
+36 -61
View File
@@ -1,92 +1,67 @@
// capture_browser.h — PURE layout + hit-test for the S10 capture-first editor's default
// face: a scannable grid of capture CARDS with a bank-FILTER tab strip above it. NO VST3,
// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry /
// embed_strip / mode_switch: the fiddly card-grid + tab arithmetic lives here so it is
// unit-tested outside the DAW, while the editor shell draws each card's peak thumbnail +
// name + root/key badge and routes clicks into these functions.
// capture_browser.h — layout + hit-test for the capture-first editor's default face: a
// scannable grid of capture cards with a bank-filter tab strip above it. Mirror of
// editor_geometry/embed_strip/mode_switch; the shell draws thumbnails/names/badges and
// routes clicks into these functions.
//
// The browser replaces the old text item-list (the named anti-pattern). It lays out N
// cards in a fixed-cell grid that wraps across the browser width, and a horizontal tab
// strip of bank filters (one tab per bank_book bank + an "All" tab) above the grid. This
// module knows only COUNTS and RECTS — it draws nothing and holds no sample data; the
// shell owns the SampleChoice list, the peak envelopes, and the filter state, and asks this
// module only "where does card i draw" / "what did the user click".
// This module knows only counts and rects — it draws nothing and holds no sample data;
// the shell owns the SampleChoice list, peak envelopes, and filter state.
//
// Scroll is NOT here (S12 layers it over this module). The browser lays out every card
// top-down; the shell clips at the browser's bottom until S12 adds a scroll offset. Keeping
// scroll out keeps this module the stable card/tab geometry S12 builds on.
//
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom).
// Scroll is layered on top by browser_scroll — this module lays out every card top-down
// and the shell clips at the bottom until a scroll offset is applied.
#pragma once
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
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.
inline constexpr int kBrowserTabHeight = 26; // the bank-filter tab strip band height
inline constexpr int kBrowserCardWidth = 132; // one card cell width (incl. gutter)
inline constexpr int kBrowserCardHeight = 84; // one card cell height (incl. gutter)
inline constexpr int kBrowserCardGutter = 8; // inset between the cell edge and the card
inline constexpr int kBrowserThumbHeight = 44; // the peak-thumbnail band inside a card
// Fixed browser metrics, exposed so the shell and tests agree.
inline constexpr int kBrowserTabHeight = 26;
inline constexpr int kBrowserCardWidth = 132;
inline constexpr int kBrowserCardHeight = 84;
inline constexpr int kBrowserCardGutter = 8;
inline constexpr int kBrowserThumbHeight = 44;
// The browser's regions, derived from the (w x h) area the shell allots it. Both clamp to
// the area so a degenerate (tiny/zero) size never yields an inverted rect.
// Clamped so a degenerate (tiny/zero) size never yields an inverted rect.
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
Rect tabStrip;
Rect grid;
int columns = 1; // cards per row in `grid` (>= 1)
};
// 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
// browser narrower than one card still lays out a single column. A zero/negative size
// yields empty rects + columns==1.
// Divide a (w x h) browser area into its regions and compute the column count. columns =
// max(1, grid.width/cardWidth) so a browser narrower than one card still lays out a
// single column.
BrowserLayout layoutBrowser(int w, int h);
// The cell rect of capture card `index` (0-based) in the grid, laid out left-to-right then
// top-to-bottom across `columns`. This is the full CELL (card + gutter); cardContentRect
// insets it to the drawable card. Rows past the visible grid are still computed (the shell
// clips at paint time). A negative index yields an empty rect. Pure.
// Cell rect of capture card `index` (0-based), left-to-right then top-to-bottom across
// `columns`. This is the full cell (card + gutter); cardContentRect insets it.
Rect cardCellRect(const BrowserLayout& layout, int index);
// The drawable card rect inside a cell: the cell inset by kBrowserCardGutter on all sides.
// The shell fills this (background + border) and draws the thumbnail/name/badge inside it. Pure.
// Drawable card rect inside a cell: the cell inset by kBrowserCardGutter on all sides.
Rect cardContentRect(const BrowserLayout& layout, int index);
// The peak-thumbnail sub-rect at the top of a card's content: full card width, the top
// kBrowserThumbHeight (clamped to the card height). The shell draws the envelope here; the
// name + badge go in the remaining strip below. Pure.
// Peak-thumbnail sub-rect at the top of a card's content: full card width, the top
// kBrowserThumbHeight (clamped to the card height).
Rect cardThumbnailRect(const BrowserLayout& layout, int index);
// The name/badge sub-rect below the thumbnail: the card content minus the thumbnail band.
// The shell draws the display name + root/key badge here. Pure.
// Name/badge sub-rect below the thumbnail.
Rect cardLabelRect(const BrowserLayout& layout, int index);
// The card a click at (x, y) lands on, given `cardCount` cards, or -1 for a click outside
// every card (in a gutter, past the last card, or on the tab strip). Only the card CONTENT
// rect counts as a hit — a click in the inter-card gutter is a miss. Pure.
// Card a click at (x, y) lands on, given `cardCount` cards, or -1 for a miss. Only the
// card content rect counts as a hit — a click in the inter-card gutter is a miss.
int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y);
// --- Bank-filter tabs --------------------------------------------------------
// --- Bank-filter tabs ---------------------------------------------------------
//
// The tab strip divides tabStrip into `tabCount` equal segments (mirror of mode_switch):
// one tab per bank_book bank plus a leading "All" tab the shell prepends, so tabCount ==
// bankCount + 1 in practice. This module only divides the strip + hit-tests; the shell
// supplies the labels and tracks which tab is active. A tab click narrows the card list to
// that bank (the shell filters its SampleChoice list before laying out cards).
// Divides tabStrip into `tabCount` equal segments: one tab per bank plus a leading "All"
// tab the shell prepends. This module only divides the strip + hit-tests; the shell
// supplies labels and tracks the active tab.
// The rect of tab `index` (0-based) when the strip is divided into `tabCount` equal
// segments. The last tab absorbs any width remainder so the tabs tile the whole strip with
// no gap (mirror of mode_switch's segment split). A negative index or tabCount<=0 yields an
// empty rect. Pure.
// Rect of tab `index` when the strip is divided into `tabCount` equal segments. The last
// tab absorbs any width remainder so the tabs tile the whole strip with no gap.
Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index);
// The tab a click at (x, y) lands on, given `tabCount` tabs, or -1 for a click outside the
// tab strip. Pure.
int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y);
} // namespace reasampler::instrument::ui
+18 -24
View File
@@ -1,17 +1,12 @@
// curve_popup.h — PURE sheet geometry + dismissal test for the r11 velocity-curve popup
// editor (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
// of overflow_menu: the size-clamp / centering / title-row arithmetic lives here, unit-tested
// at the clamps outside the DAW, while the editor shell draws the wash + sheet through the
// L1 kit and routes clicks (close / curve box / outside-sheet dismiss) via these rects.
// curve_popup.h — sheet geometry + dismissal test for the velocity-curve popup editor.
// Mirror of overflow_menu; the shell draws through the L1 kit and routes clicks via
// these rects.
//
// THE POPUP (CONTEXT.md §S-VIEW r11). Summoned by the mini curve-preview button, a CENTERED
// SHEET over the Sample face (a 0.50-alpha bg/base wash behind it — lighter than Browse's
// 0.82; a focused sub-editor, not a view change): width clamp(60% of window, 360..520),
// height clamp(55% of window, 260..380). Inside: a ~22px title row ("VELOCITY -> AMP"
// micro-caps left, an 18x18 Close button right) over the full-size curve box filling the
// remainder. The curve box rect here is the BORDER rect — the shell derives the mapping box
// through its ONE curveBoxFromRect formula (the landed inset grammar), so the popup editor
// and the Zone-panel inline editor share coordinates by construction.
// A centered sheet over the Sample face (a lighter wash than Browse's, since this is a
// focused sub-editor, not a view change): width/height each clamp to a fraction of the
// window within min/max bounds. A title row sits over the curve box. The curve box rect
// here is the border rect — the shell derives the mapping box via its curveBoxFromRect
// formula, so the popup editor and the Zone-panel inline editor share coordinates.
#pragma once
@@ -19,7 +14,7 @@
namespace reasampler::instrument::ui {
// Fixed popup metrics (spec r11), exposed so the shell and tests agree.
// Fixed popup metrics, exposed so the shell and tests agree.
inline constexpr int kCurvePopupMinW = 360;
inline constexpr int kCurvePopupMaxW = 520;
inline constexpr int kCurvePopupMinH = 260;
@@ -29,20 +24,19 @@ inline constexpr int kCurvePopupCloseSize = 18;
inline constexpr int kCurvePopupPad = 8; // sheet inner padding (title inset + box margins)
struct CurvePopupLayout {
Rect sheet; // the bg/panel sheet, centered in the window
Rect title; // the caption text rect (left part of the title row)
Rect close; // the 18x18 Close (x) button, right-anchored in the title row
Rect curveBox; // the full-size curve editor BORDER rect (shell insets via curveBoxFromRect)
Rect sheet;
Rect title;
Rect close; // Close (x) button, right-anchored in the title row
Rect curveBox; // full-size curve editor border rect (shell insets via curveBoxFromRect)
};
// The popup geometry for a (w x h) window: sheet width clamp(60% w, 360..520) and height
// clamp(55% h, 260..380) — each additionally capped at the window dimension so a degenerate
// window never yields an overhanging sheet — centered; title row + close button at the top;
// the curve box filling the remainder inside kCurvePopupPad margins. Pure.
// Popup geometry for a (w x h) window: sheet width clamp(60% w, min..max) and height
// clamp(55% h, min..max), each additionally capped at the window dimension so a
// degenerate window never yields an overhanging sheet — centered.
CurvePopupLayout computeCurvePopup(int w, int h);
// True when (x, y) lands OUTSIDE the sheet (on the wash) — the click-outside dismissal test.
// The shell additionally gates on "no drag in flight" (spec). Pure.
// True when (x, y) lands outside the sheet (on the wash) — the click-outside dismissal
// test. The shell additionally gates on "no drag in flight".
bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y);
} // namespace reasampler::instrument::ui
+16 -51
View File
@@ -8,8 +8,6 @@ namespace reasampler::instrument::ui {
namespace {
// Spike editor layout constants. These are the editor's fixed metrics; the real
// editor (S4/S5) will parameterize as its content demands.
constexpr int kTitleBarHeight = 28;
constexpr int kButtonMargin = 10;
constexpr int kButtonWidth = 120;
@@ -17,26 +15,18 @@ constexpr int kButtonHeight = 24;
} // namespace
// 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
// inverted rects.
// Clamp to non-negative extents so a degenerate view can't produce inverted rects.
const int cw = std::max(0, w);
const int ch = std::max(0, h);
EditorLayout out;
// Title bar spans the top, clamped so it never exceeds the client height.
const int titleH = std::min(kTitleBarHeight, ch);
out.titleBar = Rect::ltrb(0, 0, cw, titleH);
// Canvas is everything below the title bar.
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.
// Button inset from the canvas top-left, clamped so it never overhangs a small view.
const int bx = out.canvas.x + kButtonMargin;
const int by = out.canvas.y + kButtonMargin;
const int bRight = std::min(bx + kButtonWidth, out.canvas.right());
@@ -59,15 +49,11 @@ Rect sampleRowRect(const EditorLayout& layout, int index) {
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.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.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;
return index;
@@ -80,9 +66,6 @@ KeymapEditorLayout layoutKeymapEditor(int w, int h) {
out.base = layoutEditor(w, h);
const Rect& canvas = out.base.canvas;
// 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 splitW = canvasW / kZonePanelFraction; // width of the zone panel
const int splitX = std::max(canvas.x, canvas.right() - splitW);
@@ -90,13 +73,11 @@ KeymapEditorLayout layoutKeymapEditor(int w, int h) {
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));
out.addZoneButton =
Rect::ltrb(out.zonePanel.x, out.zonePanel.y, out.zonePanel.right(),
out.zonePanel.y + addH);
// Zone rows stack below the button.
out.zoneRowArea = Rect::ltrb(out.zonePanel.x, out.addZoneButton.bottom(),
out.zonePanel.right(), out.zonePanel.bottom());
return out;
@@ -138,10 +119,8 @@ ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int
const Rect row = zoneRowRect(layout, index);
if (y >= row.bottom()) return ZoneHit{};
// Seven mini-buttons pinned to the right edge, right-to-left:
// delete, root+, root-, high+, high-, low+, low-
// Each is kZoneCtrlWidth wide. A click left of the leftmost is the label ("select").
// The fields laid out LEFT-TO-RIGHT in slot order 0..6.
// Seven mini-buttons pinned to the right edge, each kZoneCtrlWidth wide, in slot
// order 0..6; a click left of the leftmost is the label ("select").
const ZoneField fields[7] = {
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp,
@@ -159,35 +138,25 @@ 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 kHeroMinHeight = 150; // elastic hero's floor
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 kStripBandHeight = 40; // 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).
// Cluster's fixed right-anchored run: Preview button, vel knob cell, curve 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
constexpr int kVelCellW = 48;
constexpr int kCurveBtnSize = 28;
// 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.
// Band order: title (fixed) -> hero (elastic, absorbs remaining height, floor
// kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom-anchored). A
// window too short for the floor keeps the hero at its floor and clips lower bands.
SampleBands computeSampleBands(int w, int h, int deckH) {
SampleBands b;
const int titleH = (std::min)(kTitleHeight, h);
@@ -214,7 +183,6 @@ SampleBands computeSampleBands(int w, int h, int 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;
@@ -261,16 +229,14 @@ 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.
// Sits below the "+ Add Zone" affordance (top+4, height 20) with a 12px gap.
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.
// Anchored off zonesStripArea.bottom() so the legend top tracks the strip bottom.
Rect noteEntryFieldsArea(const Rect& content) {
const int stripBottom = zonesStripArea(content).bottom();
const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom() + 8)
@@ -292,9 +258,8 @@ Rect zonesControlPanel(const Rect& content) {
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.
// Top-anchored; reserves a column at the panel's right for the 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());
+78 -118
View File
@@ -1,14 +1,6 @@
// editor_geometry.h — PURE view geometry + hit-test for the VST3 IPlugView LICE
// editor (Phase S1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
//
// The IPlugView shell (reasampler_editor.cpp) owns the window/bitmap/SWELL plumbing
// and is DAW-verified; this module holds the fiddly rectangle math and hit-testing so
// it can be unit-tested outside the DAW — the mirror of how bank_grid / mode_switch /
// tab_strip split their layout math out of the panel shell.
//
// The spike's editor is deliberately trivial (a title band + one clickable button),
// enough to PROVE the host->draw/hit-test event routing works. As the real editor
// (S4/S5) grows, its layout math accretes here, not in the shell.
// editor_geometry.h — view geometry + hit-test for the VST3 IPlugView LICE editor. The
// IPlugView shell owns window/bitmap/SWELL plumbing; the rectangle math and hit-testing
// live here so they can be unit-tested outside the DAW.
#pragma once
@@ -16,101 +8,80 @@
namespace reasampler::instrument::ui {
// 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
// that spills outside the surface.
// Title band + one button + remaining canvas, clamped so a degenerate (too-small) view
// never yields a region spilling outside the surface.
struct EditorLayout {
Rect titleBar; // top band: the plugin name + a live-state readout
Rect button; // a single clickable button (proves hit-test routing)
Rect canvas; // the remaining surface below the title bar
Rect titleBar;
Rect button;
Rect canvas;
};
// Divide a (w x h) client area into the spike editor's regions. Pure: the same
// inputs always yield the same layout. Guards tiny sizes — every returned rect stays
// within [0,w] x [0,h], and the button never overhangs the canvas.
// Divide a (w x h) client area into the editor's top-level regions. Pure.
EditorLayout layoutEditor(int w, int h);
// The editor's hit-test targets. kNone means the point landed on inert surface.
enum class HitTarget {
kNone,
kButton,
};
// Classify a click at (x, y) against a layout. The button wins only when the point is
// inside the button rect; everything else (including the title bar and empty canvas)
// is kNone in the spike.
// Classify a click at (x, y) against a layout.
HitTarget hitTest(const EditorLayout& layout, int x, int y);
// --- Sample-selection list (S4 Tier-0 UI) -----------------------------------
// --- Sample-selection list ---------------------------------------------------
//
// The Tier-0 editor lists the bank's samples as a vertical stack of fixed-height rows
// below the title bar; clicking a row selects that sample. This is the pure geometry:
// the row rectangles and the point->row hit-test, unit-tested outside the DAW while the
// shell draws the names and routes the click into the processor's reloadInstrument.
// A vertical stack of fixed-height rows below the title bar; clicking a row selects that
// sample. Pure geometry only — the shell draws names and routes the click.
// The fixed row height (px) for one sample entry. Exposed so the shell and tests agree.
inline constexpr int kSampleRowHeight = 22;
// The rectangle for row `index` (0-based) of the sample list, laid out top-down inside
// the layout's canvas. Rows beyond what the canvas can show are still computed (the
// shell clips at paint time); a negative index yields an empty rect. Pure.
// Rect for row `index` (0-based), laid out top-down inside the layout's canvas. Rows
// beyond what the canvas can show are still computed (the shell clips at paint time); a
// negative index yields an empty rect.
Rect sampleRowRect(const EditorLayout& layout, int index);
// The row index a click at (x, y) lands on, given `rowCount` rows, or -1 for a click
// outside the list (above the first row, past the last, or on the title bar). Pure.
// Row index a click at (x, y) lands on given `rowCount` rows, or -1 for a click outside
// the list.
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y);
// --- Keymap editor (S5 Tier-1 UI) -------------------------------------------
// --- Keymap editor ------------------------------------------------------------
//
// The Tier-1 editor splits the canvas into a LEFT bank-sample list (the same rows as
// Tier 0, reused for the "sample to add / fallback pick") and a RIGHT zone panel listing
// the performance map's zones. An "Add Zone" button sits at the top of the zone panel;
// each zone row carries small nudge/delete controls so the user can set the range and
// root note without a text field (LICE has no native numeric entry). All rectangle math
// is here so the shell only draws + routes — the mirror of the sample-list split above.
// Splits the canvas into a LEFT bank-sample list (the sample-selection rows above, reused
// as the "sample to add / fallback pick") and a RIGHT zone panel listing the performance
// map's zones. An "Add Zone" button sits at the top of the zone panel; each zone row
// carries nudge/delete mini-buttons (LICE has no native numeric entry field).
// Fixed metrics for the zone panel, exposed so the shell and tests agree.
inline constexpr int kZoneRowHeight = 24;
inline constexpr int kZonePanelFraction = 2; // zone panel gets the RIGHT 1/2 of the canvas
inline constexpr int kZoneCtrlWidth = 20; // width of one nudge/delete mini-button
inline constexpr int kAddZoneHeight = 22; // the "Add Zone" button band height
inline constexpr int kAddZoneHeight = 22; // "Add Zone" button band height
// 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.
// Clamps every rect to the canvas so a degenerate view still yields in-bounds rects.
struct KeymapEditorLayout {
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
EditorLayout base;
Rect sampleList; // LEFT column
Rect zonePanel; // RIGHT column
Rect addZoneButton; // top of the zone panel
Rect zoneRowArea; // below addZoneButton: where zone rows stack
Rect zoneRowArea; // below addZoneButton
};
KeymapEditorLayout layoutKeymapEditor(int w, int h);
// The rectangle for bank-sample row `index` inside the LEFT sample list column of a
// keymap layout. Same fixed height as the Tier-0 list; laid out top-down inside
// sampleList. Negative index -> empty. Pure.
// Rect for bank-sample row `index` inside the LEFT column. Negative index -> empty.
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index);
// The bank-sample row a click lands on inside the left list, or -1 outside it. Pure.
// Bank-sample row a click lands on inside the left list, or -1 outside it.
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y);
// The rectangle for zone row `index` inside the zone panel's zoneRowArea. Negative
// index -> empty. Pure.
// Rect for zone row `index` inside zoneRowArea. Negative index -> empty.
Rect zoneRowRect(const KeymapEditorLayout& layout, int index);
// A zone row's interactive fields. The row is a horizontal strip: a label on the left,
// then seven fixed-width mini-buttons on the right (left-to-right: low-, low+, high-, high+,
// root-, root+, delete). kZoneNone means the click missed a control
// (e.g. on the label) — the shell may still treat that as "select this zone".
// A zone row's interactive fields: a label on the left, then seven fixed-width
// mini-buttons on the right (low-, low+, high-, high+, root-, root+, delete). kZoneNone
// means the click missed a control (e.g. the label) — the shell may still treat that as
// "select this zone".
enum class ZoneField {
kZoneNone,
kLowDown,
@@ -122,101 +93,90 @@ enum class ZoneField {
kDelete,
};
// The result of hit-testing a click against the zone rows: which zone row (or -1) and
// which field within it. A click on the "Add Zone" button is reported separately by
// addZoneHitTest — this covers only the zone rows.
// Which zone row (or -1) and which field within it a click landed on. A click on
// "Add Zone" is reported separately by addZoneHitTest.
struct ZoneHit {
int zoneIndex = -1;
ZoneField field = ZoneField::kZoneNone;
};
// Classify a click at (x, y) against `zoneCount` zone rows. Returns {-1, kZoneNone} for a
// click outside every zone row. Within a row, the seven mini-buttons occupy fixed-width
// slots on the right edge (left-to-right: low-, low+, high-, high+, root-, root+, delete);
// a click left of those slots is {index, kZoneNone} (the label area — "select"). Pure.
// Classify a click at (x, y) against `zoneCount` zone rows. {-1, kZoneNone} for a miss.
// Within a row, the seven mini-buttons occupy fixed-width slots on the right edge; a
// click left of those slots is {index, kZoneNone} (the label area — "select").
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y);
// 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) ----------------------
// --- Sample / Zone face layout ------------------------------------------------
//
// 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).
// The capture-first editor's band/cluster/zone-surface layout math. Draw and hit-test
// both derive every rect from these 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).
// 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.
// Sample-face bands (top->bottom): TITLE (name + Browse/Zone nav), a full-width elastic
// HERO (absorbs all height left after the fixed bands, floored), the root+preview
// CLUSTER, and the bottom-anchored knob DECK (height `deckH` from knob_deck's wrap). A
// window shorter than the hero floor clips the lower bands past the window bottom.
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 title;
Rect navBrowse;
Rect navZone;
Rect hero; // waveform + 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)
Rect deck;
};
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).
// Cluster sub-rects: the root strip keeps the left side at remainder width; the right
// side is the fixed-width right-anchored run (Preview · vel knob cell · curve button ·
// Mono|Stereo). `knobSize` is the deck knob square, 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)
Rect rootStrip;
Rect preview;
Rect velCell; // preview-velocity knob cell (knob + label band)
Rect velKnob;
Rect velLabel;
Rect curveBtn; // opens the curve-preview 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.
// Mono/stereo toggle: a two-segment control right-anchored in `area`, vertically centered.
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.
// Zone-view 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).
// Zone/Browse "Back" button — 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).
// "+ Add Zone" affordance 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.
// Zone-view keyboard strip rect: below "+ Add Zone" 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.
// Numeric-entry field row area inside the Zones legend, and the rect of field `f`
// (0=low, 1=high, 2=root) within it — three equal segments left-to-right. 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).
// Per-zone parameter panel below the strip + legend, running to the content bottom; the
// knob-deck area within it (a right column reserved for the curve-preview button); and
// that button's rect (right-anchored at the panel top).
Rect zonesControlPanel(const Rect& content);
Rect zonesDeckArea(const Rect& content);
Rect zonesCurveButton(const Rect& content);
+5 -8
View File
@@ -8,17 +8,15 @@ namespace reasampler::instrument::ui {
namespace {
// Clamp a MIDI note to [0, kEmbedKeyCount-1].
int clampNote(int n) {
if (n < 0) return 0;
if (n > kEmbedKeyCount - 1) return kEmbedKeyCount - 1;
return n;
}
// Map a key boundary in [0, kEmbedKeyCount] to an x pixel inside a band of the given
// left/width. keyEdge is a boundary (0..128), so keyEdge==128 maps to the band's right.
// Integer math, floored — a zone's left uses floor(low) and its right uses floor(high+1),
// which tiles adjacent zones without a seam.
// Maps a key boundary (0..128) to an x pixel; keyEdge==128 maps to the band's right. A
// zone's left uses floor(low) and its right uses floor(high+1), tiling adjacent zones
// without a seam.
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
if (keyEdge <= 0) return bandLeft;
if (keyEdge >= kEmbedKeyCount) return bandLeft + bandWidth;
@@ -33,9 +31,8 @@ EmbedLayout layoutEmbed(int w, int h) {
EmbedLayout out;
// The level band takes a fixed height at the bottom, but never so much that the keymap
// above it falls below its minimum (or that the band exceeds the area). On a very short
// area the band yields to the keymap entirely.
// Fixed height at the bottom, but never so much that the keymap falls below its
// minimum; on a very short area the band yields to the keymap entirely.
int bandH = std::min(kEmbedLevelBandHeight, ch);
if (ch - bandH < kEmbedKeymapMinHeight) {
bandH = std::max(0, ch - kEmbedKeymapMinHeight);
+28 -47
View File
@@ -1,76 +1,57 @@
// embed_strip.h — PURE layout + hit-test for the S6 embedded TCP/MCP strip. NO VST3,
// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry /
// mode_switch: the fiddly rectangle math for the compact inline keymap/level strip lives
// here so it is unit-tested outside the DAW, while the embed shell (reasampler_embed.cpp)
// marshals REAPER's embed messages (paint bitmap + mouse coords) into these functions.
// embed_strip.h — layout + hit-test for the embedded TCP/MCP strip. Mirror of
// editor_geometry/mode_switch; the embed shell marshals REAPER's embed messages (paint
// bitmap + mouse coords) into these functions.
//
// The strip is a single compact band REAPER draws inline in the track/mixer control panel
// (context TCP or MCP) via the Cockos embedded-UI surface. It shows:
// * the zone layout — each performance zone as a horizontal segment across the keyboard
// span (MIDI 0..127 mapped to the strip width), so the keymap reads at a glance; and
// * a thin level band at the bottom — a 0..1 activity indicator the shell fills.
// Interaction is zone SELECTION at most (S6 constraint: no new editing semantics) — a
// click maps to the zone whose key range covers that point, or -1.
//
// It reuses the same Rect + contains() as editor_geometry (the strip and the editor share
// one geometry idiom), so this header depends on editor_geometry.h rather than redefining
// a second rectangle type.
// A single compact band REAPER draws inline in the track/mixer control panel via the
// Cockos embedded-UI surface: each performance zone as a horizontal segment across the
// keyboard span (MIDI 0..127 mapped to the strip width), plus a thin activity level band
// at the bottom. Interaction is zone selection only — no editing.
#pragma once
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
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.
inline constexpr int kEmbedKeyCount = 128;
// Fixed metrics for the strip, exposed so the shell and tests agree.
inline constexpr int kEmbedLevelBandHeight = 4; // the bottom activity band (px)
inline constexpr int kEmbedKeymapMinHeight = 6; // keymap area collapses no smaller
inline constexpr int kEmbedLevelBandHeight = 4;
inline constexpr int kEmbedKeymapMinHeight = 6;
// One zone rendered on the strip: its inclusive MIDI key range. This is the minimal
// projection of a PerformanceZone the strip needs (it does not carry sample ids or PCM
// the shell resolves labels; the strip only lays out ranges). lowNote/highNote are
// expected in [0,127] with low <= high, but the layout clamps defensively so a malformed
// zone never yields an out-of-strip rect.
// One zone rendered on the strip: its inclusive MIDI key range the minimal projection
// of a PerformanceZone the strip needs (no sample ids or PCM). Expected in [0,127] with
// low <= high; layout clamps defensively regardless.
struct EmbedZone {
int lowNote = 0;
int highNote = 127;
};
// The strip's regions, derived from the (w x h) embed area REAPER reports. Both clamp to
// the area so a degenerate (tiny) size never yields a region spilling outside the surface.
// Clamped to the area so a degenerate (tiny) size never yields a region spilling outside
// the surface.
struct EmbedLayout {
Rect keymap; // top: the zone-segment band (the compact keymap)
Rect levelBand; // bottom: the thin level/activity indicator
Rect keymap; // top: zone-segment band
Rect levelBand; // bottom: level/activity indicator
};
// Divide a (w x h) embed area into the strip's regions. Pure: same inputs -> same layout.
// The level band takes a fixed height at the bottom (clamped so it never exceeds the area
// or starves the keymap below kEmbedKeymapMinHeight); the keymap takes the rest. A zero or
// negative size yields empty rects (no inversion).
// Divide a (w x h) embed area into the strip's regions. The level band takes a fixed
// height at the bottom (clamped so it never starves the keymap below
// kEmbedKeymapMinHeight); the keymap takes the rest.
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
// 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,
// never an inverted one. Pure.
// Horizontal sub-rect of the keymap band for a zone spanning [lowNote, highNote]
// (inclusive). Spans the half-open pixel range so adjacent zones tile without a gap or
// overlap. Notes clamp to [0,127] and low clamps to <= high.
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote);
// The zone a click at (x, y) lands on, given the zones in draw order, or -1 for a click
// outside the keymap band or on a key not covered by any zone. When zones overlap on a
// key, the FIRST covering zone in order wins — mirroring the sampler core's first-match
// Keymap::resolve and the editor's zone order, so selection agrees with playback. Pure.
// Zone a click at (x, y) lands on, given zones in draw order, or -1 for a miss. When
// zones overlap on a key, the first covering zone in order wins — mirroring the sampler
// core's first-match Keymap::resolve, so selection agrees with playback.
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
int y);
// The filled portion of the level band for a 0..1 level. Clamps level to [0,1]; the
// returned rect is the left sub-rectangle of levelBand whose width is level * band width
// (rounded down). level <= 0 -> empty rect; level >= 1 -> the whole band. Pure.
// Filled portion of the level band for a 0..1 level (clamped); left sub-rect of levelBand
// whose width is level * band width.
Rect levelFillRect(const EmbedLayout& layout, double level);
} // namespace reasampler::instrument::ui
+19 -43
View File
@@ -9,34 +9,28 @@ namespace reasampler::instrument::ui {
namespace {
// 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.
// Matches envelope_overlay::timeToX. Zero when the area is degenerate (no motion).
double secondsPerPixel(const Rect& area, double totalSeconds) {
const int w = std::max(0, area.width);
if (w <= 0 || totalSeconds <= 0.0) return 0.0;
return totalSeconds / static_cast<double>(w);
}
// Seconds per pixel for a GATE time-node drag (FA2): the reciprocal of the overlay's
// param-domain gatePxPerSecond(area) scale — sample-length-free, matching
// envelope_overlay::gatePolyline exactly so the dragged handle tracks the cursor 1:1 (each
// node's x is affine in its own segment duration with slope gatePxPerSecond). Zero when the
// area is degenerate.
// Reciprocal of the overlay's gatePxPerSecond, matching gatePolyline's scale exactly so a
// dragged handle tracks the cursor 1:1.
double gateSecondsPerPixel(const Rect& area) {
const double pps = gatePxPerSecond(area);
return pps > 0.0 ? 1.0 / pps : 0.0;
}
// 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.
// Matches envelope_overlay::levelToY (spans height-1 rows for [0,1]).
double levelPerPixel(const Rect& area) {
const int h = std::max(0, area.height);
if (h <= 1) return 0.0;
return 1.0 / static_cast<double>(h - 1);
}
// True for the nodes the user can grab-and-drag (Origin + ReleaseStart are draw-only anchors).
// Origin + ReleaseStart are draw-only anchors, not grabbable.
bool isDraggable(EnvNode n) {
switch (n) {
case EnvNode::Origin:
@@ -47,10 +41,8 @@ bool isDraggable(EnvNode n) {
}
}
// True when the node belongs to the envelope's active mode. Guards the degenerate cross-mode
// write: the degenerate baseline polyline carries a ReleaseEnd vertex regardless of mode, so a
// zero-height Trigger-mode grab of it must not write releaseSeconds (and vice versa for Gate
// nodes vs Trigger fields). Applied by BOTH the hit-test and the drag resolver so they agree.
// Guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds in
// Trigger mode (and vice versa). Applied by both the hit-test and the drag resolver.
bool nodeInMode(EnvNode n, EnvMode m) {
switch (n) {
case EnvNode::AttackEnd:
@@ -64,7 +56,7 @@ bool nodeInMode(EnvNode n, EnvMode m) {
return m == EnvMode::Trigger;
case EnvNode::Origin:
case EnvNode::ReleaseStart:
return false; // never draggable in any mode (isDraggable filters these anyway)
return false;
}
return false;
}
@@ -73,18 +65,15 @@ bool nodeInMode(EnvNode n, EnvMode m) {
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y) {
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds);
// NEAREST draggable, mode-matching node within the pick radius wins (Chebyshev distance
// the square grab box); ties break to the earlier draw-order node (FA2). Gate nodes never
// coincide (the forward map enforces kGateNodeSepPx separation), so the tie-break only
// matters for Trigger's zero-fade-out coincidence: FadeOutStart overlays LengthEnd, WINS the
// tie, and can be dragged inward from the right edge. The mode filter keeps the degenerate
// baseline's ReleaseEnd vertex from registering as a grabbable node in Trigger mode.
// Nearest draggable, mode-matching node within the pick radius wins (Chebyshev distance);
// ties go to the earlier draw-order node. Only matters for Trigger's zero-fade-out
// coincidence (FadeOutStart overlaps LengthEnd and wins).
NodeHit best;
int bestDist = kNodeGrabRadius + 1;
for (const EnvVertex& v : poly) {
if (!isDraggable(v.node) || !nodeInMode(v.node, env.mode)) continue;
const int dist = std::max(std::abs(x - v.x), std::abs(y - v.y));
if (dist < bestDist) { // strictly closer only: earlier draw order keeps ties
if (dist < bestDist) { // strict-less-than keeps ties at the earlier draw order
bestDist = dist;
best = NodeHit{true, v.node};
}
@@ -101,16 +90,12 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
const double secPerPx = secondsPerPixel(area, totalSeconds);
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
const double dSec = static_cast<double>(dxPixels) * secPerPx;
// Gate time nodes use the schematic's PARAM-DOMAIN px->seconds scale (FA2) — the reciprocal
// of the overlay's gatePxPerSecond, sample-length-free — so the dragged handle tracks the
// cursor 1:1. gateTimedWidth >= 1 whenever the area is non-empty, so gateDSec is
// well-defined past the degenerate guard above.
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(area);
switch (node) {
// --- Gate: each cumulative-time node edits its OWN segment duration. Non-negative
// durations ARE the monotonic-in-time guarantee (a node can never cross a neighbour
// because every segment stays >= 0), so the [0, max] clamp is the whole constraint.
// Gate: each cumulative-time node edits its own segment duration. Non-negative durations
// ARE the monotonic-in-time guarantee (a segment can never go negative, so a node can
// never cross a neighbour) — the [0, max] clamp is the whole constraint.
case EnvNode::AttackEnd:
out.attackSeconds =
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
@@ -119,8 +104,7 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
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).
// X sets decay time, Y sets sustain level (drag down = higher y = lower level).
out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
const double lvlPerPx = levelPerPixel(area);
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
@@ -132,17 +116,9 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds);
break;
// --- Trigger: fades + length are FRACTIONS. X pixels convert to a fraction of the PLAYED
// span (fades) or the whole sample (length). Monotonic: fadeIn + fadeOut <= 1 so the
// two fade nodes never cross (each clamps against the other), and length in [0, max].
//
// TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this):
// fadeInFraction/fadeOutFraction in AmpEnvelope are fractions of the played span.
// TriggerParams (sampler_core.h) stores the corresponding values as SOURCE FRAMES
// (fadeInFrames/fadeOutFrames, int64_t). The shell owes a converter on BOTH directions:
// pack (draw): fadeInFrames/fadeOutFrames -> fraction (needs frameCount + rate)
// unpack (commit): fraction -> fadeInFrames/fadeOutFrames (same inputs)
// See the TRIGGER SEAM note on AmpEnvelope in envelope_overlay.h for the formula.
// Trigger: fades + length are fractions. X pixels convert to a fraction of the played
// span (fades) or the whole sample (length). fadeIn + fadeOut <= 1 keeps the two fade
// nodes from crossing (each clamps against the other).
case EnvNode::FadeInEnd: {
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
+35 -72
View File
@@ -1,41 +1,18 @@
// envelope_edit.h — PURE node hit-test + pixel-deltaclamped-param inverse map for the S-VIEW-3
// draggable envelope nodes. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
// of card_drag / waveform_view: the drag arithmetic + clamp/monotonic constraints live here,
// unit-tested at the boundaries outside the DAW, while the editor shell (reasampler_editor.cpp)
// draws the handles, captures the grab on WM_LBUTTONDOWN, feeds each move's pixel delta back
// through here, and commits the resulting params to the zone through the same off-audio-thread
// path a slider edit uses.
// envelope_edit.h — node hit-test + pixel-delta -> clamped-param inverse map for the draggable
// envelope nodes. Mirror of card_drag/waveform_view: drag arithmetic lives here, unit-tested
// outside the DAW; the shell draws handles, captures the grab, and feeds pixel deltas back in.
//
// TWO SURFACES, ONE MODEL. envelope_overlay owns the paramspolyline FORWARD map (draw); this
// module owns the pixel→params INVERSE map (edit) + node hit-test. Both read/write the SAME
// AmpEnvelope fields (the shell re-reads the zone every paint — no listener chain), so a node
// drag and a slider edit are two views on one source of truth and can never diverge.
// envelope_overlay owns the params->polyline forward (draw) map; this module owns the inverse
// (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the zone
// every paint), so a node drag and a slider edit are two views on one source of truth.
//
// THE INVARIANT (S-VIEW-F2). A drag can NEVER produce a param a slider couldn't:
// * MONOTONIC IN TIME — a node clamps between its time predecessor and successor, so attack-end
// can't pass hold-end, decay can't pass release, etc. Each segment stays >= 0.
// * RANGE-CLAMPED — times clamp to the SAME per-param [min,max] the slider enforces; levels
// clamp to [0,1]. Because the concrete second/fraction maxima live SHELL-SIDE (param_slider
// is deliberately engine-free — the shell owns the 0..1↔domain mapping), the clamp bounds are
// CALLER-SUPPLIED here (EnvClampBounds): the shell passes the same maxima it feeds the slider,
// so the two surfaces share one clamp by construction.
// A drag can never produce a param a slider couldn't: nodes are monotonic in time (clamped
// between time predecessor/successor) and range-clamped to the same per-param [min,max] the
// slider uses (EnvClampBounds, caller-supplied since those maxima live shell-side).
//
// WHICH AXES. Time-only nodes (AttackEnd, HoldEnd, ReleaseEnd; FadeInEnd, FadeOutStart,
// LengthEnd) drag on X only. The sustain node (DecayEnd) drags on BOTH axes — its X sets the
// decay time, its Y sets the sustain level (the standard ADSR-editor grammar). Origin and the
// drawing-only ReleaseStart vertex are NOT draggable.
//
// GATE DRAG SCALE (FA2). Gate time nodes convert px->seconds via the reciprocal of the
// schematic's PARAM-DOMAIN scale (envelope_overlay's gatePxPerSecond — sample-length-free), so
// a dragged handle tracks the cursor exactly 1:1 for stages within the schematic domain (each
// node's x is affine in its own segment duration). Trigger nodes keep the full-canvas
// PCM-aligned scale. Both match the forward map in envelope_overlay. A node is only editable in
// its OWN mode: Gate nodes ignore drags while the envelope is in Trigger mode and vice versa
// (guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds).
//
// Reuses editor_geometry's Rect + the EnvNode / AmpEnvelope / EnvMode types from
// envelope_overlay (one shared node vocabulary across draw + edit), and the shared timeToX /
// levelToY maps so the handle the overlay drew and the grab region here agree pixel-for-pixel.
// Time-only nodes drag on X; DecayEnd (the sustain node) drags on both axes (X = decay time,
// Y = sustain level). Origin and the drawing-only ReleaseStart are not draggable. A node is only
// editable in its own mode (Gate nodes ignore drags in Trigger mode and vice versa).
#pragma once
@@ -47,60 +24,46 @@
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
// small handle comfortably, narrow enough that adjacent nodes stay distinguishable.
// Pick radius (px) around a node's drawn point, in both x and y. Mirrors waveform_view's
// kMarkerGrabWidth.
inline constexpr int kNodeGrabRadius = 6;
// The per-param clamp bounds the shell supplies (the SAME maxima its sliders map 0..1 onto). All
// are upper bounds in the param's own domain; the lower bound is 0 (each stage >= 0), and the
// monotonic-in-time constraint tightens these further at edit time. Defaults are conservative
// placeholders; the shell OVERRIDES them with its live slider domain so the clamp matches exactly.
// Per-param clamp bounds the shell supplies the same maxima its sliders map [0,1] onto.
// Lower bound is always 0; the monotonic-in-time constraint tightens further at edit time.
// Defaults are placeholders; the shell overrides with its live slider domain.
struct EnvClampBounds {
double maxAttackSeconds = 4.0; // upper bound of the attack slider
double maxAttackSeconds = 4.0;
double maxHoldSeconds = 4.0;
double maxDecaySeconds = 4.0;
double maxReleaseSeconds = 4.0;
// Trigger fades + length are fractions; their natural upper bound is 1.0. Exposed so a shell
// that caps a fade below the full span (e.g. 0.5) shares that cap with its slider.
double maxFadeInFraction = 1.0;
double maxFadeOutFraction = 1.0;
double maxLengthFraction = 1.0;
// sustainLevel is always [0,1] — no shell knob needed, kept implicit.
// sustainLevel is always [0,1] — no shell knob needed.
};
// Which node a grab at (x, y) lands on, given the CURRENT envelope + overlay rect + sample
// duration (the same inputs buildEnvelopePolyline drew from, so the grab tests the drawn handles).
// Returns EnvNode::Origin's NON-membership as a miss via the bool return: `hit` is false for a
// point off every DRAGGABLE node. Origin and ReleaseStart are never returned (not draggable),
// and a node from the OTHER mode is never returned (the degenerate baseline's ReleaseEnd vertex
// is not grabbable in Trigger mode). The NEAREST node within the radius wins (Chebyshev
// distance); an exact tie goes to the earlier draw-order node (FA2 — deterministic). Gate nodes
// never coincide (the forward map enforces kGateNodeSepPx separation, so every Gate handle is
// individually grabbable in every state); the tie-break matters only for Trigger's zero-fade-out
// coincidence, where FadeOutStart overlays LengthEnd, wins the tie, and can be dragged inward
// from the right edge. Pure.
// Which node a grab at (x, y) lands on, given the current envelope/rect/duration (the same
// inputs buildEnvelopePolyline drew from). `hit` is false for a point off every draggable node;
// Origin/ReleaseStart and nodes from the other mode never hit. Nearest node within the radius
// wins (Chebyshev distance); an exact tie goes to the earlier draw-order node — this only matters
// for Trigger's zero-fade-out coincidence (FadeOutStart overlaps LengthEnd and wins, so the fade
// can be dragged open from zero). Gate nodes never coincide (forward map enforces
// kGateNodeSepPx), so every Gate handle is independently grabbable.
struct NodeHit {
bool hit = false;
EnvNode node = EnvNode::Origin; // meaningful only when hit == true
};
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y);
// Resolve a drag of `node` to a new AmpEnvelope. Given the envelope AS OF GRAB TIME (`grabEnv` —
// the shell snapshots it on WM_LBUTTONDOWN so the delta is absolute, not accumulated), the overlay
// rect + sample duration (the pixel↔param maps), the caller's clamp bounds, and the pixel delta
// since grab (`dxPixels`, `dyPixels`), returns the envelope the node should now describe:
// * X delta -> the node's TIME param, shifted proportionally (same linear map as timeToX),
// clamped to [0, per-param max] AND to its monotonic-in-time neighbours (>= predecessor time,
// <= successor time). For a cumulative-time node the shift lands on that node's OWN segment
// duration (e.g. dragging HoldEnd changes holdSeconds, not attack).
// * Y delta -> the LEVEL param, but ONLY for the sustain node (DecayEnd); clamped to [0,1].
// dyPixels is IGNORED for every time-only node.
// * Non-draggable node (Origin / ReleaseStart), a node from the OTHER mode (a Gate node while
// grabEnv.mode is Trigger, or vice versa), a zero-width/zero-height area, or
// totalSeconds <= 0 -> `grabEnv` returned unchanged (no motion).
// Only the dragged node's param(s) change; every other field carries through from `grabEnv`. Pure
// — rounding is to the param's continuous value (no snapping, matching the sliders' resolution).
// Resolves a drag of `node` to a new AmpEnvelope. `grabEnv` is the envelope as of grab time (the
// shell snapshots it on button-down so the delta is absolute, not accumulated); `dxPixels`/
// `dyPixels` is the pixel delta since grab.
// * X delta -> the node's time param, shifted via the same linear map as timeToX, clamped to
// [0, per-param max] and to its monotonic-in-time neighbours.
// * Y delta -> the level param, only for DecayEnd; clamped to [0,1]. Ignored for time-only nodes.
// * A non-draggable node, an other-mode node, a zero-size area, or totalSeconds <= 0 returns
// `grabEnv` unchanged.
// Only the dragged node's param(s) change. Pure.
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels);
+18 -29
View File
@@ -8,15 +8,14 @@
namespace reasampler::instrument::ui {
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
using util::clamp01;
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.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
// t would overflow a 32-bit long (Windows) and wrap to the WRONG edge — then round.
// Clamp in double space before the int cast — a huge t would overflow a 32-bit long
// (Windows) and wrap to the wrong edge.
double px = (t / totalSeconds) * static_cast<double>(w);
if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1);
return area.x + static_cast<int>(px + 0.5);
@@ -33,9 +32,7 @@ int gateTimedWidth(const Rect& area) {
double gatePxPerSecond(const Rect& area) {
const int timedW = gateTimedWidth(area);
if (timedW <= 0) return 0.0;
// Usable width = timed region minus the four per-segment separation bases and the last
// in-bounds column, floored at 1 px so the scale never degenerates; the domain is the four
// stages end-to-end at their schematic maxima (param-domain scale — sample-length-free).
// Minus the four per-segment separation bases and the last in-bounds column, floored at 1.
const double usable =
std::max(1.0, static_cast<double>(timedW - 1 - 4 * kGateNodeSepPx));
return usable / (4.0 * kGateStageMaxSeconds);
@@ -46,8 +43,8 @@ int levelToY(const Rect& area, double level) {
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.
// Level 1 -> top row, level 0 -> bottom row; spans (h-1) px so both endpoints land on a
// drawable row.
const int span = h - 1;
const long dy = static_cast<long>((1.0 - level) * static_cast<double>(span) + 0.5);
return area.y + static_cast<int>(dy);
@@ -64,10 +61,8 @@ EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, dou
return v;
}
// One Gate vertex from a pixel offset inside the area (the Gate schematic works in px space —
// timed px + the fixed sustain-plateau reserve — not through the plain timeToX map). Clamps x in
// 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).
// Gate works in px space (timed px + the fixed sustain-plateau reserve) rather than the plain
// timeToX map; clamps in double space before the int cast for the same overflow reason as above.
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
const int w = std::max(1, area.width);
if (px < 0.0) px = 0.0;
@@ -81,18 +76,16 @@ EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
}
std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
// Non-negative segment durations (a stored negative would be an upstream bug; clamp defensively).
// Clamp defensively — a stored negative duration would be an upstream bug.
const double a = std::max(0.0, env.attackSeconds);
const double h = std::max(0.0, env.holdSeconds);
const double d = std::max(0.0, env.decaySeconds);
const double r = std::max(0.0, env.releaseSeconds);
const double sus = clamp01(env.sustainLevel);
// BOUNDED SCHEMATIC (FA2): A/H/D and R map onto the TIMED region (canvas minus the reserved
// sustain-plateau width) at the PARAM-DOMAIN scale — sample-length-free — and every segment
// 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.
// A/H/D/R map onto the timed region at the param-domain scale, each segment getting a
// kGateNodeSepPx base so nodes never coincide even at 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 double sustainPx = static_cast<double>(W - gateTimedWidth(area));
const double sep = static_cast<double>(kGateNodeSepPx);
@@ -104,10 +97,9 @@ std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
double xPlateau = xDecay + sustainPx; // ReleaseStart (schematic note-off)
double xRelease = xPlateau + sep + r * pps; // ReleaseEnd
// Right-edge overrun (a stored stage beyond the schematic domain): compress from the RIGHT
// preserving the minimum gaps, so trailing nodes stay individually separated instead of
// piling on the last column. The re-floor pass only bites when the canvas is too narrow to
// hold the minimum gaps at all — then gateVtx's [0, W-1] clamp wins (in-bounds > separation).
// Overrun beyond the schematic domain compresses from the right, preserving minimum gaps so
// trailing nodes stay separated instead of piling on the last column. This re-floor only
// bites when the canvas is too narrow to hold the gaps at all — gateVtx's clamp wins then.
const double xMax = static_cast<double>(W - 1);
if (xRelease > xMax) {
xRelease = xMax;
@@ -135,12 +127,11 @@ std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds) {
// The played span is lengthFraction of the whole sample; fades are fractions OF that span.
// Played span is lengthFraction of the whole sample; fades are fractions of that span.
const double len = clamp01(env.lengthFraction);
double fadeIn = clamp01(env.fadeInFraction);
double fadeOut = clamp01(env.fadeOutFraction);
// Fades cannot overlap: clamp so fadeIn + fadeOut <= 1 (of the played span), mirroring the
// engine's TriggerParams clamp. Trim the LATER fade (fade-out) first, matching the engine.
// Fades cannot overlap; trim fade-out first, matching the engine's TriggerParams clamp.
if (fadeIn + fadeOut > 1.0) fadeOut = std::max(0.0, 1.0 - fadeIn);
const double playSeconds = len * totalSeconds;
@@ -161,12 +152,10 @@ std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds) {
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.
// Degenerate surface: flat two-point baseline 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)};
}
// Gate is a param-domain schematic — totalSeconds only gates the degenerate branch above
// (no loaded duration -> baseline); Trigger is PCM-aligned and consumes it.
return env.mode == EnvMode::Gate ? gatePolyline(env, area)
: triggerPolyline(env, area, totalSeconds);
}
+54 -176
View File
@@ -1,59 +1,7 @@
// envelope_overlay.h — PURE amp-envelope polyline geometry for the S-VIEW-3 Sample-view
// envelope overlay. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of
// waveform_view / param_slider: the params→pixel polyline math lives here, unit-tested outside
// the DAW, while the editor shell (reasampler_editor.cpp) traces the polyline in an accent hue
// and draws the node handles (via envelope_edit's hit-test).
//
// WHAT IT DRAWS. The amp envelope over the Sample view's hero waveform (Simpler / Phase-Plant
// grammar):
// * Gate -> the AHDSR shape: attack ramp 0->1, hold plateau at 1, decay 1->sustain,
// sustain plateau, release sustain->0. Since there is no held note-off to draw
// against, Gate is a BOUNDED SCHEMATIC (FA2): a fixed fraction of the canvas
// width (kGateSustainDisplayFraction) is RESERVED for the sustain plateau, and
// the remaining "timed" width carries A/H/D AND the release at the PARAM-DOMAIN
// scale — the timed width represents 4 x kGateStageMaxSeconds (the four stage
// sliders end-to-end at their maxima), NOT the sample's duration, so the layout
// is identical for a 0.3s and a 10s capture. Each segment additionally gets a
// kGateNodeSepPx pixel base, so consecutive nodes NEVER coincide: every Gate
// node is individually grabbable at ANY param values, including the tier-0
// defaults (hold 0 / decay 0). A -> (H) -> D -> S-plateau -> R all render INSIDE
// the canvas and the release is a visible, draggable segment.
// * Trigger -> the fade/%-length shape: fade-in 0->1, unity plateau, fade-out 1->0 anchored
// to playEnd (= lengthFraction of the post-start span). Trigger keeps the
// waveform's exact time base so the shape lines up with the PCM under it.
// The horizontal axis is TIME (Gate: schematic, see above; Trigger: wall-clock across the rect);
// 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.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.
//
// FA2 CONTRACT CHANGE — WAVE B SHELL AUTHOR, READ THIS:
// * 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.
// * 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
// waveform under it — do not label it as if it did. Trigger's x-axis IS still PCM-aligned.
// * Gate nodes never coincide (min-separation, above), so every Gate handle is individually
// grabbable in every state. nodeAtPoint (envelope_edit) resolves to the NEAREST node within
// the grab radius with a draw-order tie-break; the tie-break only matters for the one
// remaining coincidence, Trigger's zero-fade-out (FadeOutStart overlays LengthEnd at the
// 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_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
// is the total sample duration in seconds, which the shell resolves once from the live rate and
// the frame count and passes in — this module never sees a sample rate.
//
// It reuses editor_geometry's Rect + contains(), the one shared geometry idiom.
// envelope_overlay.h — amp-envelope -> polyline geometry for the Sample-view envelope overlay.
// Engine-free by design (no sample_map/sampler_core dependency); mirror of waveform_view /
// param_slider. The shell packs the zone's AdsrSeconds/TriggerParams into AmpEnvelope and draws
// the polyline plus a handle at each node (envelope_edit does the hit-test).
#pragma once
@@ -64,166 +12,96 @@
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.
// Local mirror of sampler_core's PlayMode, kept here so this module stays engine-free.
enum class EnvMode { Gate, Trigger };
// Which breakpoint a polyline vertex / node is. The shell draws a draggable handle at each of
// these; envelope_edit hit-tests against them. Kept in one enum shared by overlay + edit so the
// forward map (draw) and inverse map (edit) name the same nodes.
//
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(=sustain corner) -> ReleaseStart
// -> ReleaseEnd. The sustain node is DecayEnd (its Y is the sustain level);
// ReleaseStart is a drawing-only plateau-end vertex (the schematic note-off);
// release is edited by dragging ReleaseEnd.
// Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd, level 0). The fade-out
// ramp is the FadeOutStart->LengthEnd segment; LengthEnd is the playEnd terminal.
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(sustain) -> ReleaseStart -> ReleaseEnd.
// Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd).
// Shared by envelope_overlay (forward/draw map) and envelope_edit (inverse/edit map).
enum class EnvNode {
Origin, // t=0, level 0 (both modes) — not draggable (fixed anchor)
AttackEnd, // Gate: top of the attack ramp (level 1) — X sets attackSeconds
HoldEnd, // Gate: end of the hold plateau (level 1) — X sets holdSeconds
DecayEnd, // Gate: decay settles to sustain — the SUSTAIN node (X sets decaySeconds,
// Y sets sustainLevel)
ReleaseStart, // Gate: end of the sustain plateau / start of the release (sustain level) —
// a DRAWING vertex only, not a draggable handle (release is edited at
// ReleaseEnd; this vertex sits a fixed sustain-plateau width right of
// DecayEnd — the schematic note-off — Y = sustain level)
ReleaseEnd, // Gate: end of the release tail (level 0) — X sets releaseSeconds
FadeInEnd, // Trigger: top of the fade-in (level 1) — X sets fadeInFraction
FadeOutStart, // Trigger: end of the unity plateau / start of the fade-out (level 1) —
// X sets fadeOutFraction
LengthEnd, // Trigger: the playEnd terminal / %-length (level 0) — X sets lengthFraction
Origin, // t=0, level 0 — not draggable
AttackEnd, // Gate: attack ramp top — sets attackSeconds
HoldEnd, // Gate: hold plateau end — sets holdSeconds
DecayEnd, // Gate: decay settles to sustain — sets decaySeconds (X) and sustainLevel (Y)
ReleaseStart, // Gate: sustain plateau end — drawing-only, not draggable
ReleaseEnd, // Gate: release tail end — sets releaseSeconds
FadeInEnd, // Trigger: fade-in top — sets fadeInFraction
FadeOutStart, // Trigger: fade-out start — sets fadeOutFraction
LengthEnd, // Trigger: playEnd terminal — sets lengthFraction
};
// The amp-envelope params the overlay draws — the small view struct the shell packs from the
// zone's stored AdsrSeconds / TriggerParams. Engine-free by design (no sampler_core include).
//
// Gate fields (SECONDS, wall-clock): attack / hold / decay / release; sustain is a LEVEL 0..1.
// These map 1-to-1 with the stored AdsrSeconds fields — no conversion required.
//
// Trigger fields (FRACTIONS of play): fadeIn / fadeOut as a fraction of the played span;
// lengthFraction is the played span as a fraction of the
// post-start sample length (matching TriggerParams).
//
// TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this):
// TriggerParams (sampler_core.h) stores Trigger fades as SOURCE FRAMES:
// fadeInFrames (int64_t) — 0->1 ramp length in source frames
// fadeOutFrames (int64_t) — 1->0 ramp length in source frames
// AmpEnvelope stores them as FRACTIONS of the played span:
// fadeInFraction = fadeInFrames / playLengthFrames
// fadeOutFraction = fadeOutFrames / playLengthFrames
// where playLengthFrames = round(lengthFraction * (frameCount - startFrame)).
// This is a NON-TRIVIAL derived view — NOT a direct field copy. The shell owes a
// converter on BOTH directions:
// PACK (draw): frames -> fraction (TriggerParams -> AmpEnvelope, needs frameCount + rate)
// UNPACK (commit): fraction -> frames (AmpEnvelope -> TriggerParams, same inputs)
// lengthFraction maps 1-to-1 with TriggerParams::lengthFraction and needs no conversion.
//
// Unused fields for the active mode are ignored.
// Amp-envelope params the overlay draws. Trigger's fadeIn/fadeOutFraction are derived from
// TriggerParams' frame counts, not a direct field copy — see the trigger_seam gotcha in
// core/instrument/CLAUDE.md.
struct AmpEnvelope {
EnvMode mode = EnvMode::Gate;
// Gate (AHDSR), seconds + a dimensionless sustain level.
// Gate (AHDSR): seconds, plus a dimensionless sustain level.
double attackSeconds = 0.003;
double holdSeconds = 0.0;
double decaySeconds = 0.0;
double sustainLevel = 1.0;
double releaseSeconds = 0.060;
// Trigger, fractions of the play span (fadeIn/fadeOut) and of the post-start length.
// NOTE: fadeInFraction/fadeOutFraction are DERIVED from TriggerParams::fadeInFrames/
// fadeOutFrames — see the TRIGGER SEAM note above. A converter is owed on both the
// pack (draw) and unpack (commit) paths; these fields are NOT a direct TriggerParams copy.
double lengthFraction = 1.0; // (0,1] of the post-start span that plays (1-to-1 with TriggerParams)
double fadeInFraction = 0.0; // 0->1 ramp as a fraction of the played span (DERIVED — see above)
double fadeOutFraction = 0.0; // 1->0 ramp as a fraction of the played span (DERIVED — see above)
// Trigger: fractions of the played span.
double lengthFraction = 1.0;
double fadeInFraction = 0.0;
double fadeOutFraction = 0.0;
};
// One polyline vertex: a pixel point plus which node it is. The shell draws a line through the
// points in order (the amp curve) and a draggable handle at each vertex whose node is not Origin.
// Level is carried alongside (0..1) for callers that want to label/inspect; it is redundant with y.
// One polyline vertex: pixel point plus which node it is. level is redundant with y, carried for
// inspection.
struct EnvVertex {
EnvNode node = EnvNode::Origin;
int x = 0; // pixel x inside the overlay rect
int y = 0; // pixel y inside the overlay rect (top = level 1, bottom = level 0)
double level = 0.0; // 0..1, the vertex's amplitude (redundant with y; for inspection)
int x = 0;
int y = 0;
double level = 0.0;
bool operator==(const EnvVertex& o) const {
return node == o.node && x == o.x && y == o.y && level == o.level;
}
};
// The fraction of the canvas width RESERVED for the Gate sustain-plateau display (FA2). The
// plateau is a fixed-width schematic region between DecayEnd and ReleaseStart; the remaining
// width is the "timed" region A/H/D/R map onto at the schematic param-domain scale. One
// constant shared by the forward map (here) and the inverse map (envelope_edit).
// Fraction of canvas width reserved for the Gate sustain-plateau display; the remaining width
// carries A/H/D/R at the param-domain scale. Shared with envelope_edit.
inline constexpr double kGateSustainDisplayFraction = 0.15;
// The minimum pixel separation between consecutive Gate polyline nodes: every Gate segment gets
// this many px as a base, PLUS its time-proportional extent, so zero-duration stages (tier-0
// defaults: hold 0, decay 0) still render as distinct, individually grabbable handles. Chosen
// larger than envelope_edit's kNodeGrabRadius (6) so a click dead-on a node can never tie with
// its neighbour. Shared by the forward map and the drag inverse.
// Minimum pixel separation between consecutive Gate nodes, so zero-duration stages (tier-0
// defaults) still render as distinct, grabbable handles. Larger than envelope_edit's grab
// radius (6) so a click can never tie between neighbours.
inline constexpr int kGateNodeSepPx = 8;
// The Gate schematic's per-stage time domain (seconds): the timed region represents the four
// stages end-to-end at this maximum each (4 x this total). MIRRORS the shell's stage-slider
// ceiling (kEnvTimeMaxSeconds in reasampler_editor.cpp) — keep the two equal so a stage at its
// slider max lands exactly at the canvas edge. Drag safety does NOT depend on this constant
// (param clamps are caller-supplied in envelope_edit); only layout does.
// Gate schematic's per-stage time domain (seconds) the timed region represents four stages
// end-to-end at this max each. Must match the shell's stage-slider ceiling so a maxed slider
// lands exactly at the canvas edge.
inline constexpr double kGateStageMaxSeconds = 2.0;
// 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.
// Pixel width of the Gate timed region (area width minus the sustain reserve), floored at 1 for
// a non-empty area; 0 for a zero/negative-width area.
int gateTimedWidth(const Rect& area);
// Pixels per second of the Gate timed region under the PARAM-DOMAIN scale: the timed width,
// minus the four per-segment kGateNodeSepPx bases and the last in-bounds column, spread over
// 4 x kGateStageMaxSeconds. Independent of the sample's duration. Returns 0 for a
// zero/negative-width area; otherwise > 0 (the usable width floors at 1 px). The ONE px<->sec
// scale shared by the forward map (gatePolyline) and the drag inverse (envelope_edit), so a
// dragged handle tracks the cursor 1:1.
// Pixels per second of the Gate timed region, independent of the sample's actual duration.
// Shared by buildEnvelopePolyline and envelope_edit's drag inverse so a dragged handle tracks
// the cursor 1:1.
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.y] (level 1 at the TOP). The polyline reads
// left-to-right in draw order, Origin first.
// Maps an amp envelope to polyline vertices inside `area` over a sample of `totalSeconds`
// duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); vertices
// are in draw order, Origin first.
//
// TIME BASE (FA2).
// * Gate: a bounded schematic, INDEPENDENT of totalSeconds. The canvas splits into a TIMED
// region of gateTimedWidth(area) px — where attack/hold/decay run from t=0 and the release
// ramp runs after the plateau, at the gatePxPerSecond(area) PARAM-DOMAIN scale, each segment
// carrying a kGateNodeSepPx base so consecutive nodes never coincide — plus a FIXED sustain
// plateau of (width - timedWidth) px between DecayEnd and ReleaseStart (the schematic
// note-off). Stages beyond the schematic domain (a stored stage > kGateStageMaxSeconds)
// compress from the RIGHT preserving the minimum gaps, so trailing nodes stay individually
// separated instead of piling on the last column; only a canvas too narrow to hold the
// minimum gaps at all sacrifices separation (in-bounds wins).
// * Trigger: the waveform's exact time base (PCM-aligned). The played span is
// 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.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.
// Gate's x-axis is a bounded schematic independent of totalSeconds (does NOT line up with the
// waveform under it); Trigger's x-axis is PCM-aligned wall-clock. Every vertex is clamped inside
// the canvas: x in [area.x, area.right()-1], y in [area.y, area.bottom()-1]. A degenerate area
// or totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0].
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds);
// 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.
// Maps a time (seconds) to a pixel x inside `area`, linear and clamped at both ends. Shared
// with envelope_edit's node hit-test 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.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.y. Pure — the shared level->y map the polyline
// and the node hit-test share.
// Maps a level [0,1] to a pixel y inside `area` (level 1 at the top, 0 at the bottom row),
// clamped. Shared with envelope_edit's node hit-test.
int levelToY(const Rect& area, double level);
} // namespace reasampler::instrument::ui
+8 -22
View File
@@ -14,10 +14,8 @@ int clampNote(int n) {
return n;
}
// Map a key BOUNDARY in [0, kStripKeyCount] to an x pixel inside a band of the given
// left/width. keyEdge is a boundary (0..128): 0 -> band left, 128 -> band right. Integer
// math, floored — key N's left is keyEdgeToX(N) and its right is keyEdgeToX(N+1), tiling
// adjacent keys/zones without a seam (mirror of embed_strip::keyEdgeToX).
// Maps a key boundary (0..128) to an x pixel. Key N's left is keyEdgeToX(N), right is
// keyEdgeToX(N+1) — tiles adjacent keys/zones without a seam. Mirrors embed_strip::keyEdgeToX.
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
if (keyEdge <= 0) return bandLeft;
if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth;
@@ -37,8 +35,7 @@ StripLayout layoutStrip(int w, int h) {
int keyLeftX(const StripLayout& layout, int note) {
const Rect& band = layout.keys;
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.
// note is a key (0..127); callers pass note+1 to get its right edge, 128 -> band right.
const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note);
return keyEdgeToX(band.x, bandWidth, edge);
}
@@ -59,8 +56,7 @@ int keyAtPoint(const StripLayout& layout, int x, int y) {
if (!contains(band, x, y)) return -1;
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).
// Inverts keyEdgeToX: the key whose half-open [leftX, rightX) contains x.
const int offset = x - band.x;
int note = (offset * kStripKeyCount) / bandWidth;
return clampNote(note);
@@ -69,7 +65,7 @@ int keyAtPoint(const StripLayout& layout, int x, int y) {
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) {
int lo = clampNote(lowNote);
int hi = clampNote(highNote);
if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts
if (lo > hi) lo = hi; // malformed zone collapses rather than inverts
const int leftX = keyLeftX(layout, lo);
const int rightX = keyLeftX(layout, hi + 1);
return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom());
@@ -80,8 +76,7 @@ ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x,
if (!contains(bar, x, y)) return ZoneGrab::kNone;
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).
// A narrow bar has no body: split at the midpoint, low edge wins the tie.
if (barW < 2 * kStripEdgeGrabWidth) {
const int mid = bar.x + barW / 2;
return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge;
@@ -103,11 +98,7 @@ ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int*
}
bool isNaturalKey(int note) {
// Clamp to the valid MIDI range before indexing.
const int n = note < 0 ? 0 : (note > kStripKeyCount - 1 ? kStripKeyCount - 1 : note);
// The 12-semitone pattern of natural (white) keys within an octave, starting at C:
// positions 0(C) 2(D) 4(E) 5(F) 7(G) 9(A) 11(B) are natural;
// positions 1(C#) 3(D#) 6(F#) 8(G#) 10(A#) are accidental.
static constexpr bool kNatural[12] = {
true, // 0 C
false, // 1 C#
@@ -129,13 +120,8 @@ int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) {
if (dxPixels == 0) return clampNote(startNote);
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
// key width is (bandWidth / kStripKeyCount) in exact rational arithmetic; rounding to
// the nearest key (half-key drag flips at the key centre) is achieved by adding
// bandWidth/2 to the absolute pixel delta before dividing — identical to the old
// formula except keyWidth is now derived from the same linear map (exact rational)
// rather than the truncated-integer bandWidth/128 that caused drift at the far end.
// Same linear mapping as keyAtPoint/keyEdgeToX (exact rational), not a truncated-integer
// bandWidth/128 key width — that drifted at the far end of the strip.
const int half = bandWidth / 2;
int shift;
if (dxPixels > 0) {
+41 -88
View File
@@ -1,106 +1,70 @@
// keyboard_strip.h — PURE layout + hit-test + drag math for the S10 capture-first
// editor's keyboard strip. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
// The mirror of editor_geometry / embed_strip / mode_switch: the fiddly rectangle +
// note-mapping arithmetic lives here so it is unit-tested outside the DAW, while the
// editor shell (reasampler_editor.cpp) draws the strip and marshals mouse events into
// these functions.
// keyboard_strip.h — layout + hit-test + drag math for the capture-first editor's
// keyboard strip. Mirror of editor_geometry/embed_strip/mode_switch; the shell draws
// and marshals mouse events into these functions.
//
// The strip maps the full 128-key MIDI span across a horizontal band (the same key-span
// idiom embed_strip uses). It serves TWO faces of the S10 editor:
// * the SINGLE-CAPTURE fast path (default): one loaded capture with a ROOT MARKER on
// the strip, click-a-key (or drag the marker) sets the capture's root note; and
// * the opt-in ZONES panel (S10-Z, demoted): each performance zone drawn as a bar over
// the keys it covers, with edge-grab resize handles + a body move-handle so a drag
// sets low/high (edges) or moves the span (body), and a key-click sets the zone root.
//
// All interaction resolves through the pure DRAG-DELTA resolver here: the shell captures
// a grab on WM_LBUTTONDOWN, feeds each WM_MOUSEMOVE's pixel delta back through
// resolveDragNote, and commits the resolved note(s) on WM_LBUTTONUP. Live feedback is the
// shell re-drawing the in-flight note; one coherent edit lands on release.
//
// 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.
// The strip maps the full 128-key MIDI span across a horizontal band (the same idiom
// embed_strip uses) and serves two faces: the single-capture fast path (a root marker,
// click-a-key or drag it to set root) and the opt-in zones panel (each zone drawn as a
// bar with edge-grab resize handles + a body move-handle).
#pragma once
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
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
// independent — the editor strip may grow octave labels/metrics the embed strip never does.
// Named distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips
// stay independent.
inline constexpr int kStripKeyCount = 128;
// The width (px) of an edge-grab hit region at each end of a zone bar: a drag started
// within this many pixels of the bar's left/right edge resizes that edge; a drag started
// anywhere else on the bar moves the whole span. A zone narrower than 2*this has no body
// move-handle (both edges win their halves) — deliberate: a 1-key zone is all edges.
// Pixel width of a zone bar's edge-grab region. A zone narrower than 2x this has no
// body move-handle (both edges win their halves).
inline constexpr int kStripEdgeGrabWidth = 6;
// The strip's regions, derived from the (w x h) band the shell allots it. The keys band
// 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.
// The keys band takes the whole strip area today; clamped so a degenerate size never
// yields an inverted rect.
struct StripLayout {
Rect keys; // the key band: the 128-key span maps linearly across keys.width
Rect keys;
};
// Divide a (w x h) strip area into its regions. Pure: same inputs -> same layout. A zero or
// negative size yields empty rects (no inversion).
// Divide a (w x h) strip area into its regions. Pure.
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
// [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.
// x pixel of the LEFT edge of key `note` (0..127) under the linear 128-key map; key N
// occupies [keyLeftX(N), keyLeftX(N+1)). note==128 maps to the band's right edge.
int keyLeftX(const StripLayout& layout, int note);
// The half-open pixel rect of a single key `note` (0..127): [keyLeftX(note),
// keyLeftX(note+1)) horizontally, the full keys-band height. A malformed (out-of-range)
// note clamps to [0,127]. Pure.
// Half-open rect of a single key `note`, clamped to [0,127].
Rect keyRect(const StripLayout& layout, int note);
// The rect of the ROOT MARKER for the single-capture fast path: the key cell of `rootNote`,
// drawn as a highlighted key. Equivalent to keyRect(layout, rootNote) — a named entry point
// so the shell's intent (this is the root marker, not just any key) reads at the call site,
// and so a future marker shape (a triangle over the key) has one place to change. Pure.
// Root-marker rect for the single-capture fast path; equivalent to
// keyRect(layout, rootNote) but named so the intent reads at the call site.
Rect rootMarkerRect(const StripLayout& layout, int rootNote);
// The MIDI note a point (x, y) lands on, or -1 for a point outside the keys band. Backs
// click-to-set-root (single capture) and click-a-key-sets-zone-root (zones). Pure.
// MIDI note a point (x, y) lands on, or -1 outside the keys band.
int keyAtPoint(const StripLayout& layout, int x, int y);
// The horizontal sub-rect of the keys band for a zone spanning [lowNote, highNote]
// (inclusive): [keyLeftX(low), keyLeftX(high+1)) horizontally, the full band height. Notes
// clamp to [0,127] and low clamps to <= high, so a malformed zone yields an in-band
// (possibly zero-width) rect, never an inverted one. Mirrors embed_strip::zoneSegmentRect.
// Pure.
// Horizontal sub-rect for a zone spanning [lowNote, highNote] inclusive. Notes clamp to
// [0,127] and low clamps to <= high, so a malformed zone never yields an inverted rect.
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote);
// Which part of a zone bar a grab landed on. The shell uses this to decide what a drag
// edits: an edge resizes that boundary; the body moves the whole span; none means the grab
// missed the bar entirely (the shell may treat that as a key-click to set the root, or as a
// deselect).
// Which part of a zone bar a grab landed on: an edge resizes that boundary, the body
// moves the whole span, kNone means the grab missed the bar.
enum class ZoneGrab {
kNone, // the point is not on this zone's bar
kLowEdge, // within kStripEdgeGrabWidth of the bar's LEFT edge -> resize low
kHighEdge, // within kStripEdgeGrabWidth of the bar's RIGHT edge -> resize high
kBody, // on the bar but not an edge -> move the whole span
kNone,
kLowEdge,
kHighEdge,
kBody,
};
// Classify a grab at (x, y) against ONE zone's bar (low..high). Returns kNone when the
// point is off the bar (or off the keys band). On the bar: kLowEdge/kHighEdge when within
// kStripEdgeGrabWidth of that edge, else kBody. A narrow bar (< 2*kStripEdgeGrabWidth)
// resolves the near half to each edge (no body). The LOW edge wins a tie at the exact
// midpoint of a narrow bar (deterministic). Pure.
// Classify a grab at (x, y) against one zone's bar. A narrow bar (< 2*kStripEdgeGrabWidth)
// resolves the near half to each edge (no body); the low edge wins a tie at the exact
// midpoint.
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y);
// The zone (index into `lows`/`highs`, draw order) whose bar a grab at (x, y) lands on,
// plus which part of it, or {-1, kNone} for a point off every bar. First covering zone in
// draw order wins (first-match, mirroring the core's Keymap::resolve + embed_strip). The
// arrays are parallel (lows[i]/highs[i] is zone i's inclusive range); `count` is their
// length. Pure — no host containers at the boundary (a raw pointer pair, like
// embed_strip::zoneAtPoint).
// Zone (index into the parallel `lows`/`highs` arrays, draw order) whose bar a grab
// lands on, plus which part, or {-1, kNone} for a miss. First covering zone in draw
// order wins.
struct ZoneBarHit {
int zoneIndex = -1;
ZoneGrab grab = ZoneGrab::kNone;
@@ -108,24 +72,13 @@ struct ZoneBarHit {
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
int count, int x, int y);
// Resolve a drag to a new MIDI note. Given the note the grabbed field held at grab time
// (`startNote`) and the horizontal pixel delta since grab (`dxPixels`), returns the note
// the field should now hold: startNote shifted by round(dxPixels / keyWidth), clamped to
// [0,127]. keyWidth is derived from the layout (band width / 128); a zero-width band pins
// the result to startNote (no motion). This is the single arithmetic behind edge-resize,
// body-move (apply to both edges with the SAME delta so the span is preserved), and
// root-marker drag. Pure — rounding is to the nearest key so a half-key drag flips at the
// key centre. Returns startNote unchanged for dxPixels==0.
// Resolves a drag to a new MIDI note: `startNote` shifted by round(dxPixels / keyWidth),
// clamped to [0,127]. The one arithmetic behind edge-resize, body-move (apply to both
// edges with the same delta to preserve span), and root-marker drag.
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels);
// Returns true when `note` (0..127) is a NATURAL (white) key in standard 12-tone equal
// temperament; false when it is an ACCIDENTAL (black) key. Notes out of the [0,127]
// range are clamped to [0,127] before classification (i.e. this never throws/UBs on a
// bad input). The 12 semitone positions within an octave:
// Natural (white): 0(C) 2(D) 4(E) 5(F) 7(G) 9(A) 11(B)
// Accidental (black): 1(C#) 3(D#) 6(F#) 8(G#) 10(A#)
// Used by the shell to overlay the two-tone bright/dark piano-key pattern over the
// pastel spectral fill (S-VIEW-7). Pure — no layout required, no host types.
// True when `note` (clamped to [0,127]) is a natural (white) key in 12-tone equal
// temperament; false for an accidental (black) key.
bool isNaturalKey(int note);
} // namespace reasampler::instrument::ui
+1 -1
View File
@@ -37,7 +37,7 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
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).
// Caption row: text left, compact toggle right-anchored.
out.caption = Rect::ltrb(innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH);
if (g.captionToggle.id >= 0) {
const int segW = g.captionToggle.segWidth;
+27 -39
View File
@@ -1,27 +1,18 @@
// knob_deck.h — PURE knob-deck layout + hit-test for the r11 Sample-face recomposition
// (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary, and — like
// param_slider — NO engine types: cells and toggles carry opaque shell-owned control ids.
// The mirror of action_bar / param_slider: the fiddly group-box / caption-row / cell-grid
// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws each
// group (fence, caption, compact toggles, knobs) through the L1 kit and routes clicks/drags
// via the hit-test. The KNOB PRIMITIVE itself (value<->needle-angle, vertical drag) is
// param_slider's (FA4); a knob cell here is just a rect — the shell composes the two.
// knob_deck.h — knob-deck layout + hit-test for the Sample-face knob deck. Engine-free
// like param_slider: cells and toggles carry opaque shell-owned control ids. Mirror of
// action_bar/param_slider; the knob primitive itself (value<->needle-angle, drag) is
// param_slider's — a knob cell here is just a rect the shell composes it into.
//
// THE DECK (CONTEXT.md §S-VIEW r11). A horizontal run of FENCED GROUPS, left -> right, each
// a hairline-bordered bg/panel box with a CAPTION ROW (micro-caps caption left; the group's
// compact mode toggle right-anchored IN the caption row — this is where the not-full-width
// toggles live) over a KNOB ROW of fixed 48x58 cells (28px knob centered, 12px label band
// beneath). A group may additionally place one 18px-tall two-segment toggle IN the knob row
// after its cells (the VOICE group's Retrig|Legato — same Mono/Stereo segment grammar,
// vertically centered). Groups that must keep stable geometry across a mode flip reserve
// blank cells (id -1): the AMP ENVELOPE group always spans 5 cells so Gate<->Trigger never
// reflows its neighbours.
// The deck is a horizontal run of fenced groups, left->right, each a bordered box with a
// caption row (caption left, the group's compact mode toggle right-anchored) over a knob
// row of fixed cells (knob centered, label band beneath). A group may also place one
// two-segment toggle in the knob row after its cells. Groups that must keep stable
// geometry across a mode flip reserve blank cells (id -1) so a mode flip never reflows
// neighbouring groups.
//
// WRAP (deterministic): groups place left-to-right with kDeckGroupGap between; a group that
// does not fit the remaining width starts a new deck row (whole groups only, never split).
// The first group of a row always places even if wider than the row (degenerate width).
// deckHeight() exposes the resulting height so the shell can bottom-anchor the deck band and
// give the ELASTIC HERO the rest (r11 band order).
// Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group
// that does not fit the remaining width starts a new row (whole groups only, never
// split); the first group of a row always places even if wider than the row.
#pragma once
@@ -31,7 +22,7 @@
namespace reasampler::instrument::ui {
// Fixed deck metrics (spec r11), exposed so the shell and tests agree.
// Fixed deck metrics, exposed so the shell and tests agree.
inline constexpr int kDeckCellW = 48; // one knob cell
inline constexpr int kDeckCellH = 58;
inline constexpr int kDeckKnobSize = 28; // knob diameter inside the cell
@@ -55,9 +46,8 @@ struct DeckToggleDesc {
};
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
// is a RESERVED BLANK cell (geometry held, never hit — the AMP ENVELOPE Trigger face).
// `captionWidth` is the px the shell reserves for the caption text (this module does not
// measure text — the house constant-metrics pattern).
// is a reserved blank cell (geometry held, never hit). `captionWidth` is the px the shell
// reserves for the caption text (this module does not measure text).
struct DeckGroupDesc {
int id = 0; // shell group id (opaque here)
int captionWidth = 60;
@@ -96,21 +86,20 @@ struct DeckLayout {
int height = 0; // rowCount * kDeckGroupH + (rowCount-1) * kDeckRowGap; 0 for no groups
};
// The width of one group box: the wider of its caption row (caption + gap + toggle) and its
// knob row (cells + gap + row toggle), plus the horizontal padding. Pure.
// Width of one group box: the wider of its caption row (caption + gap + toggle) and its
// knob row (cells + gap + row toggle), plus horizontal padding.
int deckGroupWidth(const DeckGroupDesc& g);
// The number of deck rows the groups occupy at `availWidth` under the greedy whole-group
// wrap (a group that does not fit the remaining row width starts a new row; the first group
// of a row always places). 0 for an empty group list. Pure — the wrap is deterministic.
// Number of deck rows the groups occupy at `availWidth` under the greedy whole-group wrap.
// 0 for an empty list.
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth);
// The total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). 0 for an
// empty list. The shell bottom-anchors a band of exactly this height. Pure.
// Total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). The shell
// bottom-anchors a band of exactly this height.
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth);
// Lay the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's rule.
// Every rect is absolute. Pure — same inputs, same layout.
// Lays the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's
// rule. Every rect is absolute.
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
int availWidth);
@@ -124,10 +113,9 @@ struct DeckHit {
int segment = -1; // 0/1 for a toggle hit; -1 otherwise
};
// The deck element a point lands on: a knob CELL (the whole 48x58 cell — friendlier than the
// bare knob circle; the shell anchors the vertical drag wherever the grab lands), a caption-
// toggle segment, or a row-toggle segment. Blank cells (id -1) and everything else miss.
// Pure — the shell's routing entry point.
// The deck element a point lands on: a knob cell (the whole cell, not just the knob
// circle the shell anchors the vertical drag wherever the grab lands), a caption-toggle
// segment, or a row-toggle segment. Blank cells (id -1) and everything else miss.
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
} // namespace reasampler::instrument::ui
+3 -4
View File
@@ -1,5 +1,4 @@
// 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().
// param_slider.cpp — see param_slider.h. Pure control-surface geometry; no host types.
#include "core/instrument/ui/param_slider.h"
@@ -10,7 +9,7 @@
namespace reasampler::instrument::ui {
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
using util::clamp01;
std::vector<ControlRow> layoutControls(const Rect& panel,
const std::vector<ControlDesc>& controls) {
@@ -83,7 +82,7 @@ double valueAtPoint(const Rect& control, int x) {
return static_cast<double>(x - track.x) / static_cast<double>(span);
}
// --- Radial knob (Wave A FA4) ---------------------------------------------------------
// --- Radial knob -----------------------------------------------------------------------
namespace {
+68 -107
View File
@@ -1,180 +1,141 @@
// param_slider.h — PURE control-surface layout + hit-test + value<->pixel mapping for the
// S12/S15/S16 editor parameter panel. NO VST3, NO REAPER, NO SWELL/LICE types at the
// boundary, and — deliberately — NO sampler_core / sample_map engine types either. The
// mirror of keyboard_strip / waveform_view / mode_switch: the fiddly slider-track and
// toggle-segment arithmetic lives here, unit-tested outside the DAW, while the editor shell
// draws each row (label + track/segments + handle) and routes clicks/drags into these
// functions, owning the control-id -> engine-param binding + the value DOMAIN mapping.
// param_slider.h — control-surface layout + hit-test + value<->pixel mapping for the
// editor parameter panel. Engine-free by design (no sampler_core/sample_map). Mirror of
// keyboard_strip/waveform_view/mode_switch; the shell draws each row and routes
// clicks/drags into these functions, owning the control-id -> engine-param binding and
// the value domain mapping.
//
// WHY IT EXISTS (S12 + the S15/S16 control surfaces deferred here). The setup / Zones surface
// grows a stack of parameter controls: the S15 play-mode toggle (Gate|Trigger), the AHDSR
// amp-envelope sliders (attack/hold/decay/sustain/release), the Trigger %-length + fade
// controls, the S16 Varispeed|Preserve engine toggle, and the AD pitch-envelope
// enable/attack/decay/depth. They are three shapes — a two-segment TOGGLE, a horizontal
// SLIDER, and (Wave A FA4) a radial KNOB with a needle indicator and vertical-drag value
// mapping — laid out as a vertical stack of fixed-height rows. This module lays out that
// stack and maps a control's NORMALIZED value (0..1) to/from its handle pixel / needle
// angle; the shell converts each control's engine value (frames, seconds, a fraction, a
// signed semitone depth) to/from that 0..1 with its own domain knowledge (this module stays
// engine-free so it tests without the audio core).
//
// It reuses editor_geometry's Rect + contains() (one shared geometry idiom).
// Controls are one of three shapes — a two-segment Toggle, a horizontal Slider, or a
// radial Knob with a needle and vertical-drag mapping — laid out as a vertical stack of
// fixed-height rows. This module maps a control's normalized value (0..1) to/from its
// handle pixel / needle angle; the shell converts each control's engine value (frames,
// seconds, a fraction, a signed semitone depth) to/from that 0..1.
#pragma once
#include <vector>
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
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)
inline constexpr int kControlRowGap = 4; // vertical gap below each row
inline constexpr int kControlLabelWidth = 92; // the label column at the row's left
inline constexpr int kSliderHandleWidth = 8; // the draggable slider handle width (px)
inline constexpr int kToggleSegments = 2; // a toggle is always two segments
inline constexpr int kControlRowHeight = 22;
inline constexpr int kControlRowGap = 4;
inline constexpr int kControlLabelWidth = 92;
inline constexpr int kSliderHandleWidth = 8;
inline constexpr int kToggleSegments = 2;
// A control is one of three shapes. Toggle = a two-segment selector (the active segment
// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value;
// Knob = a radial dial with a needle indicator over a 0..1 value, dragged VERTICALLY
// (up = increase).
// Toggle = two-segment selector (active segment highlights); Slider = horizontal track
// with a draggable handle over a 0..1 value; Knob = radial dial with a needle, dragged
// vertically (up = increase).
enum class ControlKind { Toggle, Slider, Knob };
// One control the shell places in the panel, in stack order. `id` is the shell's own control
// identifier (an int the shell casts from its ControlId enum) returned by the hit-test so the
// shell routes the interaction to the right engine param — this module never interprets it.
// One control the shell places in the panel, in stack order. `id` is the shell's own
// control identifier, returned by the hit-test so the shell routes to the right engine
// param — this module never interprets it.
struct ControlDesc {
int id = 0;
ControlKind kind = ControlKind::Slider;
};
// The laid-out geometry of one control row: its full row rect plus the interactive sub-rect
// (the track for a Slider, the whole control area for a Toggle — the shell splits a Toggle
// into segments via toggleSegmentRect). `index` is the control's position in the stack.
// Laid-out geometry of one control row: full row rect plus the interactive sub-rect (the
// track for a Slider, the whole control area for a Toggle — the shell splits a Toggle
// into segments via toggleSegmentRect).
struct ControlRow {
int id = 0;
ControlKind kind = ControlKind::Slider;
Rect row; // the full row (label column + control column)
Rect label; // the label column at the left
Rect control; // the control column to the right of the label (track / toggle area)
Rect row;
Rect label;
Rect control;
};
// Lay out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down. Each
// row is kControlRowHeight tall with kControlRowGap below it; the label column takes the left
// kControlLabelWidth (clamped so it never exceeds the panel), the control column the rest. A
// row whose top falls past the panel bottom is still returned (the shell clips at paint /
// suppresses it) so the stack geometry is deterministic regardless of panel height. An empty
// control list or a degenerate panel yields an empty vector. Pure.
// Lays out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down.
// Label column takes the left kControlLabelWidth (clamped to the panel), control column
// the rest. A row past the panel bottom is still returned (the shell clips/suppresses it)
// so stack geometry is deterministic regardless of panel height.
std::vector<ControlRow> layoutControls(const Rect& panel,
const std::vector<ControlDesc>& controls);
// The rect of segment `seg` (0..kToggleSegments-1) within a toggle control's `control` rect,
// splitting it into kToggleSegments equal segments left-to-right (the last absorbs any width
// remainder, mirror of mode_switch's segment split). An out-of-range segment or a degenerate
// control rect yields an empty rect. Pure.
// Rect of segment `seg` within a toggle's `control` rect, splitting it into
// kToggleSegments equal segments left-to-right (last absorbs any width remainder).
Rect toggleSegmentRect(const Rect& control, int seg);
// The toggle segment a point lands on within a toggle control's `control` rect, or -1 for a
// miss (outside the control area). Pure.
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.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.
// Slider track sub-rect inside `control`: inset so the handle stays fully within the
// control at value 0 and 1. The handle center ranges across [track.x, track.right()] as
// the value ranges [0,1].
Rect sliderTrackRect(const Rect& control);
// The handle rect for a slider at normalized `value` (clamped to [0,1]) within `control`: a
// kSliderHandleWidth-wide bar centered at the value's position along sliderTrackRect. A
// degenerate control yields an empty rect. Pure — the inverse of valueAtPoint.
// Handle rect for a slider at normalized `value` (clamped to [0,1]).
Rect sliderHandleRect(const Rect& control, double value);
// Map a point x to a normalized slider value [0,1] within `control` (the handle-center range).
// x at/left of the track start -> 0; at/right of the end -> 1; linear between. A degenerate
// track (zero movable span) -> 0. Pure — the inverse of sliderHandleRect's position map; the
// shell converts the returned 0..1 into its engine domain (frames/seconds/fraction/semitones).
// Maps a point x to a normalized slider value [0,1]: at/left of track start -> 0, at/right
// of end -> 1, linear between. Inverse of sliderHandleRect's position map.
double valueAtPoint(const Rect& control, int x);
// --- Radial knob (Wave A FA4) --------------------------------------------------------------
// --- Radial knob ---------------------------------------------------------------------
//
// Angle convention: DEGREES CLOCKWISE FROM 12 O'CLOCK, matching a clock face in screen
// coordinates (y grows downward): 0 = 12 o'clock (up), 90 = 3 o'clock (right), 180 = 6
// o'clock (down), 270 = 9 o'clock (left). The value arc sweeps CLOCKWISE from startDeg
// (value 0) to endDeg (value 1); an endDeg at-or-behind startDeg wraps +360, so equal
// angles mean a full 360° sweep.
// Angle convention: degrees clockwise from 12 o'clock (screen coords, y grows downward).
// The value arc sweeps clockwise from startDeg (value 0) to endDeg (value 1); an endDeg
// at-or-behind startDeg wraps +360.
//
// The DEFAULT arc is the conventional 7→5 o'clock layout: min at 7 o'clock (210°) sweeping
// clockwise 300° around to max at 5 o'clock (150°), leaving a symmetric 60° dead arc at the
// bottom. The 50% (midpoint) value lands at 12 o'clock (0°/360°) — straight up. The angles
// are PARAMETERS, not hardcoded — the shell sets the final sweep when the parallel layout
// spec lands.
inline constexpr double kKnobArcStartDeg = 210.0; // value 0 — 7 o'clock
inline constexpr double kKnobArcEndDeg = 150.0; // value 1 — 5 o'clock (clockwise wrap)
// Default arc: 7 o'clock (210°) sweeping clockwise 300° to 5 o'clock (150°), leaving a
// symmetric 60° dead arc at the bottom; the 50% value lands at 12 o'clock. Angles are
// parameters, not hardcoded.
inline constexpr double kKnobArcStartDeg = 210.0;
inline constexpr double kKnobArcEndDeg = 150.0;
// Default vertical-drag sensitivity: pixels of upward drag for one full 0->1 sweep.
// Pixels of upward drag for one full 0->1 sweep.
inline constexpr int kKnobDragRangePixels = 128;
// The configurable value arc of a knob. Defaults to the 7->5 o'clock reading above.
struct KnobArc {
double startDeg = kKnobArcStartDeg;
double endDeg = kKnobArcEndDeg;
};
// A knob's circle within its control cell: center + radius in pixel space (doubles so the
// shell rounds once, at draw time). radius == 0 marks a degenerate cell.
// A knob's circle within its control cell: center + radius (doubles so the shell rounds
// once, at draw time). radius == 0 marks a degenerate cell.
struct KnobGeometry {
double centerX = 0.0;
double centerY = 0.0;
double radius = 0.0;
};
// A pixel-space point (the needle endpoint the shell draws to).
struct KnobPoint {
double x = 0.0;
double y = 0.0;
};
// The knob circle inscribed in `cell`, centered, radius = half the smaller dimension. A
// degenerate cell yields radius 0. CONTRACT: the shell MUST pass `row.control` (the full
// control column) both when drawing and when hit-testing — `controlAtPoint` always uses
// `r.control` as the cell, so the draw cell and hit cell must be the same. If the shell
// wants to draw a smaller circle it must center it within `row.control` and accept that the
// hit area is the larger column-inscribed circle. Pure.
// Knob circle inscribed in `cell`, centered, radius = half the smaller dimension. The
// shell must pass `row.control` both when drawing and hit-testing — controlAtPoint always
// uses `r.control` as the cell, so draw cell and hit cell must agree.
KnobGeometry computeKnob(const Rect& cell);
// True if (x, y) falls strictly inside the knob circle (boundary exclusive, matching the
// module's half-open Rect convention). A degenerate knob (radius <= 0) hits nothing. Pure.
// True if (x, y) falls strictly inside the knob circle (boundary exclusive).
bool knobHitTest(const KnobGeometry& knob, int x, int y);
// The clockwise sweep of `arc` in degrees, in (0, 360]: normalized end - start, wrapping
// +360 when the end is at-or-behind the start (default arc -> 300). Pure.
// Clockwise sweep of `arc` in degrees, in (0, 360]: normalized end - start, wrapping +360
// when the end is at-or-behind the start (default arc -> 300).
double knobSweepDeg(const KnobArc& arc);
// The needle angle for normalized `value` (clamped to [0,1]): startDeg at 0, endDeg at 1,
// linear between, returned normalized to [0, 360). Pure.
// Needle angle for normalized `value` (clamped to [0,1]): startDeg at 0, endDeg at 1,
// linear between, normalized to [0, 360).
double knobValueAngleDeg(const KnobArc& arc, double value);
// The needle endpoint for normalized `value`: the point on the knob circle at the value's
// angle, from the center. The shell draws the needle from (centerX, centerY) to this point
// (or lerps toward the center for a shorter needle). Pure.
// Needle endpoint for normalized `value`: the point on the knob circle at the value's
// angle, from the center.
KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double value);
// Map a vertical drag onto a knob value: `startValue` is the value at drag start (clamped),
// `dyPixels` the pointer's y displacement in screen coordinates (down = positive). Dragging
// UP increases, DOWN decreases; `dragRangePixels` pixels of travel covers the full 0..1
// range. Result clamps to [0,1]; a non-positive drag range yields the clamped start value.
// Pure — the inverse map for the knob's drag interaction.
// Maps a vertical drag onto a knob value: `startValue` is the value at drag start,
// `dyPixels` the pointer's y displacement (down = positive). Up increases, down
// decreases; `dragRangePixels` pixels of travel covers the full 0..1 range.
double knobDragValue(double startValue, int dyPixels,
int dragRangePixels = kKnobDragRangePixels);
// The control a point lands on, given the laid-out `rows`. Returns the control id (ControlDesc
// id) whose interactive area (a Slider's track, a Toggle's whole control area, a Knob's
// circle) contains the point, or -1 for a miss (a gap, the label column, or outside every
// row). The FIRST matching row wins (rows never overlap, so at most one matches). Pure — the
// shell's routing entry point: on a hit it reads the value (valueAtPoint /
// toggleSegmentHitTest / knobDragValue over the ensuing drag) and commits.
// Control a point lands on, given laid-out `rows`. Returns the control id whose
// interactive area contains the point, or -1 for a miss. First matching row wins (rows
// never overlap).
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y);
} // namespace reasampler::instrument::ui
+2 -4
View File
@@ -21,8 +21,7 @@ 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.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).
// x = left + round(f * w / frameCount); multiply before divide to keep this exact.
const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2;
return area.x + static_cast<int>(num / frameCount);
}
@@ -33,8 +32,7 @@ std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x) {
if (x <= area.x) return 0;
if (x >= area.right()) return frameCount;
const std::int64_t dx = static_cast<std::int64_t>(x - area.x);
// Inverse of frameToX: frame = round(dx * frameCount / w). Round so click and marker draw
// agree at bin granularity.
// Inverse of frameToX: frame = round(dx * frameCount / w).
const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2;
return clampFrame(num / static_cast<std::int64_t>(w), frameCount);
}
+25 -57
View File
@@ -1,84 +1,52 @@
// waveform_view.h — PURE waveform/marker geometry + zero-crossing snap for the S11
// waveform surface. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
// of keyboard_strip / editor_geometry: the fiddly frame<->pixel + marker hit-test + snap
// arithmetic lives here, unit-tested outside the DAW, while the editor shell
// (reasampler_editor.cpp) draws the envelope + markers and marshals mouse events into it.
// waveform_view.h — waveform/marker geometry + zero-crossing snap. Mirror of keyboard_strip/
// editor_geometry: frame<->pixel + marker hit-test + snap arithmetic lives here, unit-tested
// outside the DAW; the shell draws and marshals mouse events into it.
//
// The surface maps a sample's full frame span [0, frameCount] linearly across a horizontal
// waveform rect. Draggable MARKERS mark frames of interest (S11: start point, loop start,
// loop end). The marker set is GENERIC — N named markers with drag + snap — deliberately
// not three hardcoded specials, so S15 (Trigger/Gate) can repurpose this same surface with a
// different marker set (start + %-length end + fades) without reworking the machinery.
//
// Interaction resolves through the pure DRAG-DELTA resolver here: the shell captures a grab
// on WM_LBUTTONDOWN (markerAtPoint identifies the grabbed marker), feeds each WM_MOUSEMOVE's
// pixel delta back through resolveDragFrame (which clamps + optionally zero-crossing-snaps),
// and commits on WM_LBUTTONUP. Live feedback is the shell re-drawing the in-flight frame.
//
// 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_codec do
// the same), so the zero-crossing helper takes the same mono PCM the shell already decoded.
// waveform rect. Markers are a generic N-named-marker set (not hardcoded specials), so a
// different mode (e.g. start + %-length end + fades) can repurpose the same machinery.
#pragma once
#include <cstdint>
#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
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
#include "core/audio/peaks.h" // AudioSample (float)
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
// idiom — wide enough to grab a 1px line comfortably, narrow enough that adjacent markers stay
// distinguishable.
// Pixel width of a marker's grab region either side of its x line. Mirrors keyboard_strip's
// edge-grab idiom.
inline constexpr int kMarkerGrabWidth = 5;
// 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.x (a degenerate, non-inverting result). Pure.
// x pixel of `frame` under the linear map: frame 0 -> area.x, frame frameCount -> area.right().
// Frame is clamped to [0, frameCount] before mapping. frameCount <= 0 or a zero-width area pins
// every frame to area.x.
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.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).
// Inverse of frameToX: the frame a point x maps to, clamped to [0, frameCount]. A point left of
// area.x yields 0; right of area.right() yields frameCount.
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x);
// Which marker (index into a caller-supplied parallel `frames` array, in draw order) a grab at
// (x, y) lands on, or -1 for a point off every marker (or off the waveform area). A marker is
// grabbed when x is within kMarkerGrabWidth of its drawn x AND y is inside `area`. First marker
// in order wins a tie where two markers overlap within the grab band (deterministic, mirroring
// keyboard_strip's first-match). `frames` is `count` frame indices; a null/empty array or
// count <= 0 yields -1. Pure — a raw pointer at the boundary (no host container), like
// keyboard_strip::zoneBarAtPoint.
// Which marker (index into the caller's parallel `frames` array, in draw order) a grab at
// (x, y) lands on, or -1 for a miss. A marker is grabbed when x is within kMarkerGrabWidth of
// its drawn x and y is inside `area`. First marker in draw order wins an overlapping tie.
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
int count, int x, int y);
// Resolve a drag to a new frame. Given the frame the grabbed marker held at grab time
// (`startFrame`) and the horizontal pixel delta since grab (`dxPixels`), returns the frame the
// marker should now hold: startFrame shifted by round(dxPixels * frameCount / areaWidth),
// clamped to [0, frameCount]. A zero-width area or non-positive frameCount pins the result to
// the clamped startFrame (no motion). This is the single arithmetic behind every marker drag;
// the shell applies clamps BETWEEN markers (start <= loopEnd, loopStart <= loopEnd) after this
// per-marker resolve. Pure — rounding is to the nearest frame. Returns the clamped startFrame
// for dxPixels == 0.
// Resolves a drag to a new frame: `startFrame` shifted by round(dxPixels * frameCount /
// areaWidth), clamped to [0, frameCount]. The shell applies between-marker clamps (e.g.
// start <= loopEnd) after this per-marker resolve.
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
int dxPixels);
// The nearest zero-crossing frame to `target` in the mono PCM, for the loop/start snap (the
// S2 zero-crossing-aware requirement). A zero crossing is a frame index i (1 <= i < frames)
// where the sign of pcm[i-1] and pcm[i] differ (a sample exactly 0 counts as its own crossing
// — pcm[i] == 0 snaps to i). The search fans out symmetrically from the clamped target and
// returns the closest crossing frame; ties (equidistant crossings on both sides) resolve to
// the LOWER frame (deterministic). When the PCM has NO sign change anywhere (all one sign, or
// fewer than 2 frames), returns the clamped target unchanged (nothing to snap to — the caller
// keeps the raw frame). `target` is clamped to [0, frames) before searching. Pure — scans the
// decoded PCM the shell already holds; no host types, no file I/O.
// Nearest zero-crossing frame to `target` in the mono PCM, for loop/start snap. A crossing is a
// frame i (1 <= i < frames) where pcm[i-1] and pcm[i] differ in sign (pcm[i] == 0 snaps to i).
// Search fans out symmetrically from the clamped target; an equidistant tie resolves to the
// lower frame. No sign change anywhere (or fewer than 2 frames) returns the clamped target
// unchanged.
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
std::int64_t target);
+20
View File
@@ -0,0 +1,20 @@
# src/core/json — the hand-rolled JSON lexical layer
## Scope
The ONE hand-rolled JSON lexical layer used across the pure core: string/number/
bool/null tokens, the scoped object `Writer`, and the bounds-checked `Reader`
cursor. Domain grammars — what fields a bank, view-mode, or manifest blob actually
has — stay in the consumers; this module owns lexing/emitting only.
## Modules
- `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.
## Gotchas
- Byte-compatible with the five pre-extraction per-module writers it replaced
(`bank_model` / `bank_book` / `view_mode_model` / `owned_manifest` /
`tail_control`) — a change here risks silently breaking round-trip compatibility
with ext-state blobs already persisted by projects written before the Q-W1
extraction.
+4 -11
View File
@@ -1,6 +1,5 @@
// 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.
// core/json implementation — see json.h. Any behavioral change here changes
// every persisted-blob parser that shares this lexical layer at once.
#include "core/json/json.h"
@@ -11,9 +10,7 @@
namespace reasampler::json {
// ---------------------------------------------------------------------------
// emit helpers
// ---------------------------------------------------------------------------
// -- emit helpers -------------------------------------------------------
void writeEscaped(std::string& out, const std::string& s) {
out += '"';
@@ -75,9 +72,7 @@ void writeIntArray(std::string& out, const std::vector<int>& v) {
out += ']';
}
// ---------------------------------------------------------------------------
// Reader
// ---------------------------------------------------------------------------
// -- Reader ---------------------------------------------------------------
void Reader::skipWs() {
while (!eof()) {
@@ -117,7 +112,6 @@ bool Reader::parseString(std::string& out) {
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;
@@ -149,7 +143,6 @@ bool Reader::parseString(std::string& out) {
return false; // unpaired low surrogate — malformed
}
// Encode codePoint as UTF-8.
if (codePoint <= 0x7F) {
out += static_cast<char>(codePoint);
} else if (codePoint <= 0x7FF) {
+16 -23
View File
@@ -1,22 +1,19 @@
// 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.
// core/json — the ONE hand-rolled JSON lexical layer. 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.
// Owns the lexical half of the house JSON dialect: escape-aware string literals
// (incl. \uXXXX + surrogate pairs re-encoded as UTF-8), bare scalar tokens, number
// parsing (strtod/strtoll, full-token + ERANGE rejection), key+':' consumption,
// unknown-value skipping, and the emit side (escaping, %.17g/%d/%lld rendering,
// the scoped object writer). Domain grammars — which keys exist, what shape each
// value takes — stay in the consumers (bank_model, bank_book, view_mode_model,
// owned_manifest, tail_control).
//
// 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.
// 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. Persisted .rpp ext-state must not shift by a byte.
#pragma once
@@ -26,9 +23,7 @@
namespace reasampler::json {
// ---------------------------------------------------------------------------
// emit helpers (writer side)
// ---------------------------------------------------------------------------
// -- 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).
@@ -88,9 +83,7 @@ private:
bool first_ = true;
};
// ---------------------------------------------------------------------------
// Reader — the lexical cursor (parser side)
// ---------------------------------------------------------------------------
// -- 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
+81
View File
@@ -0,0 +1,81 @@
# src/core/model — the pure bank/sample index and its multi-bank container
## Scope
Pure (REAPER-free, unit-tested outside the DAW) sample-index models: the single-bank
index, the multi-bank registry that wraps it, its JSON codec, the gap-preserving
per-bank slot carrier, the owned-file manifest, and the capture-recipe fingerprint.
No REAPER types, no filesystem I/O — see root `CLAUDE.md` for the pure-core/shell
split this directory sits on.
## Invariants
**Pool privileges (multi-bank).**
- The pool is privileged, not special-cased: structurally one `BankIndex` among many
in `bank_book`; semantically it always exists, is un-deletable, and un-renamable
(fixed id + fixed display name "Pool"). New projects and migrated single-bank
projects start with the pool and zero named banks. Enforced in the pure rules
layer, not just the UI.
- No action path may delete or rename the pool, leave a project with zero banks, or
evacuate the pool (the pool is evacuation's destination, not a source).
**Bank identity, movement, dedup.**
- Bank id is the stable key (GUID-style, minted on create); display name and ordinal
are mutable. Display names are unique — trimmed + case-insensitive (ASCII) —
enforced by `createBank`/`renameBank`; the pool's reserved name "Pool" is protected
by the same check.
- Movement moves the index entry, not the file: move/copy between banks is
index-only (remove from A's `BankIndex`, add to B's); the underlying file stays in
the shared project bank folder. Per-bank subfolders on disk are an explicit
non-goal.
- Dedup-by-hash is per-bank. Moving a sample whose hash already exists in the
destination bank collapses onto the existing entry there. Cross-bank dedup is not
enforced — the same hash may exist in the pool and a named bank simultaneously.
- Move is the default (removes from source, adds to destination); copy is the
deliberate secondary act (adds to destination, leaves source intact).
- Delete drops members (files are not deleted); evacuate returns all of a bank's
members to the pool (index-only, same destination-collapse rule). Evacuate cannot
be applied to the pool. A plain delete of a non-empty bank orphans those members
out of every index until prune reclaims their files — the UI confirms on
non-empty delete and offers evacuate as the alternative.
**Sample removal.**
- Remove is index-only: drops one `Sample` entry from one `BankIndex`; mutates only
index + ext-state, no file written/moved/deleted, no timeline item touched.
- Remove can orphan a file — the same designed orphaned-until-prune state a
non-empty delete-bank produces — when it drops the last index reference to a
file. Reclaimed later by prune, never by remove.
- The pool's contents are removable; the pool container is not. Remove-from-pool is
allowed.
- Remove scope is this-bank only (settled 2026-07-24): drops the entry from this
bank, leaving copies in other banks untouched. `scope: this-bank | all-banks` is a
latent seam; only this-bank is a surfaced verb.
- Removes are silent — no confirm dialog. Recoverability comes from batched REAPER
undo (`Undo_BeginBlock`/`Undo_EndBlock`): one Ctrl-Z restores the index entry.
This undo-batching is Phase-B-wide (create/rename/reorder/delete-bank, move, copy,
evacuate, and remove all batch this way). `hashReferencedElsewhere` is a tested
model API retained for Phase R prune; it has no shell caller in the remove path.
**Precision implications.**
- Relative-paths-only survives unchanged: every `BankIndex` in the book keeps the
relative-path invariant at its `add` boundary; movement is index-only so files
never relocate.
- Non-destructive: bank create/rename/delete/activate/evacuate and sample
move/copy/remove mutate only index + ext-state; no file is written, moved, or
deleted, and no timeline item is touched.
## Modules
- `bank_model``Sample` metadata struct + `BankIndex` (add/remove/query/tier/dedup-by-hash + JSON round-trip). Test it hard — it is the heart.
- `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.
- `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`.
- `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.
- `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.**
## Gotchas
- `bank_book` wraps `BankIndex`; it does not modify it (additive — no `bank-id`
field on `Sample`). Do not add per-bank subfolders on disk or a global
cross-bank dedup — both are rejected-in-review non-goals.
- `bank_book_json` is a sibling TU, not a separate module — its round-trip is part
of `bank_book`'s public surface, not a distinct thing to describe separately.
+18 -39
View File
@@ -5,19 +5,13 @@
// 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).
// and the reference queries. The JSON round-trip half lives in bank_book_json.cpp,
// compiled into the same target. The one symbol both halves share is the private
// static BankBook::nameKey display-name folding rule (declared in bank_book.h).
namespace reasampler {
// SlotMap lives in core/model/slot_map.cpp (extracted Q-W1, T4-05).
// ---------------------------------------------------------------------------
// BankBook — construction + bank lookup
// ---------------------------------------------------------------------------
// -- construction + bank lookup ----------------------------------------------
BankBook::BankBook() {
Bank pool;
@@ -59,9 +53,7 @@ const Bank& BankBook::pool() const {
return *bank(kPoolBankId);
}
// ---------------------------------------------------------------------------
// Ordinal normalization
// ---------------------------------------------------------------------------
// -- Ordinal normalization ----------------------------------------------------
void BankBook::normalizeOrdinals() {
// Stable-sort by ordinal with the pool pinned first, then rewrite ordinals to a
@@ -75,16 +67,13 @@ void BankBook::normalizeOrdinals() {
banks_[i].ordinal = static_cast<int>(i);
}
// ---------------------------------------------------------------------------
// Display-name uniqueness (trimmed + case-insensitive, ASCII)
// ---------------------------------------------------------------------------
// -- Display-name uniqueness (trimmed + case-insensitive, ASCII) --------------
// 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. Private static member (Q-W5): the one folding rule shared
// with bank_book_json.cpp's parse-time duplicate-display-name coalesce.
// whitespace, lower-case ASCII letters — so "Drums"/"drums"/" Drums " share one
// key. ASCII-only by design — the pure core carries no locale facility; bank names
// are short user labels, not Unicode case-folding candidates. Private static: the
// one folding rule shared with bank_book_json.cpp's parse-time 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'; };
@@ -109,9 +98,7 @@ bool BankBook::displayNameTaken(const std::string& name, const std::string& exce
return false;
}
// ---------------------------------------------------------------------------
// Bank lifecycle
// ---------------------------------------------------------------------------
// -- Bank lifecycle ------------------------------------------------------------
bool BankBook::createBank(const std::string& id, const std::string& displayName) {
if (id.empty()) return false; // ids key the registry
@@ -208,9 +195,7 @@ bool BankBook::evacuate(const std::string& id) {
return true;
}
// ---------------------------------------------------------------------------
// Active bank
// ---------------------------------------------------------------------------
// -- Active bank ----------------------------------------------------------------
bool BankBook::setActiveBank(const std::string& id) {
if (bank(id) == nullptr) return false; // unknown id never corrupts state
@@ -227,9 +212,7 @@ const BankModel& BankBook::activeIndex() const {
return bank(activeBankId_)->index;
}
// ---------------------------------------------------------------------------
// Sample movement (index-only)
// ---------------------------------------------------------------------------
// -- Sample movement (index-only) -----------------------------------------------
namespace {
@@ -276,9 +259,7 @@ TransferResult BankBook::copySample(const std::string& sampleId,
return applyDestAdd(to->index, copy, TransferResult::Copied);
}
// ---------------------------------------------------------------------------
// Sample removal (index-only) + the last-reference query
// ---------------------------------------------------------------------------
// -- Sample removal (index-only) + the last-reference query ---------------------
RemoveResult BankBook::removeSample(const std::string& sampleId,
const std::string& fromBankId,
@@ -310,9 +291,7 @@ bool BankBook::updateSampleInPlace(const std::string& sampleId, const Sample& up
return false; // no bank holds the id
}
// ---------------------------------------------------------------------------
// Sample display order (L7) — SlotMap driven, index membership untouched
// ---------------------------------------------------------------------------
// -- Sample display order — SlotMap driven, index membership untouched -----------
namespace {
@@ -323,9 +302,9 @@ std::vector<std::string> indexIds(const BankModel& idx) {
return ids;
}
// Squares one bank's SlotMap with its index membership. A map with NO overlap with the
// index (the pre-L7 migration case, or a freshly-constructed bank) is seeded dense from
// insertion order; an existing map is reconciled (drop stale markers, append unmapped).
// Squares one bank's SlotMap with its index membership. A map with NO overlap with
// the index (a bank with no persisted slot data, or freshly constructed) is seeded
// dense from insertion order; an existing map is reconciled (drop stale, append unmapped).
void reconcileBankSlots(Bank& b) {
const std::vector<std::string> live = indexIds(b.index);
if (b.slots.empty()) {
+108 -208
View File
@@ -1,41 +1,13 @@
#pragma once
// bank_book — the pure core of the multi-bank phase (Phase B), deliberately free
// of any REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the
// third instance of the same "pure registry + JSON round-trip, unit-tested outside
// the DAW" pattern as bank_model and view_mode_model.
// bank_book — pure multi-bank registry: wraps N BankModel instances (bank_model
// itself is untouched — additive, no bankId on Sample). Movement between banks is
// index-only; files never relocate, banks are logical groupings over one shared pool.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only.
//
// -- What it is --------------------------------------------------------------
//
// An ordered registry of banks. Each bank = { stable id, display name, ordinal,
// 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 BankModel, add to destination's); files never
// relocate — banks are logical groupings over one shared file pool.
//
// -- The pool (privileged, not special-cased) --------------------------------
//
// Structurally the pool is bank-zero — one Bank among many, seeded on construction
// with a fixed id (kPoolBankId) and fixed display name (kPoolBankName), ordinal 0.
// Semantically it is privileged, and the privileges are enforced HERE in the pure
// rules layer (CONTEXT.md §Multi-bank guardrail — not deferred to a shell):
// * always exists (seeded on construction; the book never reaches zero banks)
// * un-deletable (deleteBank rejects the pool)
// * un-renamable (renameBank rejects the pool)
// * un-evacuable (evacuate rejects the pool — the pool is evacuation's
// destination, not a source)
//
// -- Id minting is the CALLER'S job (design decision) ------------------------
//
// createBank takes a caller-supplied stable id, mirroring bank_model's "id
// assigned by the caller" and view_mode_model's mode ids. The pure core has no
// REAPER genGuid / RNG and deliberately introduces none: a fake in-model id source
// would not be a real GUID anyway, and keeping ids caller-supplied lets the B2
// shell mint a genuine REAPER GUID while the model stays pure and deterministically
// testable. The model still enforces the invariants: non-empty, unique, not the
// reserved pool id.
// The pool is bank-zero (fixed id/name, ordinal 0), privileged and enforced HERE:
// always exists, un-deletable, un-renamable, un-evacuable (evacuate's destination
// only). createBank takes a caller-supplied id — REAPER GUID minting stays in the
// shell so this model stays pure and deterministic; the model still enforces
// non-empty/unique/not-reserved.
#include <optional>
#include <string>
@@ -47,26 +19,22 @@
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.
// Interim: this module re-namespaces later; the 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.
// The pool's fixed identity: createBank rejects this id; renameBank rejects this name.
inline constexpr const char* kPoolBankId = "pool";
inline constexpr const char* kPoolBankName = "Pool";
// 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 BankModel. The pool is the bank whose id == kPoolBankId.
// One bank: id/display/ordinal/BankModel/SlotMap. 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
BankModel index; // this bank's samples
SlotMap slots; // L7 display positions of this bank's samples (gap-preserving)
SlotMap slots; // display positions of this bank's samples (gap-preserving)
bool isPool() const { return id == kPoolBankId; }
@@ -76,16 +44,12 @@ struct Bank {
}
};
// Outcome of a cross-bank sample move/copy. Mirrors AddResult's honesty: the op
// reports what happened rather than silently mutating on a bad request.
// - Moved / Copied: the sample was transferred to the destination as a new entry.
// - Collapsed: the destination already held the hash; it collapsed onto the
// existing entry (a no-op add on the destination side). For a
// MOVE the source entry is STILL removed; for a COPY the source
// entry is (as always) retained.
// - RejectedUnknownBank: a source or destination id named no bank.
// - RejectedSampleAbsent: the sample id was not in the source bank.
// - RejectedSameBank: source and destination were the same bank (no-op).
// Outcome of a cross-bank move/copy — reports what happened rather than mutating
// silently on a bad request.
// - Moved / Copied: transferred to the destination as a new entry.
// - Collapsed: destination already held the hash, collapsed onto it (move
// still removes the source; copy keeps it, as always).
// - RejectedUnknownBank / RejectedSampleAbsent / RejectedSameBank: no-op guards.
enum class TransferResult {
Moved,
Copied,
@@ -95,21 +59,18 @@ enum class TransferResult {
RejectedSameBank,
};
// Scope of a sample-remove (fork R-A, settled 2026-07-24). ThisBank is the default
// and the ONLY behavior surfaced in the UI/action layer; AllBanks is a latent seam
// live and tested at the model level, promotable later behind this parameter without
// a rewrite, but never wired to an affordance in B5.
// - ThisBank: drop the entry from the one named source bank only. A same-hash entry
// in another bank survives (no cross-bank cascade — dedup is per-bank).
// - AllBanks: drop the sample's entry from EVERY bank that holds the source id
// ("purge from the library"). Latent; unsurfaced.
// Scope of a sample-remove. ThisBank is the only behavior surfaced in the UI;
// AllBanks is a tested latent seam, not wired to any affordance.
// - ThisBank: drop the entry from the one named source bank only (no cross-bank
// cascade — dedup is per-bank).
// - AllBanks: drop the sample's entry from every bank holding it ("purge from
// the library").
enum class RemoveScope {
ThisBank,
AllBanks,
};
// Outcome of BankBook::removeSample. Mirrors TransferResult's honesty: the op reports
// what happened rather than silently mutating on a bad request.
// Outcome of BankBook::removeSample — same honesty as TransferResult.
// - Removed: at least one index entry was dropped.
// - RejectedUnknownBank: the source bank id named no bank (ThisBank scope only).
// - RejectedSampleAbsent: the sample id was in no bank in scope (nothing removed).
@@ -120,8 +81,7 @@ enum class RemoveResult {
};
// An ordered registry of banks with the pool seeded as bank-zero, per-bank sample
// indices, an active-bank pointer, and lossless JSON round-trip. The heart of the
// multi-bank phase — mirror of bank_model / view_mode_model.
// indices, an active-bank pointer, and lossless JSON round-trip.
class BankBook {
public:
BankBook(); // seeds the pool (id kPoolBankId, name kPoolBankName, ordinal 0);
@@ -129,34 +89,29 @@ public:
// -- Bank lifecycle ------------------------------------------------------
// Creates a named bank with the caller-supplied stable id and display name,
// assigning the next ordinal. Rejects (returns false, no mutation) an empty id,
// a duplicate id, the reserved pool id, or a display name that duplicates an
// existing bank's name (including the pool's "Pool"). Display-name uniqueness is
// trimmed + case-insensitive (ASCII): "Drums", "drums", and " Drums " collide.
// Creates a named bank with a caller-supplied id/display name (next ordinal
// assigned automatically). Rejects (false, no mutation) an empty/duplicate id,
// the reserved pool id, or a duplicate display name (trimmed + case-insensitive,
// ASCII — "Drums"/"drums"/" Drums " collide, including against the pool's "Pool").
bool createBank(const std::string& id, const std::string& displayName);
// Renames a named bank. Rejects (false, no mutation) an unknown id, the pool, or a
// target name already used by a DIFFERENT bank (trimmed + case-insensitive, as
// createBank). Renaming a bank to its own current name is a no-op success.
// Renames a named bank. Rejects (false, no mutation) an unknown id, the pool, or
// a name already used by another bank. Renaming to its own current name is a
// no-op success.
bool renameBank(const std::string& id, const std::string& displayName);
// Deletes a NAMED bank, removing it (and its member index entries) from the
// registry. Files are a shell/prune concern and are NOT touched here. Rejects
// (false, no mutation) an unknown id or the pool. Remaining banks' ordinals are
// compacted so the pool stays 0 and named banks stay contiguous 1..N. If the
// deleted bank was active, the active bank falls back to the pool.
// Deletes a named bank and its member entries (files untouched — a shell/prune
// concern). Rejects (false, no mutation) an unknown id or the pool. Remaining
// ordinals compact after; if the deleted bank was active, falls back to the pool.
bool deleteBank(const std::string& id);
// Reorders a NAMED bank to `newOrdinal` (clamped into the named-bank range),
// shifting the others to keep ordinals contiguous. The pool is pinned at 0 and
// cannot be reordered. Rejects (false, no mutation) an unknown id or the pool.
// Reorders a named bank to newOrdinal (clamped into range, others shift to stay
// contiguous). The pool is pinned at 0. Rejects an unknown id or the pool.
bool reorderBank(const std::string& id, int newOrdinal);
// Moves EVERY member of a named bank into the pool (index-only, observing the
// same destination-collapse-by-hash as a move), leaving the bank empty. Rejects
// (false, no mutation) an unknown id or the pool (the pool is the destination,
// never a source). Returns true on success even if the bank was already empty.
// Moves every member of a named bank into the pool (index-only, same
// destination-collapse-by-hash as a move). Rejects an unknown id or the pool
// (the pool is only ever a destination). Returns true even if already empty.
bool evacuate(const std::string& id);
// -- Active bank ---------------------------------------------------------
@@ -164,123 +119,83 @@ public:
// The active bank's id (the capture target). Defaults to the pool.
const std::string& activeBankId() const { return activeBankId_; }
// Sets the active bank. Rejects (returns false, no change) an id that names no
// bank — an invalid set never corrupts state.
// Sets the active bank. Rejects (false, no change) an id that names no bank.
bool setActiveBank(const std::string& id);
// 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).
// The active bank's BankModel — always valid (falls back to the pool).
BankModel& activeIndex();
const BankModel& activeIndex() const;
// -- Sample movement (index-only; files never relocate) ------------------
// Moves a sample by id from `fromBankId` to `toBankId`: removes it from the
// source index and adds it to the destination (observing destination
// collapse-by-hash). See TransferResult for the full outcome set.
// Moves a sample by id between banks (destination collapse-by-hash observed).
// See TransferResult for the full outcome set.
TransferResult moveSample(const std::string& sampleId,
const std::string& fromBankId,
const std::string& toBankId);
// Copies a sample by id from `fromBankId` to `toBankId`: the source entry is
// retained, the destination gains it (observing destination collapse-by-hash).
// Same hash may then live in both banks — cross-bank dedup is NOT enforced.
// Copies a sample by id between banks, source retained (destination
// collapse-by-hash observed). Cross-bank dedup is NOT enforced — the same hash
// may then live in both banks.
TransferResult copySample(const std::string& sampleId,
const std::string& fromBankId,
const std::string& toBankId);
// -- Sample removal (index-only; the file is NEVER touched — orphaned until prune) --
// Drops a sample's index entry (the sample-level sibling of move/copy/evacuate).
// 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.
//
// Scope (fork R-A): ThisBank (default, the only surfaced verb) drops the entry from
// `fromBankId` alone; AllBanks (latent seam) drops the sample id from every bank
// that holds it. See RemoveResult for the outcome set.
// * ThisBank: RejectedUnknownBank if `fromBankId` names no bank; RejectedSampleAbsent
// if that bank does not hold the id; Removed on a drop.
// * AllBanks: `fromBankId` is ignored (the id is purged book-wide);
// RejectedSampleAbsent if NO bank held the id; Removed otherwise.
// No mutation occurs on any Rejected outcome (no-op guardrail for the undo layer).
// Drops a sample's index entry — non-destructive to the file (a last-reference
// remove leaves it on disk, orphaned until prune reclaims it). See RemoveScope/
// RemoveResult for scope and outcome. No mutation on any Rejected outcome.
RemoveResult removeSample(const std::string& sampleId,
const std::string& fromBankId,
RemoveScope scope = RemoveScope::ThisBank);
// -- Sample display order (L7; index membership untouched) ---------------
// -- Sample display order (index membership untouched) -------------------
// The bank's sample ids in DISPLAY (slot) order — the deterministic order the grid
// iterates, sourced from the bank's SlotMap. Reconciles the map against live index
// membership first (drops stale markers, appends unmapped samples densely), so a
// freshly-migrated or out-of-band-mutated bank always yields a complete order. An
// unknown bank id yields an empty vector. Const-logical but reconciles lazily, so
// it is a non-const member.
// The bank's sample ids in display (slot) order, reconciled against live index
// membership first (drops stale markers, appends unmapped samples densely). An
// unknown bank id yields an empty vector.
std::vector<std::string> orderedSampleIds(const std::string& bankId);
// Ensures every bank's SlotMap is consistent with its index membership: seeds a
// map that has NO overlap with its index from insertion order (the pre-L7 migration
// default — dense, no gaps), and reconciles a partially-populated map (drop stale,
// append unmapped). Idempotent. Called after deserialize and after any capture/
// transfer that added samples out-of-band of the L7 reorder path.
// Ensures every bank's SlotMap is consistent with its index membership (seeds a
// dense order, or reconciles a partial map). Idempotent — call after deserialize
// or any out-of-band membership change.
void reconcileSlots();
// Reorders sample `id` within `bankId` to `targetSlot` (gap-preserving; see
// SlotMap::reorder). INDEX-ONLY of positions — the sample's membership, file, and
// metadata are untouched (capture != placement holds). Reconciles the bank's slots
// first so the target space is complete. Returns false (no mutation) on an unknown
// bank or an id the bank does not hold.
// Reorders sample `id` within `bankId` to targetSlot (gap-preserving; index/file/
// metadata untouched). Returns false (no mutation) on an unknown bank or id.
bool reorderSample(const std::string& id, const std::string& bankId, int targetSlot);
// Alt-replace (L7 F3): the dragged sample `newId` (already a member of `bankId`)
// takes the slot of the occupant `oldId`, and `oldId` is REMOVED from `bankId`'s
// index (index-only, same semantics as removeSample ThisBank — the file stays on
// disk; owned-manifest/prune govern bytes; hashReferencedElsewhere handles the
// last-reference case). Position of the slot is preserved; only its occupant changes.
// Alt-replace: the dragged `newId` (already a member of bankId) takes the slot of
// `oldId`, and `oldId` is removed from the index (same semantics as removeSample
// ThisBank). Position is preserved; only the occupant changes.
//
// POOL GUARD (settled): the index-removal of `oldId` passes the SAME guard the
// remove verb applies — removeSample(oldId, bankId, ThisBank) must return Removed.
// For the pool this is permitted whenever the occupant exists (per-sample removal
// is not a pool privilege violation — the pool's guards are un-delete/rename/evacuate,
// never per-sample remove). If the removal would be rejected (occupant absent), the
// whole replace is rejected: false, NO mutation (neither the index nor the slots
// change), so the shell can fall back to the default insert-shift or a no-op.
// Rejects (false, no mutation) an unknown bank, a `newId`/`oldId` the bank does not
// hold, or `newId == oldId`. NEVER touches disk; introduces no new deletion authority.
// Applies the same pool guard as removeSample — per-sample removal from the pool
// is allowed (the pool's guards are un-delete/rename/evacuate, never per-sample
// remove). Rejects (false, no mutation of either index or slots) an unknown bank,
// a newId/oldId the bank doesn't hold, or newId == oldId.
bool replaceSample(const std::string& newId, const std::string& oldId,
const std::string& bankId);
// 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 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).
// Returns false (no mutation) if no bank holds the id or the replacement's path
// is absolute. Index-only and non-destructive to the timeline.
// Refreshes a sample in place wherever it lives (re-capture): finds the bank
// holding sampleId and replaces its entry with `updated` (order-preserving, no
// dedup). Updates the FIRST holder in ordinal order if the id lives in multiple
// banks via copy. Returns false (no mutation) if no bank holds the id or the
// replacement's path is absolute.
bool updateSampleInPlace(const std::string& sampleId, const Sample& updated);
// Reference-count query backing the confirm-on-last-reference guardrail: does any
// bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`?
//
// Identity is the CONTENT HASH, not the file path: hash is the canonical dedup key
// the whole model already reasons in (findByHash / collapse-by-hash), and two
// entries that share content share one file — so "some other bank still references
// this hash" is exactly "removing here does not orphan the file." An EMPTY hash is
// never matched (it does not participate in dedup, mirroring findByHash), so an
// empty-hash sample reads as referenced-nowhere-else — the safe, confirm-eliciting
// direction (we cannot prove another bank shares an unhashed file).
// Does any bank other than exceptBankId still hold an entry whose contentHash
// == hash? Backs the confirm-on-last-reference guardrail: two entries sharing a
// hash share one file, so this answers "would removing here orphan the file."
// An empty hash never matches (mirrors findByHash) — reads as
// referenced-nowhere-else, the safe confirm-eliciting default.
bool hashReferencedElsewhere(const std::string& hash,
const std::string& exceptBankId) const;
// Every project-relative file path referenced by ANY bank in the book, pool
// included — the union across the whole book (Phase R, prune). This is the
// safety-critical referenced-set the prune core subtracts: a file referenced by
// any bank (INCLUDING via a copy into a second bank) appears here, so prune never
// reclaims it. Paths are returned VERBATIM (Sample.relativePath, exact strings —
// no normalization), first-seen order across banks in ordinal order then sample
// insertion order, and DE-DUPLICATED (one file referenced by N banks appears
// once). An empty relativePath is skipped (it references no file). Additive
// read-only query; adds no mutation and no coupling to Phase R.
// Every project-relative path referenced by any bank (pool included) — the union
// prune subtracts against. Paths are verbatim (no normalization), first-seen
// order across banks in ordinal then insertion order, de-duplicated. An empty
// relativePath is skipped.
std::vector<std::string> referencedPaths() const;
// -- Query ---------------------------------------------------------------
@@ -308,33 +223,25 @@ public:
// -- Persistence ---------------------------------------------------------
// Serializes the whole book to a JSON string (lossless round-trip): the pool
// folded in as bank-zero + named banks + per-bank indices + ordinals + active
// id. deserialize(serialize(x)) == x.
// Serializes the whole book to JSON (lossless): pool as bank-zero + named banks
// + per-bank indices + ordinals + active id. deserialize(serialize(x)) == x.
std::string serialize() const;
// Parses a book JSON produced by serialize(). std::nullopt on malformed input.
//
// LEGACY MIGRATION: a bare legacy bank_index JSON (the pre-multi-bank shape, an
// object with a "samples" array and no "banks" key) is promoted into the pool's
// index, yielding a book of { pool } with zero named banks — one-way, lossless.
// After migration the book blob is authoritative (the caller persists the book
// shape going forward; the legacy key is retired by the B2 shell).
// A bare legacy bank_index JSON (pre-multi-bank shape: a "samples" array, no
// "banks" key) is promoted into the pool's index — one-way, lossless — yielding
// a book of { pool } with zero named banks.
static std::optional<BankBook> deserialize(const std::string& json);
// Resolve a BankBook from the two persisted ext-state values a project may carry:
// the authoritative `banks` blob and the retired-but-possibly-present legacy
// `bank_index` blob. The persist shell (B2) hands both raw strings straight here so
// the load-source decision stays REAPER-free and unit-tested. Precedence:
// 1. non-empty `banksJson` present -> deserialize it (authoritative). If it is
// MALFORMED, do NOT silently fall back to the legacy blob — a corrupt `banks`
// blob is an error, not an absence; return an empty book so a stale legacy key
// can never resurrect a superseded single-bank state over a broken book.
// 2. else non-empty `legacyJson` -> deserialize it (one-way pool migration).
// 3. else (both absent/empty) -> a fresh empty book (pool only).
// Never returns nullopt: an unloadable input degrades to the empty book (matching
// the shell's existing "malformed -> ignore, start empty" behaviour), so the caller
// has one branchless install path.
// Resolves a BankBook from the two persisted ext-state values a project may
// carry: the authoritative `banksJson` and the retired legacy `bank_index` blob.
// 1. non-empty banksJson -> deserialize it. If malformed, do NOT fall back to
// legacy — a corrupt banks blob is an error, not an absence; returns an
// empty book so a stale legacy key can never resurrect superseded state.
// 2. else non-empty legacyJson -> deserialize it (pool migration).
// 3. else -> a fresh empty book (pool only).
// Never returns nullopt — an unloadable input degrades to the empty book.
static BankBook loadFromPersisted(const std::string& banksJson,
const std::string& legacyJson);
@@ -343,41 +250,34 @@ private:
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.
// whitespace, lower-case ASCII letters — so "Drums"/"drums"/" Drums " share one
// key. ASCII-only by design — the pure core carries no locale facility. Private
// static because both halves of the split implementation (rules + JSON) need the
// one folding rule; 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.
// True if a bank other than exceptId already carries name's uniqueness key.
// Backs the create/rename uniqueness check; pass exceptId=id to let a bank keep
// (or re-case/-space) its own name.
bool displayNameTaken(const std::string& name, const std::string& exceptId) const;
// Re-sorts banks_ by ordinal (pool pinned first) and rewrites ordinals to a
// contiguous 0..N-1 so the pool is 0 and named banks are 1..N. Called after any
// structural change (create / delete / reorder).
// contiguous 0..N-1. Called after any structural change (create/delete/reorder).
void normalizeOrdinals();
// Replaces the book's banks with a parsed set, normalizes ordinals, and resolves
// the active bank (falling back to the pool if the id names no bank). Used only
// by deserialize; kept private so the public surface stays create/rename/etc.
// by deserialize.
void adoptBanks(std::vector<Bank>&& banks, const std::string& activeBank);
};
// The next bank id to activate when cycling the active bank forward, in ordinal
// order (the ids arrive pool-first, named 1..N, matching banks()). Wraps: the id
// after the last returns the first (pool → named → … → pool). This is the pure
// decision behind the "cycle active bank" action — the shell reads the book's
// ordered bank ids + current active id, asks for the next, and activates it.
// order (pool -> named -> ... -> pool, wraps). Free function (not a member) so it's
// unit-testable against a bare id vector without a full book.
// * empty list -> "" (nothing to cycle to)
// * single id (pool-only) -> that id (a one-bank book stays put)
// * currentBankId not present -> the first id (a sane home to jump to)
// Exposed as a free function (not a BankBook member) so it is unit-testable against
// a bare id vector without a full book. Mirror of view_mode_model's nextModeId.
std::string nextBankId(const std::vector<std::string>& orderedBankIds,
const std::string& currentBankId);
+27 -43
View File
@@ -6,33 +6,25 @@
#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.
// bank_book JSON round-trip — a sibling TU to bank_book.cpp, sharing its header
// and target. The registry RULES half stays in bank_book.cpp; the one shared
// symbol is the private static BankBook::nameKey folding rule — the parse-time
// duplicate-display-name coalesce below must fold names EXACTLY as create/rename
// uniqueness 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.
// The book blob nests one bank object per bank, each carrying that bank's
// BankModel serialized by bank_model's OWN writer, 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.
namespace reasampler {
// ===========================================================================
// JSON — writer
// ===========================================================================
// -- 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;
@@ -58,8 +50,8 @@ std::string BankBook::serialize() const {
// 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.
// Display positions (gap-preserving). Absent on a pre-existing blob;
// the parser defaults such a bank's slots from insertion order on load.
b.keyRaw("slots", banks_[i].slots.serialize());
}
out += ']';
@@ -67,13 +59,10 @@ std::string BankBook::serialize() const {
return out;
}
// ===========================================================================
// JSON — parser (recursive descent; std::nullopt on any malformed input, never UB)
// ===========================================================================
// -- JSON — parser (recursive descent; std::nullopt on 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
@@ -112,9 +101,9 @@ bool parseBank(json::Reader& r, Bank& b) {
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.
// Display positions. Absent on a pre-existing blob; when present it
// drives the bank's SlotMap. reconcileSlots() (post-adopt) squares it
// with membership.
std::vector<std::pair<std::string, int>> pairs;
if (!parseSlots(r, pairs)) return false;
b.slots = SlotMap::fromEntries(pairs);
@@ -186,9 +175,7 @@ bool parseBook(json::Reader& r, const std::string& raw, std::vector<Bank>& banks
} 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.
// Legacy marker; the legacy index is re-parsed from the whole input below.
sawSamples = true;
if (!r.skipValue()) return false;
} else {
@@ -253,23 +240,20 @@ std::optional<BankBook> BankBook::deserialize(const std::string& blob) {
json::Reader r(blob);
if (!parseBook(r, blob, banks, activeBank)) return std::nullopt;
// --- Coalesce duplicate folded display names (B4 re-review fold-in). --------
// --- Coalesce duplicate folded display names. --------------------------
// 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"
// DISAMBIGUATE the later duplicate: 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 names seen so far — the first bank to carry a key
// keeps its name verbatim. No bank or sample is lost, ids are untouched, and the
// pool's reserved "Pool" key is seeded first 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.
// Hosted here (not in the free parseBook) because it folds through the PRIVATE
// BankBook::nameKey the create/rename uniqueness check also uses.
{
std::vector<std::string> seenKeys;
seenKeys.reserve(banks.size());
+21 -40
View File
@@ -4,21 +4,14 @@
#include "core/json/json.h"
// bank_model implementation.
//
// 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.
// bank_model implementation. JSON rides on the shared core/json lexical layer;
// only the Sample/index DOMAIN grammar lives here. Doubles are 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.
namespace reasampler::model {
// ---------------------------------------------------------------------------
// equality
// ---------------------------------------------------------------------------
// -- equality -----------------------------------------------------------
bool SourceRange::operator==(const SourceRange& o) const {
return startSeconds == o.startSeconds && endSeconds == o.endSeconds &&
@@ -51,20 +44,15 @@ bool Sample::operator==(const Sample& o) const {
provenance == o.provenance && createdTimestamp == o.createdTimestamp;
}
// ---------------------------------------------------------------------------
// path invariant
// ---------------------------------------------------------------------------
// -- path invariant -------------------------------------------------------
// DECISION: reject absolute paths rather than normalize them. The pure model has
// no knowledge of the project root, so it cannot correctly relativize an absolute
// path — any "normalization" would be a guess that could point at the wrong file.
// Rejecting at the boundary is honest and deterministic; the capture backend (M3)
// is responsible for handing us an already-relative path. Covers POSIX ("/x"),
// Windows drive ("C:\x", "C:/x", "C:foo" drive-relative), and UNC ("\\host\share")
// forms. Any leading <alpha>: is rejected regardless of the character that follows —
// drive-relative paths ("C:foo.wav") resolve against the drive's current directory,
// not the project root, so they violate the relative-paths-only invariant just as
// much as "C:\foo.wav" does.
// Rejects absolute paths rather than normalizing them: the pure model has no
// knowledge of the project root, so any "normalization" would be a guess that
// could point at the wrong file. Covers POSIX ("/x"), Windows drive ("C:\x",
// "C:/x", "C:foo" drive-relative), and UNC ("\\host\share") forms. Any leading
// <alpha>: is rejected regardless of what follows — drive-relative paths
// ("C:foo.wav") resolve against the drive's current directory, not the project
// root, so they violate relative-paths-only just as much as "C:\foo.wav" does.
static bool isAbsolutePath(const std::string& p) {
if (p.empty()) return false;
if (p[0] == '/' || p[0] == '\\') return true; // POSIX root or UNC
@@ -73,9 +61,7 @@ static bool isAbsolutePath(const std::string& p) {
return false;
}
// ---------------------------------------------------------------------------
// BankModel
// ---------------------------------------------------------------------------
// -- BankModel ------------------------------------------------------------
AddResult BankModel::add(const Sample& sample) {
if (sample.id.empty()) return AddResult::RejectedEmptyId;
@@ -139,9 +125,7 @@ std::vector<Sample> BankModel::byTier(Tier tier) const {
return out;
}
// ---------------------------------------------------------------------------
// JSON writer
// ---------------------------------------------------------------------------
// -- JSON writer ------------------------------------------------------------
namespace {
@@ -182,9 +166,9 @@ void writeSample(std::string& out, const Sample& s) {
w.keyBegin("key");
if (s.key) writeEscaped(out, *s.key); else out += "null";
// Phase S seam fields (D-B). Emitted as null when absent (same shape as `key`
// and `provenance`) so pre-Phase-S JSON — which lacks these keys entirely —
// parses to empty optionals and re-serializes without invention.
// Emitted as null when absent (same shape as `key`/`provenance`) so JSON that
// lacks these keys entirely parses to empty optionals and re-serializes
// without invention.
w.keyBegin("rootNote");
if (s.rootNote) out += numToStr(*s.rootNote); else out += "null";
@@ -240,12 +224,9 @@ std::string BankModel::serialize() const {
return out;
}
// ---------------------------------------------------------------------------
// 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.
// ---------------------------------------------------------------------------
// -- 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.
namespace {
+35 -44
View File
@@ -1,11 +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 `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
// vendor/ includes. Standard library only.
// bank_model — the HEART of ReaSampler: the per-project sample bank. `Sample`
// metadata struct + `BankModel` (add/remove/query/tier moves/dedup-by-hash + JSON
// round-trip to/from std::string).
#include <cstdint>
#include <optional>
@@ -14,8 +10,8 @@
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.
// How the source audio was obtained; the capture backends map their own notion
// onto these.
enum class SourceMode {
MasterMix, // offline render of the master output
SelectedTracks, // offline render of selected tracks
@@ -31,9 +27,8 @@ enum class Tier {
Archive,
};
// Sample-accurate source bounds, in both project seconds and PPQ (ticks). Both
// are stored because capture needs seconds and musical placement needs PPQ; we
// refuse to re-derive one from the other and risk rounding (precision invariant).
// Sample-accurate source bounds, in both project seconds and PPQ (ticks) — both
// stored so capture doesn't re-derive one from the other and risk rounding.
struct SourceRange {
double startSeconds = 0.0;
double endSeconds = 0.0;
@@ -44,9 +39,9 @@ struct SourceRange {
};
// Present only when a sample was resampled FROM another sample. Carries the
// parent's id and the FX-chain snapshot string (a thin drift fingerprint, NOT a
// restorable chunk) captured at resample time; the re-capture-from-source action
// (M10) uses it to detect chain drift and replay the original capture request.
// parent's id and an FX-chain snapshot (a thin drift fingerprint, NOT a restorable
// chunk) — re-capture-from-source uses it to detect chain drift and replay the
// original capture request.
struct Provenance {
std::string parentSampleId;
std::string fxChainSnapshot;
@@ -63,15 +58,13 @@ struct Levels {
bool operator==(const Levels& o) const;
};
// Sample-accurate sustain-loop bounds, as frame indices into the captured file
// (Phase S seam field, D-B). A bank intrinsic — a fact about the file, like
// sampleRate or length — consumed by the future MIDI-playback instrument to hold
// notes past the recorded length. Modeled as one optional struct (not two loose
// optionals) so "both points or neither" is a structural invariant, not a rule to
// re-check at every boundary. Frame indices, not seconds, because the loop is a
// per-sample-frame contract; the instrument reads the file's sample rate to relate
// them to time. Invariant (enforced at the deserialize boundary): 0 <= start <= end.
// start == end is a valid zero-length loop marker.
// Sample-accurate sustain-loop bounds, as frame indices into the captured file — a
// bank intrinsic (like sampleRate or length) the MIDI-playback instrument uses to
// hold notes past the recorded length. One optional struct (not two loose
// optionals) so "both points or neither" is structural, not a rule to re-check at
// every boundary. Frame indices, not seconds — the instrument relates them to time
// via the file's sample rate. Invariant (enforced at deserialize): 0 <= start <=
// end; start == end is a valid zero-length loop marker.
struct LoopPoints {
std::int64_t start = 0;
std::int64_t end = 0;
@@ -102,23 +95,23 @@ struct Sample {
double lengthBeats = 0.0;
double captureTempo = 0.0; // project tempo (BPM) at capture time
// Time signature at capture time (L7 F1 — stamped alongside captureTempo so the
// bars.beats.subdivisions read-out is stable under later project meter changes).
// 0/0 means UNSTAMPED (pre-L7 sample, or a capture that could not read the meter);
// the metadata formatter renders a blank musical read-out for 0/0 and keeps s.ms.
// Time signature at capture time, stamped alongside captureTempo so the
// bars.beats.subdivisions read-out is stable under later project meter changes.
// 0/0 means UNSTAMPED (pre-existing sample, or a capture that could not read the
// meter); the metadata formatter renders a blank musical read-out then, keeping s.ms.
int captureTimeSigNum = 0; // meter numerator (e.g. 4 in 4/4); 0 = unstamped
int captureTimeSigDenom = 0; // meter denominator (e.g. 4 in 4/4); 0 = unstamped
std::optional<std::string> key; // musical key, when known
// Phase S seam fields (D-B) — bank intrinsics for the MIDI-playback instrument,
// additive like `provenance` (M1). Both default cleanly empty: pre-Phase-S
// samples deserialize without them and re-serialize without inventing values.
// Bank intrinsics for the MIDI-playback instrument, additive like `provenance`.
// Both default cleanly empty: pre-existing samples deserialize without them and
// re-serialize without inventing values.
// - rootNote: MIDI note (0..127) the sample was recorded at, so the instrument
// can repitch it across the keyboard. DISTINCT from the musical `key` above:
// `key` is a human label ("F#m"); `rootNote` is the exact pitch for repitch.
// Populated at/after capture only where derivable — left empty (never guessed)
// when the source is not a single played note.
// Populated only where derivable — never guessed when the source isn't a
// single played note.
// - loop: sustain-loop bounds, populated only where explicitly set.
std::optional<int> rootNote;
std::optional<LoopPoints> loop;
@@ -156,7 +149,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.
// so a panel can iterate in stable order.
class BankModel {
public:
// Adds a sample. Enforces the relative-paths-only invariant and dedups by
@@ -168,16 +161,14 @@ public:
bool remove(const std::string& id);
// Replaces the sample carrying `id` IN PLACE (preserving its position in
// insertion order), with `updated`. Used by M10 re-capture-from-source: a
// provenanced sample's file is regenerated and its metadata (relativePath,
// contentHash, levels, timestamp, ...) refreshed while its identity (id) and
// slot are kept, so the bank panel shows the same tile updated rather than a
// reordered new entry. `updated.id` should equal `id` (the caller keeps the id
// stable); a differing id is written through as given (the caller's contract).
// Does NOT dedup — an in-place refresh of one entry is not a new insert, so the
// collapse-by-hash rule (which guards NEW inserts) does not apply. Returns false
// (no mutation) if `id` is absent or `updated.relativePath` is absolute
// (the relative-paths-only invariant still holds for the replacement).
// insertion order) with `updated`. Used by re-capture-from-source: a
// provenanced sample's file is regenerated and its metadata refreshed while
// its identity (id) and slot are kept, so the panel shows the same tile
// updated rather than a reordered new entry. `updated.id` should equal `id`;
// a differing id is written through as given. Does NOT dedup — an in-place
// refresh is not a new insert, so collapse-by-hash (which guards inserts)
// does not apply. Returns false (no mutation) if `id` is absent or
// `updated.relativePath` is absolute (relative-paths-only still holds here).
bool updateInPlace(const std::string& id, const Sample& updated);
// Returns the sample with `id`, or nullptr if absent. The pointer is
+10 -23
View File
@@ -4,27 +4,21 @@
#include "core/json/json.h"
// owned_manifest implementation.
//
// 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_manifest implementation. JSON 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 domain parse is all it needs.
namespace reasampler::model {
// ---------------------------------------------------------------------------
// path invariant (mirror of bank_model's isAbsolutePath)
// ---------------------------------------------------------------------------
// -- path invariant (mirror of bank_model's isAbsolutePath) ----------------
namespace {
// Any leading '/' or '\' (POSIX root / UNC), or a leading <alpha>: (Windows drive,
// incl. drive-relative "C:foo") is absolute. Same rejection bank_model applies to
// Sample.relativePath the manifest holds the SAME kind of path, so the invariant
// must match exactly (a path the index accepts must be recordable, and vice versa).
// incl. drive-relative "C:foo") is absolute — same rejection bank_model applies to
// Sample.relativePath; the manifest holds the same kind of path, so the invariant
// must match exactly.
bool isAbsolutePath(const std::string& p) {
if (p.empty()) return false;
if (p[0] == '/' || p[0] == '\\') return true;
@@ -35,9 +29,7 @@ bool isAbsolutePath(const std::string& p) {
} // namespace
// ---------------------------------------------------------------------------
// mutation / query
// ---------------------------------------------------------------------------
// -- mutation / query -------------------------------------------------------
ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) {
if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath;
@@ -53,9 +45,7 @@ bool OwnedFileManifest::contains(const std::string& relativePath) const {
return false;
}
// ---------------------------------------------------------------------------
// JSON writer (shared core/json escape — byte-identical to the prior local one)
// ---------------------------------------------------------------------------
// -- JSON writer --------------------------------------------------------
std::string OwnedFileManifest::serialize() const {
std::string out = "{\"owned\":[";
@@ -67,11 +57,8 @@ std::string OwnedFileManifest::serialize() const {
return out;
}
// ---------------------------------------------------------------------------
// 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.
// ---------------------------------------------------------------------------
// JSON parser: string-array-only grammar. Tolerates unknown keys and requires
// the "owned" value to be an array of strings.
namespace {
+14 -29
View File
@@ -1,31 +1,16 @@
#pragma once
// owned_manifest — the pure core of the owned-file manifest seam (Phase B, B-cap).
// owned_manifest — the set of files the bank system ITSELF created; every file the
// capture path writes gets recorded here so prune can tell the system's own orphans
// (owned ∩ present referenced) apart from hand-dropped files. Writes and persists
// the manifest only — no prune logic lives here.
//
// 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_codec / tab_strip.
// NOT a mirror of the bank index: removing/moving an index entry does NOT remove
// the file's manifest record (the manifest tracks files *created*; prune reconciles
// manifest-vs-index later). Only the capture add-path adds to it — no remove verb.
//
// -- What it is --------------------------------------------------------------
//
// The set of files the bank system ITSELF created — every file the capture path
// writes gets recorded here. Phase R prune consumes it to tell the system's own
// orphans (owned ∩ present referenced) apart from hand-dropped files. B-cap only
// WRITES and PERSISTS the manifest; no prune logic lives here (fork R-D, settled
// 2026-07-24: "defer the feature, design the seam").
//
// -- What it is NOT ----------------------------------------------------------
//
// It is NOT a mirror of the bank index. Removing or moving an index entry does NOT
// remove the file's manifest record: the manifest tracks files *created*, and prune
// (Phase R) reconciles manifest-vs-index later. The ONLY thing that adds to it is
// the capture add-path. There is deliberately no remove verb here.
//
// -- The relative-paths-only invariant ---------------------------------------
//
// A manifest path is ALWAYS project-relative (same invariant as Sample.relativePath
// 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 BankModel::add's rejection).
// Paths are ALWAYS project-relative (same invariant as Sample.relativePath). add()
// rejects an absolute path rather than guess a relativization — the pure model has
// no project root, so "normalizing" could point at the wrong file.
#include <optional>
#include <string>
@@ -58,12 +43,12 @@ public:
// capture of an identical request does not double-record.
ManifestAddResult add(const std::string& relativePath);
// True iff the exact path string is recorded. Phase R uses this to attribute a
// present file to the bank system. Exact string match — path normalization (if any)
// is the caller's concern, consistent across add and query.
// True iff the exact path string is recorded. Prune uses this to attribute a
// present file to the bank system. Exact string match — path normalization (if
// any) is the caller's concern, consistent across add and query.
bool contains(const std::string& relativePath) const;
// The owned paths in insertion order. Phase R unions this with the on-disk file
// The owned paths in insertion order. Prune unions this with the on-disk file
// set; here it is the round-trip + query surface.
const std::vector<std::string>& paths() const { return paths_; }
+15 -28
View File
@@ -4,26 +4,16 @@
#include "core/wire/wire.h"
// provenance implementation — pure, self-contained (no third-party lib, mirror of
// bank_model's hand-rolled encoding discipline).
// provenance implementation — pure, self-contained encoding.
//
// ENCODING (the fingerprint string): a length-prefixed, field-ordered format so it
// is unambiguous and forge-proof (a value containing the separator cannot shift
// the parse). Grammar:
//
// "rsprov1" -- magic + version tag
// then, in fixed order, each field as <len>':'<bytes>
//
// Every field — including numbers — is emitted as its decimal / %.17g text then
// length-prefixed, so the parser never has to guess a field boundary. A trailing
// field is the track-GUID count followed by that many length-prefixed GUIDs, then
// the folded fxChainIdentity. Numbers use the SAME %.17g the bank model uses so a
// double round-trips bit-for-bit. Any deviation (wrong magic, short read, bad
// number) -> parseFingerprint returns nullopt.
//
// The fxChainIdentity fold is itself length-prefixed per entry field, so it is
// injection-proof on its own and can be embedded whole as one more length-prefixed
// field of the fingerprint.
// Fingerprint grammar: magic "rsprov1" + fixed-order length-prefixed fields
// (<len>':'<bytes>), so a value containing the separator can never shift the
// parse. Numbers render as decimal/%.17g text before prefixing (same %.17g the
// bank model uses, so doubles round-trip bit-for-bit). Trailing fields: the
// track-GUID count + that many GUIDs, then the folded fxChainIdentity — itself
// length-prefixed per entry field, so it nests safely as one more field. Any
// deviation (bad magic, short read, bad number) -> parseFingerprint returns
// nullopt.
namespace reasampler::model {
@@ -39,10 +29,9 @@ namespace {
constexpr const char* kMagic = "rsprov1";
// 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.
// The shared core/wire codec carries the field grammar + range-checked fieldInt.
// Only the %.17g double rendering stays local — this writer's convention, shared
// with the bank model's JSON doubles.
using wire::putField;
using Cursor = wire::Cursor;
@@ -112,10 +101,9 @@ std::optional<CaptureRecipe> 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.
// 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.
if (guidCount > fingerprint.size() / 2u + 1u) return std::nullopt;
r.trackGuids.reserve(guidCount);
for (std::size_t i = 0; i < guidCount; ++i) {
@@ -139,7 +127,6 @@ std::optional<std::string> detectParent(
std::optional<std::string> parent; // the single bank sample all sources point at
for (const std::string& src : sourceItemFiles) {
// Resolve this source file against the bank by exact normalized path.
const std::string* matchedId = nullptr;
for (const BankFileRef& ref : bankFiles) {
if (!ref.absolutePath.empty() && ref.absolutePath == src) {
+23 -47
View File
@@ -1,35 +1,17 @@
#pragma once
// provenance — the REAPER-free core behind Milestone 10 (re-capture from source).
// provenance — the REAPER-free core behind re-capture-from-source. The shell
// gathers raw inputs from REAPER (source media-file names, FX-chain identity,
// capture range/scope/tail) and hands plain strings/values here. Owns:
// * CaptureRecipe — the recorded request + source FX-chain identity, so
// re-capture can re-run the same request and detect drift.
// * fingerprint codec — encodes/decodes a recipe into the single
// Provenance.fxChainSnapshot string (no schema change).
// * fxChainIdentity — folds FX-chain rows into one drift-detection string.
// * detectParent — pure parent-detection: resolved file path only, no
// fuzzy match, no false parentage.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// 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
// owns:
//
// * CaptureRecipe — the recorded capture request PLUS the source FX-chain
// identity at capture time. Everything "re-capture from
// source" needs to re-run the SAME request against the
// source's CURRENT state, and to tell whether the source
// drifted since capture.
// * the ENCODING of a recipe into the single `Provenance.fxChainSnapshot`
// string (M1's field already JSON-round-trips one string,
// so the whole thin fingerprint rides in it — no schema
// change to Sample).
// * fxChainIdentity — folds the shell-gathered FX-chain rows into one identity
// string (the drift-detection component of the fingerprint).
// * detectParent — the pure parent-detection decision: given the resolved
// absolute media-file path(s) of the capture's source item(s)
// and the bank's path->sampleId map, decide whether this
// capture genuinely derives from a bank sample (P1: identity
// by resolved file path only — no fuzzy match, no false
// parentage).
//
// Fork picks (docs/product/provenance.md, settled 2026-07-23): P1 = a THIN
// reproducibility fingerprint (drift-detect + re-run the same request), NOT a
// serialized FX chunk to restore. P2 = bank-only re-capture. So nothing here
// stores a restorable chain, and nothing here reaches into view_mode_model.
// A THIN reproducibility fingerprint (drift-detect + re-run) — NOT a serialized FX
// chunk to restore; nothing here stores a restorable chain.
#include <optional>
#include <string>
@@ -111,7 +93,7 @@ std::string buildFingerprint(const CaptureRecipe& recipe);
// mis-driving a re-capture.
std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint);
// --- Parent detection (P1: identity by resolved file path) -------------------
// -- Parent detection: identity by resolved file path only --------------------
// One bank sample as the detector sees it: its stable id and the ABSOLUTE,
// normalized path its file resolves to (the shell resolves relativePath against
@@ -122,26 +104,20 @@ struct BankFileRef {
std::string absolutePath; // normalized (forward-slash, no trailing slash)
};
// Decides whether a capture derives from a bank sample.
//
// RULE (stated for the handoff, honest — no false parentage): a capture derives
// from a bank sample iff EVERY source item whose media file could be resolved
// points at the SAME bank sample's file (by exact normalized absolute path). If
// the source items resolve to files not in the bank, or to MORE THAN ONE distinct
// bank sample (ambiguous parentage), no parent is recorded. An empty source-file
// set (nothing resolvable) yields no parent.
// Decides whether a capture derives from a bank sample. No false parentage: a
// capture derives from a bank sample iff EVERY source item whose media file could
// be resolved points at the SAME bank sample's file (exact normalized absolute
// path). Files not in the bank, or matching MORE THAN ONE distinct bank sample
// (ambiguous), yield no parent. An empty source-file set yields no parent.
//
// sourceItemFiles : normalized absolute paths of the capture's source items'
// take media files (the shell gathers + normalizes them). A
// file that could not be resolved is simply omitted by the
// shell — it never becomes an empty string here.
// take media files, gathered by the shell. A file that could
// not be resolved is simply omitted — never an empty string.
// bankFiles : the active book's samples as BankFileRefs (path -> id).
//
// Returns the parent sample id, or nullopt when the capture is not a genuine
// resample-from-sample. Comparison is exact path identity; the caller normalizes
// both sides identically via normalizeSlashes (which lowercases on Windows) so a
// slash/case difference never spuriously matches or misses. On Windows both sides
// are lowercased before they reach here; on macOS/Linux they are case-exact.
// Returns the parent sample id, or nullopt otherwise. Both sides are normalized
// identically via normalizeSlashes (lowercased on Windows) so a slash/case
// difference never spuriously matches or misses.
std::optional<std::string> detectParent(
const std::vector<std::string>& sourceItemFiles,
const std::vector<BankFileRef>& bankFiles);
+2 -6
View File
@@ -4,12 +4,10 @@
#include "core/json/json.h"
// slot_map implementation (extracted from bank_book, Q-W1 T4-05).
// slot_map implementation.
//
// 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.
// slot per id. Every mutator restores it; queries assume it.
namespace reasampler::model {
@@ -128,8 +126,6 @@ SlotMap SlotMap::fromEntries(const std::vector<std::pair<std::string, int>>& pai
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) {
+12 -20
View File
@@ -1,21 +1,13 @@
#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).
// slot_map — the gap-preserving display-position carrier for ONE bank: plain
// interchangeable slots, not fixed/addressable ones. A slot is a display position a
// sample id occupies; the map is sample id -> slot (>= 0). Gaps are first-class (a
// bank may have slot 1 occupied with slot 0 empty). At most one id per slot, at
// most one slot per id.
//
// 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.
// Position lives HERE, not on Sample: 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.
#include <cstddef>
#include <string>
@@ -50,7 +42,7 @@ public:
// 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):
// Moves `id` to `targetSlot`, gap-preserving:
// * 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,
@@ -62,9 +54,9 @@ public:
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.
// dropping any prior state. The migration path: a bank with no persisted slot
// data is seeded from its BankModel insertion order, densely packed (no gaps),
// so it is visually identical on first load. Empty/duplicate ids skipped.
void resetDense(const std::vector<std::string>& ids);
// Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left

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