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. 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 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 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 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 stored **rate-free / device-free and resolved at the point of use** (`docs/product/code-organization.md`
bullet; `docs/product/code-organization.md` §2c.3; the load-bearing `sample_map` seconds §2c.3 env-coupled bullet; the load-bearing `sample_map` seconds invariant, documented in
invariant). Findings are domain-modeling calls, not "rescale by rate" patches. The judgment bar `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 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 actually change under it. Frame counts computed transiently from seconds at the use site are
correct and are not reported. 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. normalize the Trigger fade-in/out knobs.
- **Stored vs. correct domain:** the fade **storage** domain (int64 SOURCE frames, persisted in - **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- 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 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. 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 - **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 `SampleRefs` (paths + root/loop/channels intrinsics), `instanceGuid` — all rate-free or
file-fact domains. Clean. file-fact domains. Clean.
- **Trigger `fadeInFrames`/`fadeOutFrames`/`startPoint`/`SampleLoop.start/end` persisted as - **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 `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 project-rate change; the file's own rate is stored alongside and resolved at decode. Correct
domain, not a finding. domain, not a finding.
+16 -15
View File
@@ -1,19 +1,20 @@
# Capture tail — spec # Capture tail — spec
Authoritative spec for the **capture-tail** feature: preserving reverb/release 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 tails that decay past the end of a capture range. The tickable milestone's landed
`PLAN.md` (Milestone T); this doc holds the full technical detail **and** the history is in `docs/ARCHIVE.md` (Milestone T); this doc holds the full technical
product framing. detail **and** the product framing.
> **Why this doc carries the technical spec (not `CONTEXT.md`).** Every other > **Why this doc carries the technical spec (not the architecture docs).** Every
> pillar (capture M0M11, Design View, Multi-bank) keeps its authoritative > 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/` > technical spec as a per-directory `src/**/CLAUDE.md` section and its *why* in a
> note. Capture-tail is a rider on the already-shipped offline-render path > `docs/product/` note. Capture-tail is a rider on the already-shipped
> (M3/M7), not a standalone pillar, and it is being specced without reopening > offline-render path (M3/M7), not a standalone pillar, and it was specced without
> `CONTEXT.md`. So the authoritative detail lands **here**, house-styled to match > reopening the architecture spec. So the authoritative detail lands **here**,
> the CONTEXT specs; when the tail work lands, doc-keeper may fold the invariant > house-styled to match those specs; the landed invariant deltas are folded into
> deltas into `CONTEXT.md §Precision invariants` as landed history. Same standing > root `CLAUDE.md` §Precision invariants and `src/core/capture/CLAUDE.md` /
> discipline applies: **verify every REAPER API name/flag against > `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 > `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. > 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 - **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 when a tail is explicitly requested (Auto or Manual). This is exactly the existing
invariant: *"no added silence unless a tail is explicitly requested"* 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 strengthens it — the tail added is decay, not silence, and the silence past the
decay is trimmed off. decay is trimmed off.
- **The null test uses NO tail.** The null-test / verify capture (M10) and any - **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 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 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 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 note the pre-existing send-isolation caveat, which the tail inherits
the tail inherits unchanged, does not worsen). Correct and consistent. unchanged, does not worsen). Correct and consistent.
- **Item scope:** item/take FX only, self-track + ancestors + master bypassed → - **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 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. 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.**" > 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 Its build roadmap's landed history lives in **`docs/ARCHIVE.md`** ("Phase Q — Quality:
**CONTEXT.md §Phase Q**. This doc holds the *why* — the quality bar, the evidence base 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 (a grep-verified SOLID audit), the target directory/namespace shape grounded in the Vital
reference, and the numbered fork decisions. 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). 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 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 itself is staff-engineer/DSP work. The wave's landed history lives in **`docs/ARCHIVE.md`**
its spec framing in **CONTEXT.md §"The pre-restructure audit wave (Q-W0)"**; this section is the ("Q-W0 fix-now remediations" and "Phase Q — Quality: the decision record"), and its findings live in
evidence-doc framing (the *why* and the smell taxonomy), matching how §2/§2b frame the structural **`docs/product/code-quality-audit.md`**; this section is the evidence-doc framing (the *why* and
audits. the smell taxonomy), matching how §2/§2b frame the structural audits.
### 2c.1 Audit surfaces (the named targets) ### 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) - **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`), 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 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 - **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) 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 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. 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 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 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 are the evidence base for every claim here — this report cites finding IDs and does not restate
mechanisms in full: mechanisms in full:
+4 -4
View File
@@ -1,8 +1,9 @@
# Design View — product notes # Design View — product notes
Framing, rationale, and design-direction calls behind the **Design View** phase. Framing, rationale, and design-direction calls behind the **Design View** phase.
The tickable spec lives in `PLAN.md` (Phase D) and the authoritative technical The tickable spec's landed history lives in `docs/ARCHIVE.md` (Phase D) and the
detail in `CONTEXT.md` (§Design View). This doc holds the *why* — the workflow 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 narrative, the N-mode reasoning, the screenset differentiation, and the
design-direction recommendations — so those don't clutter the build docs. 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 `docs/design/`), say so and it moves. Flagging because establishing the
directory is a project convention, not mine to assume silently. directory is a project convention, not mine to assume silently.
2. **Phase namespace.** Proposed **Phase D** (a lettered namespace) rather than 2. **Phase namespace.** Proposed **Phase D** (a lettered namespace) rather than
M12 — see PLAN.md rationale. Confirm you're happy with letters for parallel, M12. Confirm you're happy with letters for parallel, non-capture phases.
non-capture phases.
3. **SDK name correction.** The brief cited `B_SHOWINMCP`; the verified SDK flag 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 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 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 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 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 the **product framing behind a scoped phase**. Its build roadmap lives in
§Phase S** and its authoritative spec in **CONTEXT.md §Phase S** — this doc holds the **`docs/ARCHIVE.md` §Phase S** and its authoritative spec in
*why* (the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, the settled **`src/core/instrument/CLAUDE.md`** and **`src/shell/instrument/CLAUDE.md`** — this
decision record). 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 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 → **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 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 overhaul** (Phase S points S10S13) — see the r5 Addendum in §4. r4 (below) settled the four
residual forks D-A..D-D. The 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" 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 collides with the existing Design View phase). **`docs/ARCHIVE.md` §Phase S now records
authoritative roadmap; CONTEXT.md §Phase S is the authoritative spec.** This doc is the 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 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 *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 **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 between extension and instrument as **live shared `"reasampler"` state**, not a file one
writes and the other re-parses. writes and the other re-parses.
**What the current index carries** (from `bank_model`'s `Sample`, per CONTEXT.md §Data **What the current index carries** (from `bank_model`'s `Sample`): id, display name,
model): id, display name, relative path, source range, channel count, sample rate, relative path, source range, channel count, sample rate,
length, capture tempo, an **optional key**, peak/RMS/LUFS, content hash, tier, length, capture tempo, an **optional key**, peak/RMS/LUFS, content hash, tier,
provenance, timestamp. Notably it *already* has an optional key field and capture provenance, timestamp. Notably it *already* has an optional key field and capture
tempo — the seeds of pitch-mapping are there. tempo — the seeds of pitch-mapping are there.
@@ -426,8 +428,8 @@ tempo — the seeds of pitch-mapping are there.
to velocity zones). to velocity zones).
- **Round-robin groups** (cycle through N samples on repeated same-note hits). - **Round-robin groups** (cycle through N samples on repeated same-note hits).
- **Loop points** (sustain loop start/end for held notes; sample-accurate, - **Loop points** (sustain loop start/end for held notes; sample-accurate,
zero-crossing-aware — CONTEXT already flags loop/zero-crossing handling as zero-crossing-aware — loop/zero-crossing handling is day-one-relevant for
day-one-relevant for wavetable material). wavetable material).
- **Amplitude envelope** (ADSR) and optionally filter/pitch envelopes. - **Amplitude envelope** (ADSR) and optionally filter/pitch envelopes.
- **Tuning/gain trim** per sample. - **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 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 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. **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 §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 **settled directions**, not open forks — specced as new Phase S points (S7S9), not
re-litigated. Recorded here per the doc's settled-decisions convention. 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 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 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 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 never a bank fact). Sequenced **first after the editor/embed work** because it touches the
engine Daniel smoke-tests. engine Daniel smoke-tests.
**Ingest routes through the bank — "option 1"; the extension owns ingest (→ PLAN.md **Ingest routes through the bank — "option 1"; the extension owns ingest (→
S8 + S9).** Loading a sample into the sampler is **one gesture**: capture/import-into-bank `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 + 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 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, **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 *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 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) ### 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** **The instrument's product name is `ReaSampler 9000`.** The extension stays **ReaSampler**
(capture + organization); the instrument is **ReaSampler 9000** (playback). Propagation is (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 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 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 **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 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 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 directions, not open forks — recorded here per the doc's settled-decisions convention;
§S10 and CONTEXT.md §Phase S (workflow hierarchy) carry the spec. `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 **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 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 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 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 here per the doc's settled-decisions convention; `docs/ARCHIVE.md` §S15 / §S16 records what
modes carry the spec. Two forks are flagged with leans (S15-F1 choke, S15-F2 param 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. granularity); the WDL question was resolved by inspection.
**Directive, verbatim (Daniel, 2026-07-26):** *"let's have product spec out some features **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 `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. 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", **Where the spec lives:** `docs/ARCHIVE.md` §S16 (reshaped to "pitch engine modes + pitch
with forks S16-F1/F2 and the corrected WDL finding) and the S15 × S16 interaction note; envelope", with forks S16-F1/F2 and the corrected WDL finding) and the S15 × S16
CONTEXT.md §Pitch engine modes — Varispeed vs Preserve + the corrected WDL surface finding. interaction note; `src/core/instrument/CLAUDE.md` §Sampling modes — Varispeed vs Preserve
+ the WDL surface finding.
### Addendum — VST channel isolation (Daniel, 2026-07-26) ### 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 channel's banks; stable-project + beta-VST = clean empty (not error); the S-NAME-1
rename/rebind test extends to the beta UID. rename/rebind test extends to the beta UID.
**Where the spec lives:** PLAN.md §S18; CONTEXT.md §VST3 channel identity — the UID pair + the **Where the spec lives:** `docs/ARCHIVE.md` §S18; `src/shell/instrument/CLAUDE.md` §VST3
pairing surface. The pairing surface's data half is already load-bearing V4 machinery; S18 channel identity — the UID pair + the pairing surface. The pairing surface's data half is
adds only the identity fork on top. 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 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 — 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 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)**; `r9` calls. Current architecture: **`src/core/instrument/CLAUDE.md`** and
build roadmap: **PLAN.md §Phase S — editor view-model redesign**. **`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** **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 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 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" 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 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 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 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** 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 (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 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 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; window size: the machinery is SDK-correct, so this is a *diagnosis* task, not a redesign) and §S-VIEW-SIZE-1
`getSize`/`checkSizeConstraint` mechanism is verified), and both are flagged for (window size: the `getSize`/`checkSizeConstraint` mechanism is verified), and both were
staff-engineer, not for a product fork. flagged for staff-engineer, not for a product fork.
**New parameters this introduces (both instrument performance state, D-B — never bank facts):** **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 — 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 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 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. preview button *fun*: tap it hard or soft without reaching for a controller — and it remembers.
**Two visual components the redesign commits to:** **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 (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. 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 **Where the spec lives:** `src/core/instrument/CLAUDE.md` (envelope overlay, key-tracking,
§Phase S — editor view-model redesign. This Addendum is the *why*; those are the *what/how*. 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 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. 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 **Where the spec lives:** `src/core/instrument/CLAUDE.md` (the `velocity_curve` module, its
sub-section; PLAN.md §Phase S — editor view-model redesign (S-VIEW-9/S-VIEW-10 + fork R10-F1). This 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*. 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 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). 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 **What does NOT change (guardrails).** Zero engine-behavior change — this is a view
recomposition of *existing* controls; no new params, no component-state bump, VST3 class UID recomposition of *existing* controls, with one additive exception: the new post-mixer master
unchanged. All drawing through the L1 kit by palette role; all layout/hit-test in new pure modules 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 (`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). 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 **Where the spec lives:** `src/core/instrument/CLAUDE.md` (the `knob_deck`/`curve_popup`/
Sample-face recomposition (r11)"; PLAN.md §Phase S — editor Wave B (S-VIEW-11/12/13 + forks `master_gain` modules) documents the current architecture; `docs/ARCHIVE.md` §FB1 and §FB2
R11-F1/R11-F2). This Addendum is the *why*; those are the *what/how*. 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 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 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 + 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 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, Daniel's "the VST is dogshit / temple os / does Cockos have a toolkit" (2026-07-26,
post-S1S6 DAW test). 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; S15-F1 (choke, held) / S15-F2 (param granularity, lean per-zone). Feature set settled;
the engine default is Daniel's fork. the engine default is Daniel's fork.
**Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1S6 the original **Authoritative from here:** **`docs/ARCHIVE.md` §Phase S** is the landed roadmap (S1S6
dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded the original dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier
UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10S13** the ReaSampler 1 → embedded UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10S13** the
9000 UX overhaul, **S15/S16** the Trigger-vs-Gate + pitch-engine-modes engine features); ReaSampler 9000 UX overhaul, **S15/S16** the Trigger-vs-Gate + pitch-engine-modes engine
**CONTEXT.md §Phase S** is the spec (seam-field semantics, scope contracts, the channel-mode features); **`src/core/instrument/CLAUDE.md`**, **`src/shell/instrument/CLAUDE.md`**, and
/ ingest / bank-generation / sampling-mode / pitch-engine contracts, the UX-overhaul spec, **`src/core/wire/CLAUDE.md`** are the current spec (seam-field semantics, scope contracts,
the product-name convention, the pure/shell split, the WDL finding, the must-verify the channel-mode / ingest / bank-generation / sampling-mode / pitch-engine contracts, the
SDK/bridge surfaces). This doc is the framing/decision record they point back to. The "no UX-overhaul spec, the product-name convention, the pure/shell split, the WDL finding, the
PLAN.md footprint" era is over. 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 # Multi-bank — product notes
Framing, rationale, and design-direction calls behind the **Multi-bank** phase. Framing, rationale, and design-direction calls behind the **Multi-bank** phase.
The tickable spec lives in `PLAN.md` (Phase B) and the authoritative technical The tickable spec's landed history lives in `docs/ARCHIVE.md` (Phase B) and the
detail in `CONTEXT.md` (§Multi-bank). This doc holds the *why* — the workflow 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 narrative, the pool-privilege reasoning, the movement semantics, and the
design-direction recommendations — so those don't clutter the build docs. design-direction recommendations — so those don't clutter the build docs.
+4 -4
View File
@@ -1,10 +1,10 @@
# Provenance — product notes # Provenance — product notes
Framing, rationale, and the dual-canvas reconciliation behind the reshaped Framing, rationale, and the dual-canvas reconciliation behind the reshaped
**Milestone 10 (provenance)**. The tickable spec lives in `PLAN.md` (M10); the **Milestone 10 (provenance)**. The tickable spec's landed history is in
authoritative technical detail is `CONTEXT.md` (§Data model, §capture) plus this `docs/ARCHIVE.md` (M10); the architecture detail is in `src/core/model/CLAUDE.md`
note for the reconciliation calls. This doc holds the *why* and the open forks so and `src/shell/capture/CLAUDE.md` plus this note for the reconciliation calls.
they don't clutter the build docs. 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. 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 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: Framing, rationale, and open forks behind the two missing removal capabilities:
**sample-remove** (a sample-level index verb) and **prune** (the file-lifecycle **sample-remove** (a sample-level index verb) and **prune** (the file-lifecycle
path CONTEXT.md keeps forward-referencing but never scoped). The tickable spec path the spec kept forward-referencing but never scoped). The tickable spec's
lives in `PLAN.md` (Phase B point B5 for remove; **Phase R** for prune) and the landed history is in `docs/ARCHIVE.md` (Phase B point B5 for remove; **Phase R**
authoritative technical detail in `CONTEXT.md` (§Sample removal, §Prune — file for prune) and the architecture detail lives in `src/core/model/CLAUDE.md` +
lifecycle). This doc holds the *why* — the workflow, the guardrails, the `src/shell/bank_ops/CLAUDE.md` (§Sample removal) and `src/core/reclaim/CLAUDE.md`
index-vs-file boundary, and the forks that need a Daniel decision. + `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 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 (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 (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 (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 decisions are folded into the fork sections below and into the B5 / Phase R
prose in CONTEXT.md and the tickable points in PLAN.md. history in `docs/ARCHIVE.md` and the architecture docs above.
--- ---
## The one boundary that governs everything: index vs. file ## 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 operation touches the *index*, never the *file*.** Move, copy, evacuate, and
delete-bank are all index-only; files persist on disk "until prune." Every 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 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 ### 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, cost disk and clutter. **Prune is the reclaim pass**: "sweep the bank folder,
delete the files nothing references, tell me what you reclaimed." 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 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** scopes. It is a real, promised capability with **no phase, no module, no point**
— a dangling reference the plan has to make good on. — 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` ReaSampler already shipped this exact shape once. Design View's `view_mode_model`
has **`ViewModeModel::reconcile(liveGuids)`** — a pure function fed the live set has **`ViewModeModel::reconcile(liveGuids)`** — a pure function fed the live set
(the tracks that still exist), returning the residual membership entries to drop (the tracks that still exist), returning the residual membership entries to drop
(CONTEXT.md §Design View: "prunes orphaned snapshots on every toggle/load; (`src/core/view/CLAUDE.md`: "tolerates unknown/stale GUIDs (pruned on reconcile
tolerates unknown/stale GUIDs (prune on reconcile)"). Prune is the **file-pool via `ViewModeModel::reconcile(liveGuids)`)"). Prune is the **file-pool
mirror of that pure pattern**: mirror of that pure pattern**:
> `reconcile(liveGuids)` reconciles *membership entries* against *live tracks*. > `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 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* (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 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 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. 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 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 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 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. **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 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 2. **A beta side-channel** — so development can continue and a beta build run
*alongside* the stable one without the beta clobbering the release. *alongside* the stable one without the beta clobbering the release.
This doc holds the *why*, the forks, and a recommendation. When Daniel picks, the This doc holds the *why*, the forks, and a recommendation. The tickable points'
tickable points land in `PLAN.md` and the deploy/build wiring hands off to dev-ops. 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. 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 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 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 **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 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 beta that writes a newer schema into a project a user then reopens in stable is a
real corruption path, not a theoretical one. 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 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 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 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 font bundling/redistribution; the kit keeps its current cached-font face.) The build points it
**PLAN.md §Phase L** (**L1** the shared LICE drawing kit, **L2** the dock-panel layout fed have landed; their history is in **`docs/ARCHIVE.md`** ("Phase L — Look-and-feel", **L1** the
redesign, **L3** the VST editor + embed-strip restyle) and **CONTEXT.md §Phase L** (the shared LICE drawing kit, **L2** the dock-panel layout redesign, **L3** the VST editor +
design-system spec). Toolkit facts below are **verified against the vendored `vendor/WDL` embed-strip restyle) and the architecture now lives in **`src/core/ui/CLAUDE.md`** /
tree**, not lore. **`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 > **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 > 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. Three points and their sequencing.
@@ -547,7 +548,7 @@ draw it.
affordances, per-selection **move / copy / remove** sample menu. affordances, per-selection **move / copy / remove** sample menu.
- **Prune** button (R-E) — the byte-deleting action, `warn`-colored. - **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 trigger buttons** — clickable buttons that fire the capture + provenance
action family directly (capture item / capture track scopes, re-capture from source, 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, 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. // main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers.
// //
// This file is the entire contract between REAPER and the extension: // REAPER dlopen()s reaper_*.dll|dylib|so from UserPlugins/ and calls the exported
// * At startup REAPER scans UserPlugins/ for reaper_*.dll|dylib|so and // ReaperPluginEntry, handing over `rec` (rec->GetFunc resolves API pointers,
// dlopen()s each one, then looks up ONE exported symbol: ReaperPluginEntry // rec->Register plugs our callbacks in). Exactly ONE .cpp defines
// (that name is produced by the REAPER_PLUGIN_ENTRYPOINT macro). // REAPERAPI_IMPLEMENT (this one) — that allocates storage for the global API
// * REAPER calls it, handing over `rec` — a small dispatch struct. // pointers every other TU gets `extern`. Never let a second TU define it.
// - 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...).
// //
// Exactly ONE .cpp defines REAPERAPI_IMPLEMENT (this one) — that allocates // This TU is ONLY pointers + entry + dispatch. Its own action family registers
// storage for those global pointers. Every other .cpp includes // through the data-driven table below (buildMainActionTable + action_registry) —
// reaper_plugin_functions.h WITHOUT the define and gets `extern` declarations. // 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
// Since Q-W3 this TU is ONLY pointers + entry + dispatch; since Q-W6 its own // triples, called from entry.
// action family registers through the DATA-DRIVEN TABLE below (kMainActionRows +
// action_registry's registerActionTable/actionTableHandleCommand/
// unregisterActionTable) — adding a bindable action here means adding ONE row and
// its handler function, nothing else (OCP). The design_view / bank / ingest
// families keep their own register/handle/unregister triples, called from entry.
#define REAPERAPI_IMPLEMENT #define REAPERAPI_IMPLEMENT
#include "reaper_plugin.h" #include "reaper_plugin.h"
@@ -32,9 +23,9 @@
#include "core/capture/render_settings.h" // captureActionTable #include "core/capture/render_settings.h" // captureActionTable
#include "core/version/app_version.h" // appVersion #include "core/version/app_version.h" // appVersion
#include "ingest.h" #include "ingest.h"
#include "shell/actions/action_registry.h" // the Q-W6 registration table #include "shell/actions/action_registry.h" // the registration table
#include "shell/actions/bank_actions.h" // multi-bank action family (B3; Q-W4 home) #include "shell/actions/bank_actions.h" // multi-bank action family
#include "shell/actions/design_view_actions.h" // Design View action family (D4; Q-W4 home) #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_batch.h" // batch + recapture action bodies
#include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies #include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies
#include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver #include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver
@@ -46,20 +37,13 @@
namespace capture = reasampler::capture; namespace capture = reasampler::capture;
// Globals other files reference via `extern`. // Globals other files reference via `extern`.
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle REAPER_PLUGIN_HINSTANCE g_hInst = nullptr;
reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct reaper_plugin_info_t* g_rec = nullptr;
// Retired command-id SUFFIXES. Kept ONLY to mirror-unregister them on unload so a // Retired command-id SUFFIXES: kept ONLY to mirror-unregister on unload so a user's
// user's stale keybindings are cleaned up. Never re-register these. Composed through // stale keybindings are cleaned up. Never re-register these. The four-mode WET ids,
// the channel prefix at unload (channelIdFor) so a beta unload clears beta-qualified // the removed master scope/realtime actions, and the removed per-action tail variants
// retired ids and a stable unload clears stable's — each channel cleans up only its // (tail is now a panel toggle, not a paired action).
// 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.
static const char* const kRetiredCaptureCmdSuffixes[] = { static const char* const kRetiredCaptureCmdSuffixes[] = {
"CAPTURE_TRACKS_WET", "CAPTURE_TRACKS_WET",
"CAPTURE_ITEMS_WET", "CAPTURE_ITEMS_WET",
@@ -70,35 +54,30 @@ static const char* const kRetiredCaptureCmdSuffixes[] = {
"CAPTURE_TRACK_TAIL", "CAPTURE_TRACK_TAIL",
}; };
// The persistence session (M4): owns the in-memory BankModel and bridges it to // Owns the in-memory BankModel and bridges it to project ext state. A timer tick
// project ext state. A timer tick drives g_session.poll() to detect project // drives g_session.poll() to detect project load / Save-As; capture adds Samples to
// load / Save-As; capture adds Samples to g_session.bank() — which (B2) resolves to // g_session.bank() (resolves to the active bank's index), and we serialize the book
// the ACTIVE bank's index inside the session's BankBook; after a capture we serialize // back into the active project's ext state (the `banks` key) so it travels with the .rpp.
// 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.
static reasampler::ReaSamplerSession g_session; static reasampler::ReaSamplerSession g_session;
// Command id of the TOGGLE_BANK_PANEL row, resolved from the table once at load so // 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). // OnToggleAction's checked-state poll is a single int compare (no per-poll lookup).
static int g_cmdToggleBankPanel = 0; static int g_cmdToggleBankPanel = 0;
// --- Action handlers (the table's 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
// Each is a thin stateless routing shim: (session, per-row arg) -> the action body // plain data with flat function pointers.
// hoisted in Q-W3/Q-W4 (shell/capture/, shell/panel/). The bodies own all behavior;
// these exist only so the table rows can be plain data with flat function pointers.
// Capture scope family: `arg` is the captureActionTable() row index — the table rows // `arg` is the captureActionTable() row index — the table rows below are built by
// below are built by iterating that pure taxonomy, so the routing stays 1:1 by // iterating that pure taxonomy, so the routing stays 1:1 by construction.
// construction (never a hand-kept parallel list).
static void RunCaptureScopeRow(int arg) { static void RunCaptureScopeRow(int arg) {
capture::RunCapture(g_session, capture::RunCapture(g_session,
capture::captureActionTable()[static_cast<std::size_t>(arg)]); capture::captureActionTable()[static_cast<std::size_t>(arg)]);
} }
static void RunToggleBankPanel(int) { reasampler::bankPanelToggle(); } static void RunToggleBankPanel(int) { reasampler::bankPanelToggle(); }
static void RunCaptureItemAssign(int) { capture::RunCaptureItemAssign(g_session); } static void RunCaptureItemAssign(int) { capture::RunCaptureItemAssign(g_session); }
// Insert: `arg` != 0 is the EXPLICIT conform-to-project-tempo opt-in (CONTEXT.md // `arg` != 0 is the EXPLICIT conform-to-project-tempo opt-in (never silent); 0
// §insert: conform is opt-in, never silent); 0 inserts at native length. // inserts at native length.
static void RunInsertSelected(int arg) { static void RunInsertSelected(int arg) {
capture::RunInsertSelected(g_session, arg != 0); 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 RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); }
static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); } static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); }
static void RunShowVersion(int) { static void RunShowVersion(int) {
// On-demand version readout — the ONLY version output on any path (Phase V: no // On-demand only — no unconditional startup print (routine console chatter pops
// unconditional startup print; routine console chatter pops the console window). // the console window).
ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str()); 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, Actions-list
// // phrase, handler, per-row arg. Registration, hookcommand dispatch, and the unload
// ONE row per bindable action this TU owns: FOREVER-STABLE id suffix (channel prefix // mirror-unregister all iterate this data. The capture scope rows come first,
// composed at register — stable rebuilds the exact shipped id, e.g. // sourced from the pure captureActionTable() taxonomy; the rest are this TU's singles.
// "CEREBELLUM_REASAMPLER_CAPTURE_TRACK"; beta its isolated forever-family), the
// Actions-list phrase (after the "ReaSampler[ beta]: " lead), the handler, and its
// per-row arg. Registration, hookcommand dispatch, and the unload mirror-unregister
// all iterate this data — adding an action = adding a row + a handler above.
//
// The capture scope rows (CAPTURE_ITEM / CAPTURE_TRACK) come first, sourced from the
// pure captureActionTable() taxonomy (render_settings) — suffix/phrase live in that
// one testable list, and `arg` carries the row index back to RunCapture. The
// remaining rows are this TU's singles, in the pre-table registration order.
static std::vector<reasampler::ActionTableRow> buildMainActionTable() { static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
using reasampler::ActionTableRow; using reasampler::ActionTableRow;
std::vector<ActionTableRow> rows; std::vector<ActionTableRow> rows;
@@ -135,40 +105,32 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
rows.push_back(ActionTableRow{cap[i].commandSuffix, cap[i].descriptionPhrase, rows.push_back(ActionTableRow{cap[i].commandSuffix, cap[i].descriptionPhrase,
&RunCaptureScopeRow, static_cast<int>(i)}); &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}); 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", rows.push_back({"CAPTURE_ITEM_ASSIGN",
"capture selected item into bank + assign to active instance", "capture selected item into bank + assign to active instance",
&RunCaptureItemAssign}); &RunCaptureItemAssign});
// M6: place the panel's selected sample at the edit cursor. Two variants that // Two variants differing ONLY in InsertOptions — native length vs conform opt-in.
// differ ONLY in InsertOptions — native length vs the explicit conform opt-in.
rows.push_back({"INSERT_SELECTED", "insert selected sample at edit cursor", rows.push_back({"INSERT_SELECTED", "insert selected sample at edit cursor",
&RunInsertSelected, 0}); &RunInsertSelected, 0});
rows.push_back({"INSERT_SELECTED_CONFORM", rows.push_back({"INSERT_SELECTED_CONFORM",
"insert selected sample at edit cursor (conform to tempo)", "insert selected sample at edit cursor (conform to tempo)",
&RunInsertSelected, 1}); &RunInsertSelected, 1});
// M11: one action fires N captures (per selected item / per razor area); the // One action fires N captures (per selected item / per razor area); the original
// original selection is restored on every exit path. Bank-only, never places. // selection is restored on every exit path. Bank-only, never places.
rows.push_back({"CAPTURE_BATCH_ITEMS", rows.push_back({"CAPTURE_BATCH_ITEMS",
"batch capture selected items (one per item)", "batch capture selected items (one per item)",
&RunBatchCaptureItems}); &RunBatchCaptureItems});
rows.push_back({"CAPTURE_BATCH_RAZOR", "batch capture razor areas (one per area)", rows.push_back({"CAPTURE_BATCH_RAZOR", "batch capture razor areas (one per area)",
&RunBatchCaptureRazor}); &RunBatchCaptureRazor});
// M8: realtime sibling of the offline CAPTURE_TRACK scope — records the selected // Realtime sibling of the offline CAPTURE_TRACK scope, plus its cancel-in-flight
// track's own output into a hidden temp track, dialog-free — plus its // companion (stop + restore, non-destructive).
// cancel-in-flight companion (stop + restore, non-destructive).
rows.push_back({"CAPTURE_TRACK_REALTIME", "capture selected track (realtime)", rows.push_back({"CAPTURE_TRACK_REALTIME", "capture selected track (realtime)",
&RunCaptureRealtime}); &RunCaptureRealtime});
rows.push_back({"CANCEL_REALTIME_CAPTURE", "cancel realtime capture", rows.push_back({"CANCEL_REALTIME_CAPTURE", "cancel realtime capture",
&RunCancelRealtime}); &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", rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source",
&RunRecaptureFromSource}); &RunRecaptureFromSource});
// Phase V: on-demand version readout for bug reports.
rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion}); rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion});
return rows; return rows;
@@ -180,74 +142,52 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
static void OnTimer() static void OnTimer()
{ {
// Advance any in-flight realtime capture FIRST, so a project switch is caught and // 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. // torn down/restored before session.poll() reacts to that switch. LOAD-BEARING:
// LOAD-BEARING (CONTEXT.md §Phase Q): the idle fast-path is a SINGLE POINTER // the idle fast-path is a SINGLE POINTER TEST — drive only when a capture is live.
// TEST — the cross-TU drive call is made only when a capture is in flight.
if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session); if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session);
g_session.poll(); g_session.poll();
// D4 reapply-on-open glue. persist stays MODEL-ONLY (it loads the saved view // persist stays MODEL-ONLY (loads the saved view model but does not apply
// model but deliberately does NOT apply visibility — that would couple persist // visibility, to avoid coupling persist to the view shell); poll() raises a
// to the view shell). Instead poll() raises a one-shot load signal; here — the // one-shot load signal that we drain here to reapply the SAVED active mode so a
// integration layer that already drives both persist and the view shell — we // project saved in Design mode parks Arrange tracks automatically. The same
// drain it and reapply the SAVED active mode's visibility/processing so opening a // signal re-arms the bank panel's new-content detector — notified BEFORE the
// project saved in Design mode parks the Arrange tracks automatically, no manual // reapply so re-arm and model restore ride the one load event (otherwise
// toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks // pre-existing tracks can be mis-detected as "new" and mass-tagged).
// 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.
if (g_session.consumeLoadSignal()) { if (g_session.consumeLoadSignal()) {
reasampler::bankPanelNotifyProjectLoaded(); reasampler::bankPanelNotifyProjectLoaded();
// Reconcile the restored lane-ownership index against the live project's lanes // Reconcile lane ownership against the live project's lanes (P_LANENAME,
// FIRST (via REAPER's durable P_LANENAME — the cross-session source of truth), // the cross-session source of truth) BEFORE reapplying visibility. Never
// so a saved lane-split project's managed/manual classification is correct // re-mints, never mass-tags.
// before the active mode's lane visibility is reapplied. Never re-mints, never
// mass-tags — it only records managed ownership recovered from lane names.
reasampler::reconcileManagedLanes(g_session.view(), nullptr); reasampler::reconcileManagedLanes(g_session.view(), nullptr);
reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr); reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
} }
// Reflect a live bank change (capture / project load) in the docked grid. reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed
// Cheap when the bank is unchanged (a fingerprint compare); repaints only on
// an actual change. No-op when the panel is closed.
reasampler::bankPanelRefresh();
} }
// --- 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
// A Ctrl-Z / Ctrl-Shift-Z rolls back / forward the "reasampler" project ext state on // but keeps the SAME project identity, so the timer's identity poll never re-reads
// disk but keeps the SAME project identity (ReaProject*/GUID/.rpp path), so the timer's // ext state on undo/redo — the in-memory book/view would stay stale until
// identity poll reads it as NoOp and never re-reads ext state — the in-memory book/view // close+reopen. REAPER's projectconfig fires BeginLoadProjectState on every
// would stay stale until close+reopen. REAPER's projectconfig extension fires // project-state (re)load INCLUDING undo/redo (isUndo == true for both); we hook it.
// BeginLoadProjectState on every project-state (re)load, INCLUDING an undo/redo restore
// (isUndo == true for both). We hook it to drive a session reload.
// //
// TIMING (the crux): BeginLoadProjectState is documented (reaper_plugin.h ~1203) as // TIMING: BeginLoadProjectState fires BEFORE any state restore, so reading
// firing BEFORE any state restore. Reading GetProjExtState synchronously here would // GetProjExtState here would return the PRE-undo value. Instead we raise a one-shot
// return the PRE-undo value. So we do NOT read here — we raise a one-shot reload request // reload request that OnTimer's poll() drains on the NEXT tick, once REAPER has
// (g_session.requestReload()) that OnTimer's poll() drains on the NEXT tick, by which // finished restoring the <EXTSTATE> block. A normal project open also fires this
// point REAPER has finished restoring the <EXTSTATE> block and GetProjExtState returns // (isUndo=false); ignored here so a normal open flows solely through the timer's
// the POST-undo value. Deterministic, event-driven — NOT ext-state content polling. // identity-transition Load path (no double load).
//
// 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.
static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/) static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/)
{ {
if (isUndo) if (isUndo)
g_session.requestReload(); g_session.requestReload();
} }
// ProcessExtensionLine / SaveExtensionConfig are intentional no-ops: ReaSampler stores // Intentional no-ops: ReaSampler stores state via project EXT STATE, not this
// its state via project EXT STATE (SetProjExtState/GetProjExtState under "reasampler"), // extension's own project lines. The struct is registered ONLY for the
// which REAPER persists in its own <EXTSTATE> RPP block — NOT via this extension's own // BeginLoadProjectState undo/redo notification.
// 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.
static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/, static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/,
bool /*isUndo*/, project_config_extension_t* /*reg*/) 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*/, static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/,
project_config_extension_t* /*reg*/) 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. // 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 nullptr, // userData
}; };
// REAPER calls this for EVERY action fired anywhere; claim only our own id, // REAPER calls this for EVERY action fired anywhere; claim only our own id, return
// return false otherwise so REAPER keeps looking. This TU's own family dispatches // false otherwise so REAPER keeps looking. This TU's own family dispatches through
// through the registration table; the Q-W4 families claim their own ids after it. // the registration table; the other families claim their own ids after it.
static bool OnHookCommand(int command, int /*flag*/) static bool OnHookCommand(int command, int /*flag*/)
{ {
if (command == 0) return false; if (command == 0) return false;
if (reasampler::actionTableHandleCommand(command)) return true; if (reasampler::actionTableHandleCommand(command)) return true;
// Design View action family (D4). Claims only its own ids; returns false for the
// rest so this hook keeps looking (per the contract).
if (reasampler::designViewHandleCommand(command)) return true; if (reasampler::designViewHandleCommand(command)) return true;
// Multi-bank action family (B3). Same contract: claims only its own ids.
if (reasampler::bankHandleCommand(command)) return true; if (reasampler::bankHandleCommand(command)) return true;
// S8 ingest action family (Media-Explorer import). Same contract.
if (reasampler::ingestHandleCommand(command)) return true; if (reasampler::ingestHandleCommand(command)) return true;
return false; return false;
} }
@@ -299,39 +234,30 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
{ {
if (!rec) if (!rec)
{ {
// rec == nullptr => REAPER is UNLOADING us. Mirror-unregister every // rec == nullptr => REAPER is UNLOADING us.
// callback with the same strings prefixed '-' (per the contract).
if (g_rec) if (g_rec)
{ {
// Abort any in-flight realtime capture FIRST, while the API pointers are // 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, // still live, so we never leave a temp track, an armed track, or an
// an armed track, or an altered transport/cursor in the user's project on // altered transport/cursor in the user's project on unload.
// unload. Commit whatever was captured (best effort) before tearing down.
capture::AbortRealtimeCaptureForUnload(g_session); capture::AbortRealtimeCaptureForUnload(g_session);
g_rec->Register("-timer", (void*)&OnTimer); g_rec->Register("-timer", (void*)&OnTimer);
g_rec->Register("-projectconfig", (void*)&g_projectConfig); g_rec->Register("-projectconfig", (void*)&g_projectConfig);
g_rec->Register("-toggleaction", (void*)&OnToggleAction); g_rec->Register("-toggleaction", (void*)&OnToggleAction);
g_rec->Register("-hookcommand", (void*)&OnHookCommand); 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); reasampler::designViewUnregisterActions(g_rec);
// Tear down the multi-bank action family (B3) — same mirror-unregister.
reasampler::bankUnregisterActions(g_rec); reasampler::bankUnregisterActions(g_rec);
// Tear down the S8 ingest action family — same mirror-unregister.
reasampler::ingestUnregisterActions(g_rec); reasampler::ingestUnregisterActions(g_rec);
// Tear down this TU's own family from the registration table (reverse // This TU's own family, reverse table order; each '-command_id'
// table order; each '-command_id' re-presents the SAME interned, // re-presents the SAME interned pointer used at register.
// channel-qualified pointer used at register).
reasampler::unregisterActionTable(g_rec); reasampler::unregisterActionTable(g_rec);
// Retire the REMOVED command ids (command_id only — we never held a gaccel // Retire the REMOVED command ids (command_id only — we never held a gaccel
// for them this session). Clears stale user keybindings on unload. Composed // for them this session).
// per channel so a beta clears beta-qualified retired ids, stable its own.
for (const char* suffix : kRetiredCaptureCmdSuffixes) for (const char* suffix : kRetiredCaptureCmdSuffixes)
g_rec->Register("-command_id", (void*)reasampler::channelIdFor(suffix)); g_rec->Register("-command_id", (void*)reasampler::channelIdFor(suffix));
} }
// Destroy the docked window and release cached thumbnails before we drop // Before dropping the API pointers: DockWindowRemove/DestroyWindow need them live.
// the API pointers (DockWindowRemove/DestroyWindow need them live).
reasampler::bankPanelShutdown(); reasampler::bankPanelShutdown();
g_rec = nullptr; g_rec = nullptr;
return 0; return 0;
@@ -349,13 +275,10 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
g_hInst = hInstance; g_hInst = hInstance;
g_rec = rec; g_rec = rec;
// Point the bank panel at the live session BEFORE registering its action, so // Point the bank panel at the live session BEFORE registering its action, so a
// a toggle firing immediately has a session to read (M5). Does not open the // toggle firing immediately has a session to read. Does not open the window.
// window — only stores the session pointer.
reasampler::bankPanelInit(&g_session); 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(); const std::vector<reasampler::ActionTableRow> rows = buildMainActionTable();
reasampler::registerActionTable(rec, rows.data(), rows.size()); reasampler::registerActionTable(rec, rows.data(), rows.size());
@@ -367,37 +290,23 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
if (g_cmdToggleBankPanel) if (g_cmdToggleBankPanel)
rec->Register("toggleaction", (void*)&OnToggleAction); rec->Register("toggleaction", (void*)&OnToggleAction);
// Register the Design View action family (D4): toggle/activate mode, tag/untag/ // Each family mints its own command_id + gaccel, shares g_session, and is routed
// show-both selected tracks. Each mints its own command_id + gaccel; the single // by the same hookcommand below. Registered before the hook so every id is
// hookcommand below routes them via designViewHandleCommand. Registered before // minted first.
// the hook so every id is minted first.
reasampler::designViewRegisterActions(rec, &g_session); 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); 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); 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); rec->Register("hookcommand", (void*)&OnHookCommand);
// Drive project-load / Save-As detection (M4 persist). The timer polls the // Drives project-load / Save-As detection: the timer polls the active project
// active project each tick; on a project load it reloads the bank from ext // each tick; on a project load it reloads the bank from ext state, on a Save-As
// state, on a Save-As it relocates the bank folder under the new .rpp. // it relocates the bank folder under the new .rpp.
rec->Register("timer", (void*)&OnTimer); rec->Register("timer", (void*)&OnTimer);
// Register the projectconfig hook so an UNDO/REDO state restore reloads the // An UNDO/REDO state restore reloads the session's book + view from the restored
// session's book + view from the restored ext state (R-B). The timer's identity // ext state. The timer's identity poll cannot see an undo (same project
// poll cannot see an undo (same project identity), so this hook owns undo/redo; it // identity), so this hook owns it (see OnBeginLoadProjectState).
// requests a deferred reload that the next timer tick drains (see the hook comment).
rec->Register("projectconfig", (void*)&g_projectConfig); rec->Register("projectconfig", (void*)&g_projectConfig);
return 1; // success — REAPER keeps us loaded 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 <cmath>
#include <cstdint> #include <cstdint>
// peaks implementation. // peaks — pure implementation. See peaks.h.
// //
// One linear pass per channel. The frame->bin partition is computed with integer // One linear pass per channel. Frame->bin partition uses integer arithmetic so it's exact for
// arithmetic so it is exact for any frameCount / binCount pairing: bin b owns the // any frameCount/binCount pairing: bin b owns [b*frameCount/binCount, (b+1)*frameCount/binCount)
// half-open frame span [b*frameCount/binCount, (b+1)*frameCount/binCount). That // — earlier bins absorb the remainder, no rounding drift, no dropped tail.
// 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.
namespace reasampler::audio { namespace reasampler::audio {
@@ -22,11 +19,10 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
std::size_t binCount) { std::size_t binCount) {
Envelope envelope(channelCount); Envelope envelope(channelCount);
if (channelCount == 0) { if (channelCount == 0) {
return envelope; // no channels -> no envelopes return envelope;
} }
// Never read past what the buffer actually holds, even if the caller's // Never read past what the buffer actually holds, even if frameCount overstates it.
// frameCount overstates the buffer (defensive: no OOB on a short buffer).
const std::size_t availableFrames = interleaved.size() / channelCount; const std::size_t availableFrames = interleaved.size() / channelCount;
const std::size_t frames = std::min(frameCount, availableFrames); 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} bins.assign(binCount, MinMax{}); // empty/degenerate bins default to {0,0}
for (std::size_t b = 0; b < binCount; ++b) { for (std::size_t b = 0; b < binCount; ++b) {
// Half-open frame span for this bin: [b*frames/binCount, (b+1)*frames/binCount). // Guard b*frames / (b+1)*frames overflow: binCount is caller-controlled and
// Guard against size_t overflow in b*frames and (b+1)*frames: binCount is // unbounded. Unreachable in practice (would OOM first) but guarded to avoid UB.
// 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.
if (frames > 0 && b >= SIZE_MAX / frames) { 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 begin = (b * frames) / binCount;
const std::size_t end = ((b + 1) * 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()); const int nbins = static_cast<int>(bins.size());
if (columnCount <= 0 || nbins == 0) return MinMax{}; if (columnCount <= 0 || nbins == 0) return MinMax{};
// Clamp col to [0, columnCount-1].
if (col < 0) col = 0; if (col < 0) col = 0;
if (col >= columnCount) col = columnCount - 1; if (col >= columnCount) col = columnCount - 1;
// Half-open bin range for this column, mirroring computeEnvelope's exact partition. // Half-open bin range for this column, mirroring computeEnvelope's partition. 64-bit
// 64-bit products: col*nbins can exceed int range for a large oversampled envelope // products: col*nbins can exceed int range for a large oversampled envelope.
// (same overflow discipline as computeEnvelope's frame-span arithmetic above).
const std::int64_t begin64 = (static_cast<std::int64_t>(col) * nbins) / columnCount; const std::int64_t begin64 = (static_cast<std::int64_t>(col) * nbins) / columnCount;
const std::int64_t end64 = const std::int64_t end64 =
(static_cast<std::int64_t>(col) + 1) * nbins / columnCount; (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); const int colBinBegin = static_cast<int>(begin64);
// When the column spans no full bin (more columns than bins), use the enclosing bin // When the column spans no full bin (more columns than bins), use the enclosing bin.
// so no column is left empty.
const int scanEnd = (end64 > begin64) ? static_cast<int>(end64) : colBinBegin + 1; const int scanEnd = (end64 > begin64) ? static_cast<int>(end64) : colBinBegin + 1;
const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins; const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins;
@@ -102,14 +90,11 @@ std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
AudioSample linearThreshold) { AudioSample linearThreshold) {
if (channelCount == 0) return kNoFrameAboveThreshold; 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 availableFrames = interleaved.size() / channelCount;
const std::size_t frames = std::min(frameCount, availableFrames); const std::size_t frames = std::min(frameCount, availableFrames);
if (frames == 0) return kNoFrameAboveThreshold; if (frames == 0) return kNoFrameAboveThreshold;
// Scan backward: the first frame (from the end) whose loudest channel exceeds the // Scan backward; `f` runs frames..1 so `f-1` never wraps.
// threshold is the last audible frame. `f` runs frames..1 so `f-1` never wraps.
for (std::size_t f = frames; f > 0; --f) { for (std::size_t f = frames; f > 0; --f) {
const std::size_t frame = f - 1; const std::size_t frame = f - 1;
const std::size_t base = frame * channelCount; const std::size_t base = frame * channelCount;
+42 -76
View File
@@ -1,32 +1,20 @@
#pragma once #pragma once
// peaks — waveform min/max envelope (thumbnail) computation from raw interleaved // peaks — waveform min/max envelope (thumbnail) computation from raw interleaved PCM. We compute
// PCM. We compute our own thumbnails from the captured file rather than depending // our own thumbnails rather than depending on REAPER's peak API: we own the file format, so this
// on REAPER's peak API: we own the file format, so this is simpler, testable, and // is simpler, testable, and dependency-free.
// 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.
#include <cstddef> #include <cstddef>
#include <vector> #include <vector>
namespace reasampler::audio { namespace reasampler::audio {
// Canonical in-memory audio-sample type. `float` is REAPER's native audio buffer // REAPER's native audio buffer format (interleaved 32-bit float), consumed directly with no
// format (its render/PCM_source callbacks hand back interleaved 32-bit float), so // lossy conversion. Named AudioSample rather than Sample to avoid colliding with bank_model's
// peaks consumes that directly with no lossy conversion. If a capture ever lands // metadata struct of the same short name.
// 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.
using AudioSample = float; using AudioSample = float;
// One bin of a channel's envelope: the extremes of every sample that fell in it. // One bin's extremes across the samples that fell in it. min <= max always; an empty bin
// min <= max always. For an empty bin (more bins than frames), both are 0. // (more bins than frames) is {0, 0}.
struct MinMax { struct MinMax {
AudioSample min = 0.0f; AudioSample min = 0.0f;
AudioSample max = 0.0f; AudioSample max = 0.0f;
@@ -37,83 +25,61 @@ struct MinMax {
// One channel's envelope: exactly `binCount` bins, in time order. // One channel's envelope: exactly `binCount` bins, in time order.
using ChannelEnvelope = std::vector<MinMax>; using ChannelEnvelope = std::vector<MinMax>;
// Per-channel envelopes: outer index is channel (channelCount entries, order // Per-channel envelopes: outer index is channel (channelCount entries, order preserved — never
// preserved — never mixed or folded), inner is that channel's bins. // mixed or folded), inner is that channel's bins.
using Envelope = std::vector<ChannelEnvelope>; using Envelope = std::vector<ChannelEnvelope>;
// Computes a per-channel min/max envelope from interleaved PCM. // Computes a per-channel min/max envelope from interleaved PCM.
// //
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. // interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. Size must be
// Size must be >= frameCount * channelCount; extra is ignored. // >= frameCount * channelCount; extra is ignored.
// channelCount channels per frame (the stride). Each channel is enveloped // channelCount channels per frame (the stride). Each channel is enveloped INDEPENDENTLY — no
// INDEPENDENTLY — no averaging, no stereo fold (precision // averaging, no stereo fold (channel count is preserved end to end).
// invariant: channel count preserved).
// frameCount frames (samples-per-channel) to consider. // frameCount frames (samples-per-channel) to consider.
// binCount requested bins per channel. Honored exactly for any frameCount. // binCount requested bins per channel. Honored exactly for any frameCount.
// //
// Frame->bin partition: frames are split into `binCount` contiguous spans as // Frame->bin partition: frames split into `binCount` contiguous spans as evenly as possible;
// evenly as possible; when frameCount does not divide evenly, the remainder is // when frameCount doesn't divide evenly, the remainder spreads one-frame-per-bin across the
// spread one-frame-per-bin across the earliest bins (ceil/floor split), so the // earliest bins, so the tail is never dropped and no bin reads out of bounds.
// tail is never dropped and no bin reads out of bounds. When binCount > frameCount
// the trailing empty bins are {0, 0}.
// //
// Defined behavior for degenerate input (no UB, no throw): // Degenerate input (no UB, no throw): binCount == 0 -> empty bin vector per channel;
// binCount == 0 -> per channel: an empty bin vector. // channelCount == 0 -> empty envelope; frameCount == 0 -> binCount bins, all {0, 0}.
// channelCount == 0 -> an empty envelope (no channels).
// frameCount == 0 -> per channel: binCount bins, all {0, 0}.
Envelope computeEnvelope(const std::vector<AudioSample>& interleaved, Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
std::size_t channelCount, std::size_t channelCount,
std::size_t frameCount, std::size_t frameCount,
std::size_t binCount); std::size_t binCount);
// The merged min/max for display column `col` (0-based, of `columnCount` total columns) // Merged min/max for display column `col` (0-based, of `columnCount` total) of a pre-computed
// of a pre-computed per-bin ChannelEnvelope: the true extremes of every bin that projects // ChannelEnvelope the true extremes of every bin projecting to that column. This is the
// to that column. This is the display-side collapse of an envelope computed at HIGHER // display-side collapse when the envelope was computed at a higher resolution than the drawn
// resolution than the drawn width (oversampled bins -> per-pixel-column min/max), so a // width, so a steep transient split across adjacent bins (e.g. {0.9,1.0} then {-1.0,-0.9})
// steep transient whose adjacent bins hold disjoint spans (e.g. {0.9,1.0} then // renders as one gap-free span instead of two separated dots.
// {-1.0,-0.9}) renders as one gap-free vertical span instead of two separated dots.
// //
// Bin->column mapping mirrors computeEnvelope's half-open partition: // Bin->column mapping mirrors computeEnvelope's half-open partition: column col owns bins
// column col owns bins [col*nbins/columnCount, (col+1)*nbins/columnCount). // [col*nbins/columnCount, (col+1)*nbins/columnCount). When that range is empty (more columns
// When that range is empty (more columns than bins), the enclosing bin // than bins), the enclosing bin fills the column instead. columnCount <= 0 or bins.empty()
// (col*nbins/columnCount) fills the column — so no column is left empty and no bin is // returns {0, 0}; col is clamped to [0, columnCount-1].
// ever dropped. columnCount <= 0 or bins.empty() returns {0, 0}; `col` is clamped to
// [0, columnCount-1]. Pure.
MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col); MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col);
// Sentinel returned by lastFrameAboveThreshold when NO frame in the scanned range // Sentinel for "no frame in the scanned range peaked above threshold". SIZE_MAX is unambiguous
// peaks above the threshold (pure silence at that level). SIZE_MAX is unambiguous: // since no real frame index can reach it.
// no valid frame index can equal it (a real index is < frameCount <= SIZE_MAX for
// any allocatable buffer), so the caller tests `== kNoFrameAboveThreshold` cleanly.
inline constexpr std::size_t kNoFrameAboveThreshold = inline constexpr std::size_t kNoFrameAboveThreshold =
static_cast<std::size_t>(-1); static_cast<std::size_t>(-1);
// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (the max // Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (max |sample| across
// absolute value across all channels of that frame — NO stereo fold, just the // all channels of that frame — no stereo fold) exceeds `linearThreshold`. Returns
// loudest channel that frame) exceeds `linearThreshold`, returning that frame index. // kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input).
// Returns kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input).
// //
// This is the boundary primitive behind the realtime tail's decay-scan trim // This is the boundary primitive behind the realtime tail's decay-scan trim (see
// (docs/product/capture-tail.md §The realtime path): the recorded tail window is // docs/product/capture-tail.md): the recorded tail is scanned back from the end for the last
// scanned back from the end for the last frame still above -72 dB, and the file is // frame still above -72 dB, and the file truncated one frame past it. Deliberately separate from
// truncated one frame past it. Deliberately a separate primitive from // computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail), this answers
// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail), // "the last frame above a level" (a boundary); bending a bin-oriented envelope to a frame-exact
// this answers "the last frame above a level" (a boundary). Bending the bin-oriented // question is a worse fit.
// envelope to a frame-exact boundary question is a worse fit (spec §option a).
// //
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. // linearThreshold a LINEAR amplitude ratio (e.g. the -72 dB ratio from
// Must hold >= frameCount * channelCount; extra is ignored, and a // render_settings::autoTrimEndRatio), NOT dB. A frame counts as above when
// short buffer is clamped to what it actually holds (no OOB read). // its peak is STRICTLY greater than this.
// 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.
std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved, std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
std::size_t channelCount, std::size_t channelCount,
std::size_t frameCount, 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. // batch_capture.cpp — pure logic for batch capture. See header.
// NO REAPER types; unit-tested by tests/test_batch_capture.cpp. // Unit-tested by tests/test_batch_capture.cpp.
#include "core/capture/batch_capture.h" #include "core/capture/batch_capture.h"
@@ -12,9 +12,7 @@ std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges)
units.reserve(ranges.size()); units.reserve(ranges.size());
int ordinal = 0; int ordinal = 0;
for (const BatchRange& r : ranges) { for (const BatchRange& r : ranges) {
// Drop empty/inverted ranges — the offline backend refuses end<=start too, so // Drop empty/inverted ranges — the offline backend refuses end<=start too.
// planning one would only manufacture a guaranteed per-unit failure. Ordinals
// count kept units so the reported numbering is contiguous.
if (!(r.endSeconds > r.startSeconds)) continue; if (!(r.endSeconds > r.startSeconds)) continue;
++ordinal; ++ordinal;
units.push_back({ordinal, r.startSeconds, r.endSeconds}); units.push_back({ordinal, r.startSeconds, r.endSeconds});
+30 -38
View File
@@ -1,29 +1,24 @@
#pragma once #pragma once
// batch_capture — the REAPER-free logic behind M11 batch capture (one action fires // batch_capture — the REAPER-free logic behind batch capture (one action fires N
// N captures: one bank sample per selected item / per razor area). // captures: one bank sample per selected item / per razor area).
// //
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
// vendor/ includes. Standard library only. The batch shell (main.cpp) reads the DAW // only. The batch shell reads the DAW state (selected items -> exact bounds;
// state (selected items -> their exact bounds; every track's P_RAZOREDITS -> areas) // each track's P_RAZOREDITS -> areas) and hands the raw ranges here:
// and hands the raw ranges here so the genuinely-pure, easy-to-get-wrong pieces are
// unit-tested outside the DAW:
// //
// 1. planCaptureUnits: an ordered list of (start,end) source ranges -> an ordered // 1. planCaptureUnits: an ordered list of (start,end) ranges -> an ordered
// list of CaptureUnit, each carrying its 1-based ordinal and validated bounds. // list of CaptureUnit, each with a 1-based ordinal and validated bounds.
// Empty/inverted ranges are DROPPED (mirrors the offline backend's own // 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. // end>start guard); ordinals count only the kept units, so three valid
// Order is preserved: unit ordinals count only the KEPT units, so a batch of // items yield 1,2,3 regardless of dropped neighbors.
// three valid items yields ordinals 1,2,3 regardless of dropped neighbors. // 2. BatchOutcome: order-preserving aggregation of per-unit results into a
// 2. BatchOutcome: order-preserving aggregation of per-unit results into a summary // summary (succeeded/failed counts + ordered failures) for one console
// (succeeded / failed counts + the ordered list of failures) so the shell can // line with no partial-corruption ambiguity.
// 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.
// //
// Range is the ONLY thing that varies per unit here. FX scope (item vs track) is a // Range is the only thing that varies per unit here. FX scope (item vs track) is
// per-ACTION constant the shell already owns (fxBypassPlanFor); it is not a // a per-action constant the shell already owns; item-batch uses item scope,
// per-unit field. Item-batch uses item scope; razor-batch uses track scope — the // razor-batch uses track scope, passed through unchanged from the single-capture
// shell passes the scope straight through to each render, unchanged from the // path.
// single-capture path.
#include <cstddef> #include <cstddef>
#include <string> #include <string>
@@ -31,10 +26,10 @@
namespace reasampler::capture { namespace reasampler::capture {
// One capture in a batch: an exact source range plus its 1-based ordinal within the // One capture in a batch: an exact source range plus its 1-based ordinal within
// KEPT set. The ordinal disambiguates per-unit file stems (the offline backend's // the kept set. The ordinal disambiguates per-unit file stems (the offline
// unique tag is 1-second-granular, so a fast batch could otherwise collide N files // backend's unique tag is 1-second-granular, so a fast batch could otherwise
// onto one name) and labels a failure in the summary. // collide N files onto one name) and labels a failure in the summary.
struct CaptureUnit { struct CaptureUnit {
int ordinal = 0; // 1-based, counts kept units only int ordinal = 0; // 1-based, counts kept units only
double startSeconds = 0.0; // exact — no rounding 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 // 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 // razor area's [start, end]). Named BatchRange (not SourceRange) to avoid
// (raw, possibly-invalid) and the output (validated, ordinal-assigned) do not share // collision with bank_model's SourceRange, which carries PPQ fields this planner
// a shape by accident. Named BatchRange (not SourceRange) to avoid collision with // doesn't need.
// bank_model's SourceRange, which carries PPQ fields this planner does not need.
struct BatchRange { struct BatchRange {
double startSeconds = 0.0; double startSeconds = 0.0;
double endSeconds = 0.0; double endSeconds = 0.0;
}; };
// Validates + orders a batch's source ranges into capture units. Preserves input // 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 // order; drops every range with end <= start; assigns 1-based ordinals over the
// planned; assigns 1-based ordinals over the KEPT units. An empty input (no selected // kept units. An empty input yields an empty plan — the shell reports "nothing
// item / no razor area) yields an empty plan — the shell reports "nothing to batch" // to batch" and writes nothing.
// and writes nothing (the same no-op posture the single-capture path takes).
std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges); std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges);
// The per-unit verdict the shell records after each render attempt, in unit order. // 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 std::string detail; // failure reason (empty on success) — for the summary
}; };
// Order-preserving aggregation of a batch's per-unit results. Built incrementally by // Order-preserving aggregation of a batch's per-unit results. Built incrementally
// the shell (record() after each unit) so a mid-batch failure is captured without // by the shell (record() after each unit) so a mid-batch failure doesn't abort
// aborting the remaining units (no partial corruption: each unit is independent, and // the remaining units each unit is independent.
// the selection is restored on every exit path by the shell's RAII guard).
class BatchOutcome { class BatchOutcome {
public: public:
// Records one unit's verdict. Order of calls IS the reported order. // Records one unit's verdict. Order of calls IS the reported order.
+11 -60
View File
@@ -6,25 +6,16 @@
namespace reasampler::capture { 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 normalizeSlashes(const std::string& path) {
std::string out = path; std::string out = path;
for (char& c : out) { for (char& c : out) {
if (c == '\\') c = '/'; if (c == '\\') c = '/';
} }
// Strip a single trailing slash so joins do not double up. Preserve a lone // Strip a trailing slash but preserve a lone "/" (root).
// "/" (root) — stripping it would turn root into empty.
if (out.size() > 1 && out.back() == '/') { if (out.size() > 1 && out.back() == '/') {
out.pop_back(); out.pop_back();
} }
#ifdef _WIN32 #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))); for (char& c : out) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
#endif #endif
return out; return out;
@@ -39,8 +30,7 @@ std::string sanitizeStem(const std::string& baseName) {
c == '-'; c == '-';
out.push_back(keep ? static_cast<char>(c) : '_'); out.push_back(keep ? static_cast<char>(c) : '_');
} }
// Collapse to a stable default if nothing usable survived (e.g. all spaces). // Collapse to a stable default if nothing alnum survived.
// A stem of only separators ('.', '_', '-') is also unhelpful as a name.
bool hasAlnum = false; bool hasAlnum = false;
for (unsigned char c : out) { for (unsigned char c : out) {
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || 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"; const std::string fileName = stem + ".wav";
// Precondition: the capture shell must resolve a non-empty project directory // Precondition: caller must resolve a non-empty project directory — an
// before calling this function. An empty projectDir would produce a bare // empty one would otherwise fall back to a bare relative path (forbidden).
// relative "reasampler_bank" path — the silent default-location fallback this // Assert in debug; leave absoluteDir empty in release so a caller that
// tool explicitly forbids. Assert in debug; leave absoluteDir empty in release // ignores it fails at the render/stat step, not silently onto CWD.
// so any caller that ignores the precondition fails loudly at the render/stat
// step rather than silently writing to CWD.
assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty"); assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty");
BankPaths p; BankPaths p;
p.fileStem = stem; // stem only — REAPER appends extension p.fileStem = stem; // stem only — REAPER appends extension
p.fileName = fileName; p.fileName = fileName;
p.relativePath = std::string(kBankSubfolder) + "/" + 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{} p.absoluteDir = dir.empty() ? std::string{}
: dir + "/" + kBankSubfolder; : dir + "/" + kBankSubfolder;
return p; return p;
@@ -88,15 +73,12 @@ BankPaths deriveBankPaths(const std::string& projectDir,
std::string bankRelativeForName(const std::string& fileName) { std::string bankRelativeForName(const std::string& fileName) {
if (fileName.empty()) return {}; if (fileName.empty()) return {};
// The SAME expression deriveBankPaths uses for relativePath, kept in one place so // Same expression deriveBankPaths uses, so the two spellings can't drift.
// the two spellings can never drift (Phase R spelling-consistency invariant).
return std::string(kBankSubfolder) + "/" + fileName; return std::string(kBankSubfolder) + "/" + fileName;
} }
std::string resolveBankFile(const std::string& projectDir, std::string resolveBankFile(const std::string& projectDir,
const std::string& relativePath) { 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()) { if (projectDir.empty() || relativePath.empty()) {
return {}; return {};
} }
@@ -109,10 +91,7 @@ std::string resolveBankFile(const std::string& projectDir,
} }
std::string projectDirOfRpp(const std::string& rppPath) { std::string projectDirOfRpp(const std::string& rppPath) {
// An unsaved project reports an empty .rpp path; keep it empty so downstream if (rppPath.empty()) return {}; // unsaved project: keep empty, no fallback
// resolution refuses (no default-location fallback). Mirrors the former persist shell's
// projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes.
if (rppPath.empty()) return {};
std::string dir = std::filesystem::path(rppPath).parent_path().string(); std::string dir = std::filesystem::path(rppPath).parent_path().string();
return normalizeSlashes(dir); return normalizeSlashes(dir);
} }
@@ -128,9 +107,7 @@ BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
r.oldBankDir = oldDir + "/" + kBankSubfolder; r.oldBankDir = oldDir + "/" + kBankSubfolder;
r.newBankDir = newDir + "/" + kBankSubfolder; r.newBankDir = newDir + "/" + kBankSubfolder;
// A Save (in place) leaves the project dir unchanged — nothing to relocate. r.needed = (oldDir != newDir); // Save-in-place leaves the dir unchanged
// Only a Save-As to a different directory needs the bank moved.
r.needed = (oldDir != newDir);
return r; return r;
} }
@@ -139,42 +116,16 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject,
const std::string& lastPath, const std::string& lastPath,
const std::string& currentGuid, const std::string& currentGuid,
const std::string& currentPath) { const std::string& currentPath) {
// 1. The GUID is the identity of record and is checked FIRST. A different // See capture_paths.h for the GUID-primary rationale and rule order.
// 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.
if (currentGuid != lastGuid) { if (currentGuid != lastGuid) {
return ProjectTransition::Load; 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) { 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) { if (currentPath != lastPath) {
return ProjectTransition::SaveAsRelocate; return ProjectTransition::SaveAsRelocate;
} }
// 4. Same object, same GUID, same path — Save in place / idle tick.
return ProjectTransition::NoOp; return ProjectTransition::NoOp;
} }
+51 -122
View File
@@ -1,16 +1,8 @@
#pragma once #pragma once
// capture_paths — the REAPER-free path arithmetic behind offline capture. // capture_paths — the REAPER-free path arithmetic behind offline capture. The
// // capture shell resolves the current project directory via REAPER APIs, then
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // hands the raw strings here. Forward-slash form throughout, no filesystem
// vendor/ includes. Standard library only. The capture shell resolves the // access; the bank subfolder name is a fixed constant.
// 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).
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
@@ -20,7 +12,7 @@
namespace reasampler::capture { namespace reasampler::capture {
// The project-relative bank subfolder. All captured wavs live here so the bank // 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"; inline constexpr const char* kBankSubfolder = "reasampler_bank";
// A resolved pair of paths for one capture: where REAPER must be told to write // 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) std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
}; };
// NOTE (Q-W3, audit §4e): the content-identity hashes (hashBytes / hashWavContent) // Normalizes a path to forward slashes and strips any trailing slash (does not
// moved to core/capture/wav_codec.{h,cpp} — the ONE pure owner of the WAV/RIFF byte // consult the filesystem). On Windows (_WIN32) also lowercases the result so
// format — so this module holds path arithmetic only, with no RIFF chunk knowledge. // paths differing only in casing compare equal; macOS/Linux preserve case.
// 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).
std::string normalizeSlashes(const std::string& path); std::string normalizeSlashes(const std::string& path);
// Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps // Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps
// [A-Za-z0-9._-], replaces every other byte (spaces, slashes, quotes, control) // [A-Za-z0-9._-], replaces every other byte with '_', and collapses to
// with '_', and collapses to "capture" if nothing usable remains. Deterministic: // "capture" if nothing usable remains. Deterministic.
// the same input always yields the same stem (feeds bit-identical file naming).
std::string sanitizeStem(const std::string& baseName); std::string sanitizeStem(const std::string& baseName);
// Derives the bank paths for one capture. // Derives the bank paths for one capture: baseName is the sanitized file-stem
// projectDir : absolute directory of the current .rpp (any slash style) // source, uniqueTag an optional sanitized disambiguator (timestamp/counter) so
// baseName : human base for the file stem (sanitized) // repeated captures don't collide. Produces "<stem>[_<tag>].wav".
// 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.
BankPaths deriveBankPaths(const std::string& projectDir, BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName, const std::string& baseName,
const std::string& uniqueTag); const std::string& uniqueTag);
// The project-relative index spelling for a bank file KNOWN ONLY by its file name — // The project-relative index spelling for a bank file known only by its file
// the forward derivation the Phase R prune shell uses to spell an ENUMERATED folder // name (bare entry, no directory) — the prune shell uses this to spell an
// entry the SAME way deriveBankPaths spelled it at capture time. By construction it // enumerated folder entry the SAME way deriveBankPaths spelled it at capture
// is the identical expression deriveBankPaths().relativePath uses (kBankSubfolder + // time; a divergence here could make a referenced file look like an orphan.
// "/" + 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.
std::string bankRelativeForName(const std::string& fileName); 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 // The index stores relative paths only; on project load the persist shell
// turn each entry's relativePath back into an absolute path against the CURRENT // turns each relativePath back into an absolute path against the current
// project directory (so a project opened from a new location still resolves its // project directory — the inverse of deriveBankPaths.
// bank). This is the inverse of the relativePath the capture path produced.
// // Returns "<projectDir>/<relativePath>" forward-slashed, or empty if either
// projectDir : absolute directory of the current .rpp (any slash style) // input is empty (no default-location fallback — an unsaved/unset project
// relativePath : a project-relative index entry (e.g. "reasampler_bank/x.wav") // fails loudly rather than resolving against CWD).
//
// 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.
std::string resolveBankFile(const std::string& projectDir, std::string resolveBankFile(const std::string& projectDir,
const std::string& relativePath); const std::string& relativePath);
// The project directory that holds a .rpp: its parent directory, forward-slashed, // The project directory that holds a .rpp: parent directory, forward-slashed,
// trailing slash stripped. Empty in -> empty out (an unsaved project has an empty // trailing slash stripped. Empty in -> empty out (an unsaved project reports
// .rpp path, which must stay empty so resolveBankFile refuses to resolve — the // an empty .rpp path). Pure so the VST3 instrument resolves audio paths the
// no-default-location invariant). This is the M4 convention persist uses to place // same way persist does.
// 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.
std::string projectDirOfRpp(const std::string& rppPath); std::string projectDirOfRpp(const std::string& rppPath);
// A relocation plan for the physical bank folder on Save-As to a new project // 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 // 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 // project dir, which moved with the .rpp), so relocation is purely a folder
// folder move: copy/move the whole bank subfolder from the old project dir to // move. Both dirs are absolute, forward-slashed, trailing-slash-stripped.
// the new one. Both dirs are absolute, forward-slashed, trailing-slash-stripped.
struct BankRelocation { struct BankRelocation {
std::string oldBankDir; // <oldProjectDir>/reasampler_bank std::string oldBankDir; // <oldProjectDir>/reasampler_bank
std::string newBankDir; // <newProjectDir>/reasampler_bank std::string newBankDir; // <newProjectDir>/reasampler_bank
bool needed = false; // false when old==new (Save in place, not Save-As) bool needed = false; // false when old==new (Save in place, not Save-As)
}; };
// Derives the relocation plan from the old and new project directories. // Derives the relocation plan: `needed` is true iff the normalized old/new
// oldProjectDir : project dir the bank currently sits under (any slash style) // project dirs differ (a genuine Save-As-to-new-dir); empty dirs/needed=false
// newProjectDir : project dir the .rpp was just saved to (any slash style) // when either input is empty.
// `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.
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir, BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
const std::string& newProjectDir); 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, // What the persist timer must do on each tick. GUID is checked FIRST because
// layered GUID-PRIMARY: // two prior pointer-primary/GUID-only designs each broke a real case: a
// 1. the minted GUID — content-based identity of record, stored in ext state. // GUID-only check misreads a Save-As fork as the same project (fork and
// It is IMMUNE to REAPER recycling a closed project's ReaProject* address, // parent share a GUID on disk); a pointer-primary check misreads REAPER
// so it is checked FIRST. // recycling a closed project's ReaProject* address onto an unrelated project
// 2. sameProjectObject — did the same live ReaProject* stay active across the // (a different project, same recycled pointer, read as NoOp/SaveAsRelocate —
// two ticks (computed in poll() as `proj == lastProject_`)? Used ONLY to // the bank never reloads). Checking GUID first catches recycling; the pointer
// disambiguate the same-GUID case: a forked sibling (Save-As copied our GUID // (sameProjectObject) then separates a forked sibling (Load) from a genuine
// onto a distinct object) vs a genuine Save-As (one object, new path). // Save-As (SaveAsRelocate).
//
// 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.
enum class ProjectTransition { enum class ProjectTransition {
NoOp, // same object, same GUID, same location — nothing to do NoOp, // same object, same GUID, same location — nothing to do
Load, // a different project is active — load ITS index from ext state Load, // a different project is active — load ITS index from ext state
SaveAsRelocate, // SAME object + SAME GUID, new .rpp location — relocate the bank SaveAsRelocate, // SAME object + SAME GUID, new .rpp location — relocate the bank
}; };
// Classifies what a poll tick observed. // Classifies what a poll tick observed. sameProjectObject is passed as a bool
// sameProjectObject : true iff the SAME ReaProject* stayed active across the two // (not the raw pointer) to keep the classifier REAPER-free and testable;
// ticks (poll() computes `proj == lastProject_`). The pure // lastGuid/lastPath is the project persist last acted on, currentGuid/
// classifier takes the bool, not the raw pointer, to stay // currentPath the now-active project (both "" if unsaved/unwritten).
// REAPER-free and testable. // Evaluated in order: currentGuid!=lastGuid -> Load; !sameProjectObject ->
// lastGuid : the GUID of the project persist last acted on ("" if none/unsaved) // Load (forked sibling); currentPath!=lastPath -> SaveAsRelocate (also covers
// lastPath : that project's .rpp path when last seen ("" if unsaved) // first save of an unsaved project); else NoOp.
// 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.
ProjectTransition classifyProjectTransition(bool sameProjectObject, ProjectTransition classifyProjectTransition(bool sameProjectObject,
const std::string& lastGuid, const std::string& lastGuid,
const std::string& lastPath, const std::string& lastPath,
+19 -44
View File
@@ -1,6 +1,5 @@
// capture_realtime.cpp — pure logic for the realtime-record backend (M8). See // capture_realtime.cpp — pure logic for the realtime-record backend. See header.
// header. NO REAPER types; unit-tested by tests/test_capture_realtime.cpp. // Unit-tested by tests/test_capture_realtime.cpp.
// (Renamed from realtime_record.cpp in Q-W3 — the Q-9 naming rider.)
#include "core/capture/capture_realtime.h" #include "core/capture/capture_realtime.h"
@@ -9,11 +8,8 @@ namespace reasampler::capture {
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) { RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) {
RecordModePlan p; RecordModePlan p;
// Stereo vs mono output recording, latency-compensated either way so the // REAPER's output-record modes are mono/stereo only; >2 channels still
// recorded file lines up with the source. A request asking for <= 1 channel // records stereo-out (a >2-channel realtime capture is out of scope).
// 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.)
p.recMode = (channelCount <= 1) ? kRecModeMonoOutLatComp p.recMode = (channelCount <= 1) ? kRecModeMonoOutLatComp
: kRecModeStereoOutLatComp; : kRecModeStereoOutLatComp;
@@ -26,42 +22,32 @@ RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) {
} }
OutputTap outputTapForWetDry(double wetDry) { 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; return (wetDry >= 1.0) ? OutputTap::PostFader : OutputTap::PreFx;
} }
Sample sampleFromRecordedCapture(const RecordedCapture& cap) { Sample sampleFromRecordedCapture(const RecordedCapture& cap) {
Sample s; Sample s;
// Same id shape as the offline path: "cap-<tag>-<fileName>" would need the file // Relative path tail included so two same-tag captures (shouldn't happen) still differ.
// 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.
s.id = "cap-" + cap.uniqueTag + "-" + cap.relativePath; s.id = "cap-" + cap.uniqueTag + "-" + cap.relativePath;
s.displayName = cap.displayName; s.displayName = cap.displayName;
s.relativePath = cap.relativePath; // project-relative (invariant) s.relativePath = cap.relativePath;
s.sourceMode = cap.sourceMode; s.sourceMode = cap.sourceMode;
s.sourceRange.startSeconds = cap.startSeconds; s.sourceRange.startSeconds = cap.startSeconds;
s.sourceRange.endSeconds = cap.endSeconds; 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.wetDry = cap.wetDry;
s.trackGuids = cap.trackGuids; s.trackGuids = cap.trackGuids;
s.channelCount = cap.channelCount; s.channelCount = cap.channelCount;
s.sampleRate = cap.sampleRate; // 0 when project rate was unknown s.sampleRate = cap.sampleRate; // 0 when project rate was unknown
s.lengthSeconds = cap.endSeconds - cap.startSeconds; s.lengthSeconds = cap.endSeconds - cap.startSeconds;
s.captureTempo = cap.captureTempo; 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.captureTimeSigDenom = cap.captureTimeSigDenom;
s.tier = Tier::Scratch; // captures land in scratch by default s.tier = Tier::Scratch;
// contentHash set by the caller (capture_realtime.cpp) after the file is // contentHash is left empty: this mapping runs before the file exists on
// finalized and on disk the hash is over the finished file bytes. Left empty // disk; the shell patches the hash in after the move+trim.
// here because sampleFromRecordedCapture runs before the file exists (the // rootNote/loop left empty: a realtime record of wet output isn't a single
// mapping is pure / DAW-free); the shell patches it in after the move+trim. // played note, so no root note is derivable; loop points are a later action.
// 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.createdTimestamp = cap.createdTimestamp; s.createdTimestamp = cap.createdTimestamp;
return s; return s;
} }
@@ -72,20 +58,15 @@ RecordPhase advanceRecordPhase(RecordPhase current,
double rangeEndSeconds) { double rangeEndSeconds) {
switch (current) { switch (current) {
case RecordPhase::Recording: { case RecordPhase::Recording: {
// Transport stopped while we still expected to be recording -> the user // Stopped early (user or REAPER) -> finalize what was captured so far.
// (or REAPER) stopped early. Move to the flush wait and finalize whatever
// was captured up to the stop.
if (!inputs.transport.recording) return RecordPhase::Finalizing; if (!inputs.transport.recording) return RecordPhase::Finalizing;
// Reached the range end (latency-compensated play position). >= (not >) // >= (not >): a cursor landing exactly on the end completes.
// so a cursor landing exactly on the end completes.
if (inputs.transport.playPosition >= rangeEndSeconds) if (inputs.transport.playPosition >= rangeEndSeconds)
return RecordPhase::Finalizing; return RecordPhase::Finalizing;
// Self-defense (review §3): the transport is running but the play cursor // Self-defense: a stuck/looping transport that never reaches end would
// is not advancing to the end (stuck / looping). Without this the machine // otherwise stay in Recording forever, leaking the temp track + armed sink.
// stays in Recording forever, leaking the temp track + armed sink. Force
// the flush wait once wall-clock exceeds the nominal duration + margin.
const double ceiling = const double ceiling =
(rangeEndSeconds - rangeStartSeconds) + kRecordMarginSeconds; (rangeEndSeconds - rangeStartSeconds) + kRecordMarginSeconds;
if (inputs.elapsedSeconds > ceiling) return RecordPhase::Finalizing; if (inputs.elapsedSeconds > ceiling) return RecordPhase::Finalizing;
@@ -94,22 +75,16 @@ RecordPhase advanceRecordPhase(RecordPhase current,
} }
case RecordPhase::Finalizing: { case RecordPhase::Finalizing: {
// The transport is stopped; wait for REAPER to flush/close the recorded // Moving the file before it's stable would race REAPER's flush and
// take on the audio thread. Finalize (move + Sample) only once the file // yield a truncated/missing capture.
// exists AND is stable (review §2) — moving it early races the flush and
// yields a truncated / missing capture.
if (inputs.fileReady) return RecordPhase::Done; 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) if (inputs.finalizingSeconds > kFinalizeFlushCeilingSeconds)
return RecordPhase::Failed; return RecordPhase::Failed;
return RecordPhase::Finalizing; 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::Done:
case RecordPhase::Failed: case RecordPhase::Failed:
default: default:
+70 -150
View File
@@ -1,27 +1,11 @@
#pragma once #pragma once
// capture_realtime — the REAPER-free logic behind the realtime-record backend (M8). // capture_realtime — the REAPER-free logic behind the realtime-record backend.
// (Renamed from realtime_record in Q-W3 — the Q-9 naming rider: the PURE module // The shell drives the transport, temp track, send routing, and file move; the
// takes the stem, the shell takes the suffix — capture_realtime_shell.cpp / // pure pieces split out here and unit-tested outside the DAW are: (1) record-
// capture_realtime_finalize.cpp — matching the drag_out ↔ drag_out_win model.) // mode bookkeeping — scope + FX-tap point -> I_RECMODE/I_RECMODE_FLAGS values
// // (bit MEANINGS transcribed verbatim from reaper_plugin_functions.h ~2197-2198;
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // the CHOICE of value per scope is this module's tested logic) — and (2) the
// vendor/ includes. Standard library only. The realtime shell drives the // recorded-file -> Sample mapping (mirrors OfflineRenderBackend's population).
// 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.
#include <cstdint> #include <cstdint>
#include <string> #include <string>
@@ -35,26 +19,17 @@ using model::Sample;
using model::Tier; using model::Tier;
using model::SourceMode; using model::SourceMode;
// --- I_RECMODE values (verbatim from SDK header ~2197) ----------------------- // 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
// I_RECMODE : int * : record mode, 0=input, 1=stereo out, 2=none, // comp, 7=midi overdub, 8=midi replace. We record a track's OUTPUT, latency-
// 3=stereo out w/latency compensation, 4=midi output, 5=mono out, // compensated, so the recorded file lines up sample-accurately with the source.
// 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.
inline constexpr int kRecModeStereoOutLatComp = 3; // stereo out w/latency comp inline constexpr int kRecModeStereoOutLatComp = 3; // stereo out w/latency comp
inline constexpr int kRecModeMonoOutLatComp = 6; // mono 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 (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
// I_RECMODE_FLAGS : int * : record mode flags, &3=output recording mode // pre-FX tap in the SDK — offline render has no pre-FX bit — so the realtime
// (0=post fader, 1=pre-fx, 2=post-fx/pre-fader). // backend is the true pre-FX "dry" path.
//
// 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.
inline constexpr int kRecOutPostFader = 0; // &3==0: post-fader (fully wet) 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 kRecOutPreFx = 1; // &3==1: pre-FX (true dry)
inline constexpr int kRecOutPostFxPreFader = 2; // &3==2: post-FX, pre-fader 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 // The concrete record-mode values a temp track must carry to capture the scoped
// output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated); `recModeFlags` // output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated);
// sets the &3 output-recording tap bits (higher bits are left at their default 0 // `recModeFlags` sets the &3 output-recording tap bits (we only own those bits).
// here — we only own the tap-point bits).
struct RecordModePlan { struct RecordModePlan {
int recMode = kRecModeStereoOutLatComp; int recMode = kRecModeStereoOutLatComp;
int recModeFlags = kRecOutPostFader; int recModeFlags = kRecOutPostFader;
}; };
// Maps (channelCount, tap) to the record-mode values. // Maps (channelCount, tap) to the record-mode values: channelCount <= 1 ->
// channelCount <= 1 -> mono-out latency-comp; otherwise stereo-out latency-comp. // mono-out latency-comp, else stereo-out; tap -> the &3 bits. The shell applies
// tap -> the &3 output-recording bits. // these via SetMediaTrackInfo_Value(I_RECMODE / I_RECMODE_FLAGS).
// 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).
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap); RecordModePlan recordModePlanFor(int channelCount, OutputTap tap);
// Maps a wetDry value to the output tap point. 1.0 (fully wet) -> PostFader; any // Maps a wetDry value to the output tap point: 1.0 (fully wet) -> PostFader,
// value < 1.0 -> PreFx (true dry — the realtime backend's distinguishing // anything less -> PreFx (true dry — the realtime backend's distinguishing
// capability). Kept pure + separate from recordModePlanFor so the wet/dry -> // capability over offline render). PostFxPreFader is not reachable from wetDry.
// 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).
OutputTap outputTapForWetDry(double wetDry); OutputTap outputTapForWetDry(double wetDry);
// --- Recorded-file -> Sample mapping ---------------------------------------- // --- Recorded-file -> Sample mapping ----------------------------------------
//
// The inputs a finished realtime capture yields, gathered by the shell into a // 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 // pure struct so Sample population is a single tested transform (mirrors the
// the inline population in OfflineRenderBackend::capture). // inline population in OfflineRenderBackend::capture).
struct RecordedCapture { struct RecordedCapture {
// Project-relative path of the recorded file (relative-paths-only invariant; // Project-relative path of the recorded file (the shell resolves REAPER's
// the shell resolves REAPER's recorded absolute path back to project-relative). // absolute path back to project-relative).
std::string relativePath; std::string relativePath;
// The disambiguating tag that named the file (feeds the Sample id, so id and // The disambiguating tag that named the file (feeds the Sample id).
// file name stay consistent — same discipline as the offline path).
std::string uniqueTag; std::string uniqueTag;
// Echoed from the request (exact bounds — no re-measuring the file). // Echoed from the request (exact bounds — no re-measuring the file).
@@ -115,60 +83,41 @@ struct RecordedCapture {
int channelCount = 0; int channelCount = 0;
// TEST-ONLY / dead in production (Q-W3 review follow-up): the shell no longer // Left at defaults here — capture_realtime_finalize.cpp calls
// populates these five fields before calling sampleFromRecordedCapture — the // stampCaptureSample(result.sample, ...) afterward, overwriting these five
// finalize path (capture_realtime_finalize.cpp) leaves them at their defaults // from the live project. Kept because the pure unit tests still assert them.
// and instead calls the shared stampCaptureSample(result.sample, ...) right
// after, which writes Sample::sampleRate/captureTempo/captureTimeSigNum/
// captureTimeSigDenom/createdTimestamp directly, overwriting whatever
// sampleFromRecordedCapture set from these. Kept (not deleted) because the pure
// unit tests still construct/assert them directly; removing the fields is a
// struct-shape decision out of scope here.
int sampleRate = 0; // 0 when the project rate was unknown (as offline) int sampleRate = 0; // 0 when the project rate was unknown (as offline)
double captureTempo = 0.0; // BPM at capture time (shell reads Master_GetTempo) double captureTempo = 0.0; // BPM at capture time
// Time signature at capture start (L7 F1; shell reads TimeMap_GetTimeSigAtTime). int captureTimeSigNum = 0; // 0/0 = unstamped
// 0/0 = unstamped (matches the Sample default; formatter renders a blank read-out).
int captureTimeSigNum = 0;
int captureTimeSigDenom = 0; 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 // Builds the Sample for a finished realtime capture: exact request bounds,
// shape to OfflineRenderBackend's population: exact request bounds (no rounding), // scratch tier, empty content hash, lengthSeconds = end - start. PPQ/beats
// scratch tier, empty content hash (does not dedup), lengthSeconds = end - start. // left 0 (deferred, as offline).
// PPQ/beats are left 0 (a musical-placement concern deferred exactly as offline).
Sample sampleFromRecordedCapture(const RecordedCapture& cap); 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 // A realtime record spans many timer ticks (CSurf_OnRecord starts the transport
// REAPER's audio thread and returns immediately — it does NOT block until the range // on REAPER's audio thread and returns immediately — it does not block until the
// completes). The completion decision — "given where the transport is now, should // range completes). The completion decision — keep waiting, stop-and-flush,
// the tick keep waiting, stop-and-flush, finalize, or give up?" — is pure and // finalize, or give up — is pure and unit-tested without a DAW; the shell only
// exactly the kind of off-by-one/edge logic a unit test locks without a DAW. It is // reads the transport/clock/file and applies the verdict.
// 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).
// //
// The lifecycle has TWO waits, not one: // Two waits, not one:
// 1. the RECORD wait (Recording): the transport is running; we wait for the play // 1. RECORD wait (Recording): transport running; wait for the play cursor to
// cursor to reach the range end OR the user stops early OR a wall-clock // reach the range end, OR the user stops early, OR a wall-clock safety
// safety ceiling trips (a started-but-never-advancing transport, §3 of review). // ceiling trips (a started-but-never-advancing transport).
// 2. the FLUSH wait (Finalizing): the transport is stopped but REAPER closes/flushes // 2. FLUSH wait (Finalizing): transport stopped but REAPER closes/flushes the
// the recorded take on the AUDIO thread — the file may not be fully written/closed // recorded take on the audio thread — the file may lag a tick or two.
// for a tick or two. We defer the file move until the file exists AND is stable // Defer the move until the file exists AND is stable, bounded by a flush
// (§2 of review), bounded by a flush ceiling so a file that never appears fails // ceiling so a file that never appears fails cleanly instead of hanging.
// cleanly rather than hanging.
// Where an in-progress capture is in its lifecycle. // Where an in-progress capture is in its lifecycle: Recording (live, transport
// Recording — live: transport running, shell keeps ticking. // running) and Finalizing (live-but-stopped, waiting for flush) are the two
// Finalizing — live-but-stopped: transport halted, shell stops the transport once // waits above; Done/Failed are terminal — the shell's verdict to act on.
// 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).
enum class RecordPhase { enum class RecordPhase {
Recording, Recording,
Finalizing, Finalizing,
@@ -176,63 +125,34 @@ enum class RecordPhase {
Failed Failed
}; };
// A distilled transport reading for the pure transition, so the state machine never // A distilled transport reading so the state machine never touches a REAPER
// touches a REAPER type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition` // type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition` is
// is GetPlayPositionEx (latency-compensated what-you-hear position). // GetPlayPositionEx (latency-compensated).
struct TransportReading { struct TransportReading {
bool recording = false; bool recording = false;
double playPosition = 0.0; double playPosition = 0.0;
}; };
// Everything the pure transition needs beyond the current phase, gathered by the // Everything the pure transition needs beyond the current phase, gathered by
// shell each tick so the machine stays REAPER-free AND owns every timing/ceiling // the shell each tick (the shell only reads and reports; never decides).
// decision (the shell only reads and reports; it never decides a transition itself).
struct RecordTickInputs { struct RecordTickInputs {
TransportReading transport; TransportReading transport;
double elapsedSeconds = 0.0; // wall-clock since begin() — record ceiling
// Wall-clock seconds since begin() (the shell reads a steady clock). Drives the double finalizingSeconds = 0.0; // wall-clock in Finalizing — flush ceiling
// record safety ceiling: a transport that starts but never advances to the range bool fileReady = false; // recorded file exists+stable (Finalizing only)
// 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;
}; };
// --- Safety ceilings (named constants, review §2/§3) ------------------------- // Record ceiling margin added to nominal duration: generous enough that
// // pre-roll/count-in/latency never trips it, tight enough a stuck transport is
// kRecordMarginSeconds: added to the record's nominal duration (end - start) to form // force-terminated within seconds.
// 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.
inline constexpr double kRecordMarginSeconds = 5.0; inline constexpr double kRecordMarginSeconds = 5.0;
// kFinalizeFlushCeilingSeconds: the max wall-clock the Finalizing phase waits for the // Max wall-clock Finalizing waits for the file to flush/stabilize before
// recorded file to flush/stabilize before giving up (RenderFailed). REAPER closes the // giving up (REAPER closes the take within a tick or two in practice).
// take on the audio thread within a tick or two in practice; this is a generous bound.
inline constexpr double kFinalizeFlushCeilingSeconds = 5.0; inline constexpr double kFinalizeFlushCeilingSeconds = 5.0;
// The pure transition: given the current phase, this tick's inputs, and the record // The pure transition (total + deterministic). Done/Failed are sticky — a late
// range end, return the next phase. Total + deterministic. // tick before teardown finishes cannot flip the verdict (the idempotence the
//
// 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
// shell's single-restore relies on). // shell's single-restore relies on).
RecordPhase advanceRecordPhase(RecordPhase current, RecordPhase advanceRecordPhase(RecordPhase current,
const RecordTickInputs& inputs, const RecordTickInputs& inputs,
+8 -11
View File
@@ -7,14 +7,14 @@ namespace reasampler::capture {
namespace { namespace {
// Base target bits (mode&3). We use only 0 (current track) and 1 (new track). // 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 kBaseCurrentTrack = 0;
constexpr int kBaseNewTrack = 1; // add new track constexpr int kBaseNewTrack = 1;
// Tempo-conform bits, verbatim from the header doc-comment. // Tempo-conform bits, verbatim from the header doc-comment.
constexpr int kMatchTempo1x = 8; // &8: try to match tempo 1x constexpr int kMatchTempo1x = 8;
constexpr int kMatchTempoHalf = 16; // &16: try to match tempo 0.5x constexpr int kMatchTempoHalf = 16;
constexpr int kMatchTempoDbl = 32; // &32: try to match tempo 2x constexpr int kMatchTempoDbl = 32;
constexpr int kDontPreservePitch = 64; // &64: don't preserve pitch when matching tempo constexpr int kDontPreservePitch = 64;
} // namespace } // namespace
@@ -24,8 +24,7 @@ int computeInsertMode(const InsertOptions& opts) {
switch (opts.conform) { switch (opts.conform) {
case TempoConform::None: case TempoConform::None:
// No tempo bits: native length, no stretch. (Also never &4.) return mode; // native length, no stretch; never &4
return mode;
case TempoConform::Ratio1x: case TempoConform::Ratio1x:
mode |= kMatchTempo1x; mode |= kMatchTempo1x;
break; break;
@@ -37,9 +36,7 @@ int computeInsertMode(const InsertOptions& opts) {
break; break;
} }
// Tempo bits are set (conform != None). Add the pitch-shift bit only when the // Reached only when a tempo bit is set (None already returned above).
// caller asked NOT to preserve pitch. When conform == None we already returned
// above, so this can never fire without a tempo bit present.
if (!opts.preservePitch) if (!opts.preservePitch)
mode |= kDontPreservePitch; mode |= kDontPreservePitch;
+15 -17
View File
@@ -1,34 +1,32 @@
#pragma once #pragma once
// insert_plan — the REAPER-free logic behind the `insert` shell (M6): computing // insert_plan — the REAPER-free logic behind the `insert` shell: computing the
// the InsertMedia `mode` bitmask from a small options struct. // InsertMedia `mode` bitmask from a small options struct.
// //
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
// vendor/ includes. Standard library only. The one genuinely testable-outside-DAW // only. The InsertMedia bitfield is easy to get wrong and its bits are
// piece of insert is the mode-bit arithmetic — the InsertMedia bitfield is easy to // load-bearing for the "no silent time-stretch" invariant, so it's factored here
// get wrong and its bits are load-bearing for the "no silent time-stretch" // and unit-tested. The REAPER-bound placement (InsertMedia call, edit-cursor
// invariant, so it is factored here and unit-tested. The REAPER-bound placement // movement, undo block) lives in insert.cpp and is DAW-verified.
// (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): // 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 // 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, // 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, // &16=try to match tempo 0.5x, &32=try to match tempo 2x,
// &64=don't preserve pitch when matching tempo, ... // &64=don't preserve pitch when matching tempo, ...
// We intentionally use only the base target (0/1) and the tempo-conform bits // We use only the base target (0/1) and the tempo-conform bits (&8/&16/&32/&64).
// (&8/&16/&32/&64). We NEVER set &4 (stretch/loop to fit time selection) — that is // We NEVER set &4 (stretch/loop to fit time selection) — the silent-time-stretch
// the silent-time-stretch path the tool forbids (CONTEXT.md §Non-goals). // path the tool forbids.
#include <cstdint> #include <cstdint>
namespace reasampler::capture { namespace reasampler::capture {
// Where InsertMedia drops the item. Maps to the low bits of `mode` (mode&3). // 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 // We expose only the two placement targets needed here; "add as takes" (3) is
// later concern (YAGNI). Both insert AT THE EDIT CURSOR — that is REAPER's // out of scope. Both insert at the edit cursor — REAPER's convention for base
// convention for base modes 0/1 (the header names no explicit edit-cursor bit; // modes 0/1 (the header names no explicit edit-cursor bit; see the flagged
// see the flagged runtime assumption in insert.cpp). // runtime assumption in insert.cpp).
enum class InsertTarget { enum class InsertTarget {
NewTrack, // mode base 1: add a new track for the item NewTrack, // mode base 1: add a new track for the item
CurrentTrack, // mode base 0: add to the current/selected track CurrentTrack, // mode base 0: add to the current/selected track
+22 -59
View File
@@ -10,9 +10,7 @@
namespace reasampler::capture { namespace reasampler::capture {
double autoTrimEndRatio() { double autoTrimEndRatio() {
// Amplitude ratio = 10^(dB/20). Derived from kAutoTrimThresholdDb so the dB is // Amplitude ratio = 10^(dB/20) (header ~3062). For -72 dB this is ~0.00025119.
// 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.
return std::pow(10.0, kAutoTrimThresholdDb / 20.0); return std::pow(10.0, kAutoTrimThresholdDb / 20.0);
} }
@@ -20,8 +18,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
TailRenderSettings t; TailRenderSettings t;
switch (mode) { switch (mode) {
case TailMode::None: case TailMode::None:
// Exact bounds — byte-identical to the pre-tail no-tail capture. Tail off, // Exact bounds — byte-identical to the pre-tail capture.
// disable-all normalize (the current default), no trim.
t.tailFlag = kTailFlagNone; t.tailFlag = kTailFlagNone;
t.tailMs = 0.0; t.tailMs = 0.0;
t.normalize = kNormalizeDisableAll; t.normalize = kNormalizeDisableAll;
@@ -29,12 +26,10 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
return t; return t;
case TailMode::Auto: case TailMode::Auto:
// Generous 8 s tail, then SURGICAL normalize: ONLY the trim-ending-silence // Surgical normalize: only the trim-ending-silence bit set, every other
// bit (32768) — every other postprocessing bit clear. A fixed-threshold // postprocessing bit clear. A fixed-threshold trim scales/limits/fades
// trailing-silence trim is a pure boundary decision (it scales/limits/fades // nothing, so identical requests trim at the identical sample -> holds
// nothing), so it re-introduces none of the coloring the disable-all bit // the bit-identical-repeats invariant.
// guarded against, and two identical requests trim at the identical sample
// -> bit-identical repeats hold (spec §surgical normalize).
t.tailFlag = kTailFlagCustomBounds; t.tailFlag = kTailFlagCustomBounds;
t.tailMs = kMaxTailMs; t.tailMs = kMaxTailMs;
t.normalize = kNormalizeTrimEnd; t.normalize = kNormalizeTrimEnd;
@@ -42,10 +37,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
return t; return t;
case TailMode::Manual: case TailMode::Manual:
// Fixed tail, no trim -> keep the disable-all normalize exactly as the // Clamped to the cap regardless of source; negative floors to 0.
// 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.
t.tailFlag = kTailFlagCustomBounds; t.tailFlag = kTailFlagCustomBounds;
t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs); t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs);
t.normalize = kNormalizeDisableAll; t.normalize = kNormalizeDisableAll;
@@ -60,14 +52,10 @@ double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
double manualTailMs) { double manualTailMs) {
switch (mode) { switch (mode) {
case TailMode::None: case TailMode::None:
// Exact no extra recording (byte-identical to today's realtime capture). return rangeEndSeconds; // exact, no extra recording
return rangeEndSeconds;
case TailMode::Auto: case TailMode::Auto:
// The 8 s runaway cap past the range end; the decay-trim shortens it later. return rangeEndSeconds + kMaxTailSeconds; // runaway cap; decay-trim shortens later
return rangeEndSeconds + kMaxTailSeconds;
case TailMode::Manual: 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; return rangeEndSeconds + std::clamp(manualTailMs, 0.0, kMaxTailMs) / 1000.0;
} }
// Unreachable for a valid enum; fail closed to exact bounds (never a stray tail). // 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*/) { RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
// `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future // wetDry doesn't affect this mapping (seam for future dry work); FX scoping
// dry work (M10 null test), but it does not affect this mapping. FX scoping is // is handled by fxBypassPlanFor, not by these render bits.
// handled by fxBypassPlanFor, not by these render bits.
RenderSettingsChoice c; RenderSettingsChoice c;
switch (mode) { switch (mode) {
case SourceMode::MasterMix: case SourceMode::MasterMix:
case SourceMode::TimeSelection: case SourceMode::TimeSelection:
// Master IS the mix — wet-only; &(1|2)==0, no source bits. c.settings = kRenderMasterMix; // wet-only, no source bits
c.settings = kRenderMasterMix;
c.supported = true; c.supported = true;
return c; return c;
case SourceMode::SelectedTracks: case SourceMode::SelectedTracks:
// Selected tracks via master (&128) — wet (post-FX). Header ~3041.
c.settings = kRenderSelTracksViaMaster; c.settings = kRenderSelTracksViaMaster;
c.supported = true; c.supported = true;
return c; return c;
case SourceMode::SelectedItems: case SourceMode::SelectedItems:
// Selected media items, rendered to ONE file (single-file bit) so a // Single-file bit so a multi-item selection yields one bank entry.
// multi-item selection yields a single bank entry, not N wavs.
c.settings = kRenderSelItems | kRenderSingleFile; c.settings = kRenderSelItems | kRenderSingleFile;
c.supported = true; c.supported = true;
return c; return c;
case SourceMode::RazorArea: case SourceMode::RazorArea:
// Render razor edits to ONE file (same single-file rationale as items).
c.settings = kRenderRazorEdits | kRenderSingleFile; c.settings = kRenderRazorEdits | kRenderSingleFile;
c.supported = true; c.supported = true;
return c; return c;
case SourceMode::Realtime: case SourceMode::Realtime:
// Not an offline-render source — the realtime backend (M8) owns it.
c.settings = kRenderMasterMix; c.settings = kRenderMasterMix;
c.supported = false; c.supported = false; // not an offline-render source
return c; return c;
} }
// Unreachable for a valid enum; fail closed (unsupported) rather than render. // Unreachable for a valid enum; fail closed (unsupported) rather than render.
@@ -135,17 +117,13 @@ FxBypassPlan fxBypassPlanFor(CaptureScope scope) {
FxBypassPlan p; FxBypassPlan p;
switch (scope) { switch (scope) {
case CaptureScope::Item: case CaptureScope::Item:
// Item = take/item FX ONLY. Bypass the item's own track FX, every // Take FX live in the item and are always rendered — bypass everything else.
// 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.)
p.bypassSelfFx = true; p.bypassSelfFx = true;
p.bypassAncestorFx = true; p.bypassAncestorFx = true;
p.bypassMaster = true; p.bypassMaster = true;
return p; return p;
case CaptureScope::Track: case CaptureScope::Track:
// Track = item FX + the selected track's OWN FX. Keep self FX; bypass // Keep self FX; bypass every ancestor (parent/folder) and the master.
// every ancestor (parent/folder) and the master. Parent/master GAIN
// still applies (I_FXEN is FX-only) — documented boundary.
p.bypassSelfFx = false; p.bypassSelfFx = false;
p.bypassAncestorFx = true; p.bypassAncestorFx = true;
p.bypassMaster = true; p.bypassMaster = true;
@@ -158,24 +136,19 @@ std::vector<RazorRange> parseRazorEdits(const std::string& razorString) {
std::vector<RazorRange> ranges; std::vector<RazorRange> ranges;
std::istringstream in(razorString); 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; std::string startTok, endTok, guidTok;
while (in >> startTok >> endTok >> guidTok) { while (in >> startTok >> endTok >> guidTok) {
// Envelope-lane areas carry a real GUID; skip them (razor captures track audio only). // Skip envelope-lane areas (real GUID); keep only track-audio (`""`).
// A track-audio area's GUID token is the empty quoted string `""`.
if (guidTok != "\"\"") continue; if (guidTok != "\"\"") continue;
// Parse the two time tokens. std::stod throws on garbage — guard so one // std::stod throws on garbage — guard so one malformed triple doesn't
// malformed triple does not abort the whole parse. // abort the whole parse.
double start = 0.0, end = 0.0; double start = 0.0, end = 0.0;
try { try {
std::size_t sp = 0, ep = 0; std::size_t sp = 0, ep = 0;
start = std::stod(startTok, &sp); start = std::stod(startTok, &sp);
end = std::stod(endTok, &ep); end = std::stod(endTok, &ep);
// Reject tokens with trailing garbage (e.g. "1.0x") — a partial parse // Reject trailing garbage (e.g. "1.0x") — a partial parse is malformed.
// is a malformed area, not a valid range.
if (sp != startTok.size() || ep != endTok.size()) continue; if (sp != startTok.size() || ep != endTok.size()) continue;
} catch (...) { } catch (...) {
continue; continue;
@@ -197,23 +170,13 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges) {
} }
const std::vector<CaptureActionDef>& captureActionTable() { const std::vector<CaptureActionDef>& captureActionTable() {
// Built once (function-local static): two SCOPE actions, item + track. Both // FOREVER-STABLE ids — never edit a shipped string. No master capture
// exact bounds by default; the tail mode a capture applies is read from the // action (its id was retired; do not reintroduce it).
// 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.
static const std::vector<CaptureActionDef> table = { 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_ITEM",
"capture selected item(s)", "item", "capture selected item(s)", "item",
CaptureScope::Item}, CaptureScope::Item},
// Track scope — item FX + the track's own FX.
{"CAPTURE_TRACK", {"CAPTURE_TRACK",
"capture selected track(s)", "track", "capture selected track(s)", "track",
CaptureScope::Track}, CaptureScope::Track},
+67 -148
View File
@@ -1,26 +1,9 @@
#pragma once #pragma once
// render_settings — the REAPER-free logic behind the capture action family. // render_settings — the REAPER-free logic behind the capture action family:
// // sourceMode -> RENDER_SETTINGS bits, P_RAZOREDITS parsing + range union,
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // razor-else-time inference, the FX-scope bypass plan, and the capture-action
// vendor/ includes. Standard library only. The capture shell (capture.cpp) and // table main.cpp iterates. Bit MEANINGS below are transcribed verbatim from
// action layer (main.cpp) read the actual DAW state (time selection, selected // reaper_plugin_functions.h; the CHOICE of which bits each mode sets is tested.
// 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.
#include <string> #include <string>
#include <vector> #include <vector>
@@ -32,67 +15,44 @@ namespace reasampler::capture {
using model::SourceMode; using model::SourceMode;
// --- RENDER_SETTINGS source/processing bits (verbatim from SDK header ~3041) -- // --- 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 kRenderMasterMix = 0; // (&(1|2))==0, no source bits
inline constexpr int kRenderSelItems = 32; // &32 selected media items inline constexpr int kRenderSelItems = 32; // &32 selected media items
inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master
inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master
inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits
// NOTE: kRenderPreFaderStems (&8192) is NOT used. REAPER offline render has no // kRenderPreFaderStems (&8192) is deliberately NOT used REAPER offline render
// true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-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 // mechanism (see fxBypassPlan below), not by any render bit. All capture actions
// fall outside a scope — NOT by any render bit. All capture actions render wet // render wet; the scope decides which FX remain enabled.
// (post the FX that remain enabled); the scope decides which FX remain enabled.
inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file
// --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ---------- // --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ----------
// //
// The capture-tail feature (docs/product/capture-tail.md) preserves reverb/release // Every offline capture renders custom-time-bounds, so &1 (RENDER_TAILFLAG,
// decay past the range end. Every offline capture renders custom-time-bounds, so // header ~3047) is the only tail-flag bit that ever applies. RENDER_NORMALIZE
// the only tail-flag bit that ever applies is &1 (RENDER_TAILFLAG, header ~3047). // (verbatim, header ~3051): &32768 = trim ending silence (Auto path);
// These values are the pure part — mode -> (RENDER_* values) — unit-tested outside // &(4<<16) = disable all render postprocessing (None/Manual path).
// 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)
inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence
inline constexpr int kNormalizeDisableAll = (4 << 16); // &(4<<16) = 262144, disable all 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 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 // Auto-trim trailing-silence threshold; single source of truth (RENDER_TRIMEND
// region is inaudible decay, loud enough to not chase a reverb's infinite noise // ratio derives from this dB, never the reverse). Daniel-set.
// floor. Daniel-set. Single source of truth: the RENDER_TRIMEND ratio derives from
// this dB, never the reverse.
inline constexpr double kAutoTrimThresholdDb = -72.0; inline constexpr double kAutoTrimThresholdDb = -72.0;
// Max tail rendered past the range end. The runaway guard: a non-decaying or // Runaway guard: max tail rendered past the range end, so a non-decaying or
// looping signal never crosses the trim threshold, so this caps the render. // looping signal doesn't render forever. Daniel-set; shared by offline+realtime.
// Daniel-set. Shared by the offline (T1) and future realtime (T2) tail paths.
inline constexpr double kMaxTailSeconds = 8.0; inline constexpr double kMaxTailSeconds = 8.0;
inline constexpr double kMaxTailMs = 8000.0; inline constexpr double kMaxTailMs = 8000.0;
// Derived linear amplitude ratio for RENDER_TRIMEND. The header (~3062) documents // Derived linear amplitude ratio for RENDER_TRIMEND (header ~3062: an amplitude
// RENDER_TRIMEND as an amplitude ratio ("0.5 means -6.02 dB"), i.e. 10^(dB/20). // ratio, "0.5 means -6.02 dB", i.e. 10^(dB/20)) from kAutoTrimThresholdDb.
// Derived from kAutoTrimThresholdDb so the dB stays the single source of truth and // Function not constant: std::pow isn't constexpr before C++26.
// 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).
double autoTrimEndRatio(); double autoTrimEndRatio();
// The three tail states (docs/product/capture-tail.md §The three tail states): // The three tail states — see src/core/capture/CLAUDE.md.
// 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.
enum class TailMode { enum class TailMode {
None, None,
Auto, Auto,
@@ -100,9 +60,9 @@ enum class TailMode {
}; };
// The RENDER_* values a tail mode drives, in addition to the exact STARTPOS/ENDPOS // 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 // the backend already sets. `trimEnd` is meaningful only when the trim-end
// bit is set (Auto); it is 0 otherwise. This is the pure mapping — the backend reads // normalize bit is set (Auto). The backend reads these straight onto
// these four fields straight onto GetSetProjectInfo. // GetSetProjectInfo.
struct TailRenderSettings { struct TailRenderSettings {
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1) int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1)
double tailMs = 0.0; // RENDER_TAILMS double tailMs = 0.0; // RENDER_TAILMS
@@ -110,54 +70,36 @@ struct TailRenderSettings {
double trimEnd = 0.0; // RENDER_TRIMEND (only used when trim bit set) 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. // Maps a tail mode (+ requested manual tail ms, used only for Manual) to its
// `manualTailMs` is used ONLY for TailMode::Manual (ignored otherwise). Manual is // RENDER_* values. Manual is clamped to kMaxTailMs regardless of source.
// clamped to kMaxTailMs — the runaway guard applies whether the length came from
// the Auto default or an explicit request (spec §Manual override). Pure + tested.
TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs); TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs);
// The REALTIME record-window end (in project seconds) a tail mode records to, given // The realtime record-window end (project seconds): realtime does NOT drive
// the request's exact range end (docs/product/capture-tail.md §The realtime path). // RENDER_*, it records a generous window and trims later, so this is where the
// Realtime does NOT drive RENDER_*; it records a generous window and trims later, so // transport actually stops. None -> exact rangeEndSeconds; Auto -> +8s runaway
// the window end is where the transport actually stops: // cap; Manual -> + clamp(manualTailMs, kMaxTailMs)/1000.
// 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).
double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds, double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
double manualTailMs); double manualTailMs);
// The RENDER_SETTINGS value for a given source mode. `supported` is false only // 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 { struct RenderSettingsChoice {
int settings = kRenderMasterMix; int settings = kRenderMasterMix;
bool supported = true; // false => not an offline-render source (e.g. Realtime) 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 // 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. // covers); FX scoping is orthogonal (done by fxBypassPlan). `wetDry` is
// `wetDry` is accepted but ignored for the mapping — retained in CaptureRequest // accepted but ignored — retained as the seam for future dry work. CONFIRMED
// as the seam for future dry work (M10 null test). // (SDK header ~3041): MasterMix/TimeSelection -> 0; SelectedTracks -> &128;
// // SelectedItems -> &32|single-file; RazorArea -> &4096|single-file.
// 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.
RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry); 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. // See src/core/capture/CLAUDE.md for the scope contract. There is NO master
// Item -> item/take FX ONLY (no track, no parent/folder, no master FX). // scope; the master track's FX/gain/pan are still NEUTRALIZED as part of the
// Track -> item FX + the selected track's OWN track FX (no parent/folder/master). // out-of-scope chain (bypassMaster below) — master is a bypass target only.
// 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.
enum class CaptureScope { enum class CaptureScope {
Item, Item,
Track, Track,
@@ -169,34 +111,28 @@ SourceMode sourceModeForScope(CaptureScope scope);
// --- Range inference: razor-else-time (orthogonal to scope) ------------------- // --- Range inference: razor-else-time (orthogonal to scope) -------------------
// //
// Every scope action infers its render range the same way: if a razor area is // Razor-present -> razor union; otherwise time selection. Razor is a range
// present, use the razor union; otherwise use the time selection. Razor is a // source, not a capture mode.
// range SOURCE, not a capture mode (the M7 four-mode model conflated them).
enum class RangeSource { enum class RangeSource {
Razor, // a razor area is present -> use its union bound Razor, // a razor area is present -> use its union bound
TimeSelection, // no razor -> use the time selection TimeSelection, // no razor -> use the time selection
}; };
// Picks the range source. Pure so the "razor wins when present" rule is tested // Picks the range source. Pure so "razor wins when present" is tested without
// without a DAW; the shell supplies whether any razor area was found. // a DAW; the shell supplies whether any razor area was found.
RangeSource inferRangeSource(bool hasRazorArea); RangeSource inferRangeSource(bool hasRazorArea);
// --- FX-bypass plan: which tracks' FX to bypass for a scope ------------------- // --- FX-bypass plan: which tracks' FX to bypass for a scope -------------------
// //
// Given a CaptureScope, returns three boolean flags: whether to bypass (a) the // Given a CaptureScope, returns three boolean flags: bypass (a) the captured
// captured track's OWN FX, (b) each of its ancestor (parent/folder) tracks' FX, // track's OWN FX, (b) every ancestor (parent/folder) track's FX, (c) the
// and (c) the master FX. The caller (FxBypassGuard) resolves these flags to // master FX. The caller (FxBypassGuard, shell) walks the ancestor chain via
// concrete MediaTrack* by walking the ancestor chain via GetParentTrack and // GetParentTrack, clears I_FXEN on each flagged track (RAII restore), and also
// clears I_FXEN on each flagged track, snapshotting first (RAII restore). // 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
// SCOPE BOUNDARY: I_FXEN bypasses a track's FX plugins but NOT its volume/pan. // ignored entirely, so forcing it is what makes the other neutralizations
// The guard (FxBypassGuard, main.cpp) therefore ALSO neutralizes the fader GAIN // take effect) — I_FXEN alone doesn't touch a track's volume/pan. This plan
// (D_VOL -> unity) of every track in this same bypass set, so a Track/Item // selects the set; the guard applies both the FX bypass and the neutralize.
// 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.
struct FxBypassPlan { struct FxBypassPlan {
bool bypassSelfFx = false; // the captured track's own FX bool bypassSelfFx = false; // the captured track's own FX
bool bypassAncestorFx = false; // every ancestor (parent/folder) track's 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 // Parses ONE track's P_RAZOREDITS string (SDK header ~2899): space-separated
// TRIPLES of <start> <end> <envGuidString>. The envelope GUID is "" (an empty // TRIPLES of <start> <end> <envGuidString>, envGuid == `""` for a track-audio
// quoted string, i.e. the literal two chars `""`) for a track-audio area and a // area vs a GUID for an envelope-lane area. Returns only track-audio ranges
// GUID like {…} for an envelope-lane area. // (envelope-lane triples skipped); malformed trailing tokens are ignored, not
// // fatal; a range with end <= start is dropped.
// 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).
std::vector<RazorRange> parseRazorEdits(const std::string& razorString); std::vector<RazorRange> parseRazorEdits(const std::string& razorString);
// The union bound (min start, max end) of a set of razor ranges — the exact // 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. // window the offline render must cover. {0,0} for empty input ("no razor area").
// Returns {0,0} for an empty input (caller treats that as "no razor area").
RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges); RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges);
// --- Capture-action taxonomy (the bindable set main.cpp registers) ----------- // --- Capture-action taxonomy (the bindable set main.cpp registers) -----------
// //
// One row per bindable SCOPE action: item and track. The range each captures // One row per bindable scope action (item/track); range inference and tail
// (razor-else-time) is inferred at fire time, not a mode. TAIL is NOT a per-action // mode are read at fire time, not baked into the row. The row stores only the
// variant — the tail MODE (None/Auto/Manual) is a panel SETTING the capture reads // channel-agnostic command-id SUFFIX + description PHRASE; the registering
// at fire time (see tail_control + bank_panel), so a single pair of actions covers // shell composes the full channel-qualified id/name via app_version.
// every tail state. Bounded, discoverable, NO dialogs (the tool's no-clutter ethos).
// //
// Phase V (V4): the row stores the channel-AGNOSTIC pieces — a command-id SUFFIX (the // commandSuffix is FOREVER-STABLE (user keybindings key off the composed id).
// 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).
struct CaptureActionDef { struct CaptureActionDef {
const char* commandSuffix; // e.g. "CAPTURE_TRACK" — FOREVER-STABLE (composed w/ prefix) const char* commandSuffix; // e.g. "CAPTURE_TRACK" — FOREVER-STABLE (composed w/ prefix)
const char* descriptionPhrase; // e.g. "capture selected track(s)" — Actions-list phrase const char* descriptionPhrase; // e.g. "capture selected track(s)" — Actions-list phrase
@@ -252,14 +174,11 @@ struct CaptureActionDef {
CaptureScope scope; // FX scope (item / track) CaptureScope scope; // FX scope (item / track)
}; };
// The capture-action table. Iterated by main.cpp to register the family and route // The capture-action table. Iterated by main.cpp to register the family and
// each fired command back to its definition. Kept here (pure) so the taxonomy is // route each fired command back to its definition.
// one testable list, not scattered registration code.
// //
// Two rows: CAPTURE_ITEM / CAPTURE_TRACK. There is no master capture — to capture // Two rows: CAPTURE_ITEM / CAPTURE_TRACK. There is no master capture — to
// the master you render a track. Razor is an inferred range, not a mode, and each // capture the master you render a track.
// 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.
const std::vector<CaptureActionDef>& captureActionTable(); const std::vector<CaptureActionDef>& captureActionTable();
} // namespace reasampler::capture } // 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" #include "core/capture/tail_control.h"
@@ -19,14 +19,10 @@ TailMode cycleTailMode(TailMode current) {
} }
double clampManualMs(double manualMs) { 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); return std::clamp(manualMs, 0.0, kMaxTailMs);
} }
double adjustManualMs(double current, int notches, double stepMs) { 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); return clampManualMs(current + notches * stepMs);
} }
@@ -35,8 +31,8 @@ std::string tailToggleLabel(const TailSetting& setting) {
case TailMode::None: return "Tail: Off"; case TailMode::None: return "Tail: Off";
case TailMode::Auto: return "Tail: Auto"; case TailMode::Auto: return "Tail: Auto";
case TailMode::Manual: { case TailMode::Manual: {
// Append the CLAMPED length in seconds to one decimal so the readout can // Clamped so the readout can't show an over-cap value even if
// never show an over-cap value even if manualMs was stored past the cap. // manualMs was stored past the cap.
const double seconds = clampManualMs(setting.manualMs) / 1000.0; const double seconds = clampManualMs(setting.manualMs) / 1000.0;
char buf[32]; char buf[32];
std::snprintf(buf, sizeof(buf), "Tail: Manual %.1fs", seconds); 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 return "Tail: Off"; // unreachable for a valid enum; fail to the safe default
} }
// --------------------------------------------------------------------------- // --- JSON round-trip ---------------------------------------------------------
// JSON round-trip // manualMs round-trips exactly (json::numToStr uses the shortest %.17g-class
// --------------------------------------------------------------------------- // form for doubles); deserialize returns nullopt on any parse failure.
//
// The setting is a flat object of one enum + one double, riding the shared
// core/json layer (Q-W1, T2-02: the former substring-scan valueAfterKey reader —
// the fifth hand-rolled JSON decoder — is retired). manualMs is emitted with 17
// significant digits (%.17g) — the shortest form that round-trips every IEEE-754
// double exactly — so deserialize(serialize(x)) == x holds bit-for-bit.
// deserialize stays forgiving in outcome: any parse failure returns nullopt so
// the caller falls back to a default, exactly as an absent ext-state key does.
namespace { namespace {
+19 -39
View File
@@ -1,13 +1,7 @@
#pragma once #pragma once
// tail_control — the REAPER-free logic behind the docked bank_panel's tail-mode // 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 // toggle. The panel shell owns the SWELL window, LICE drawing, and click
// click hit-testing; what is NOT DAW-bound — the cycle order, the manual-length // hit-testing; the cycle order, manual-length clamp, and label text live here.
// 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.
#include <optional> #include <optional>
#include <string> #include <string>
@@ -16,54 +10,40 @@
namespace reasampler::capture { namespace reasampler::capture {
// The Manual-mode starting length. 2 s is a musically useful default tail (a bar of // The Manual-mode starting length: 2s, a musically useful default (a bar of
// reverb throw at a moderate tempo) that is well under the 8 s cap. Also the value a // reverb throw at moderate tempo), well under the 8s cap. Also the fallback
// project with no stored tail setting (older / never-adjusted) falls back to on load. // for a project with no stored tail setting.
inline constexpr double kDefaultManualTailMs = 2000.0; inline constexpr double kDefaultManualTailMs = 2000.0;
// The fine-adjust step per scroll-wheel notch in Manual mode. 250 ms is coarse enough // Fine-adjust step per scroll-wheel notch in Manual mode. Daniel-set.
// 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.
inline constexpr double kManualStepMs = 250.0; inline constexpr double kManualStepMs = 250.0;
// The panel's current tail setting: the mode plus the length used ONLY when the // The panel's current tail setting: mode + the length used only when Manual.
// mode is Manual. Held as in-memory panel/session state (shell/panel), default // Default None so a capture with no explicit choice stays exact-bounds.
// None so a capture with no explicit choice stays exact-bounds / byte-identical to // `manualMs` is clamped to kMaxTailMs before it ever reaches a CaptureRequest.
// 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.
struct TailSetting { struct TailSetting {
TailMode mode = TailMode::None; TailMode mode = TailMode::None;
double manualMs = kDefaultManualTailMs; double manualMs = kDefaultManualTailMs;
}; };
// Cycles the tail mode: None -> Auto -> Manual -> None. Pure so the wrap order is // Cycles the tail mode: None -> Auto -> Manual -> None.
// 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.
TailMode cycleTailMode(TailMode current); TailMode cycleTailMode(TailMode current);
// The effective manual length a Manual capture uses: `manualMs` clamped to // The effective manual length a Manual capture uses: clamped to [0, kMaxTailMs].
// [0, kMaxTailMs] (the runaway guard the pure tailRenderSettingsFor also applies). // Exposed so the panel can show the clamped value. Meaningful only for Manual.
// 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.
double clampManualMs(double manualMs); double clampManualMs(double manualMs);
// Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped to // Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped
// [0, kMaxTailMs]. Positive notches lengthen, negative shorten. Pure so the fine-adjust // to [0, kMaxTailMs]. Meaningful only for TailMode::Manual.
// 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.
double adjustManualMs(double current, int notches, double stepMs); 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 // The toggle's label, e.g. "Tail: Off", "Tail: Auto", or (Manual, clamped
// clamped length is appended in seconds to one decimal, e.g. "Tail: Manual 2.0s" // length to one decimal) "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).
std::string tailToggleLabel(const TailSetting& setting); std::string tailToggleLabel(const TailSetting& setting);
// JSON round-trip of a TailSetting (mode + manualMs), for persist to store the tail // JSON round-trip of a TailSetting, for persist to store per-project. Pure/
// setting per-project alongside the bank and view model. Kept pure/testable here — // testable here, mirroring bank_model's serialize/deserialize; deserialize
// the natural home, mirroring bank_model's serialize/deserialize. serialize emits a // returns nullopt on malformed input so the caller falls back to a default.
// 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.
std::string serializeTailSetting(const TailSetting& setting); std::string serializeTailSetting(const TailSetting& setting);
std::optional<TailSetting> deserializeTailSetting(const std::string& json); 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. // wav_codec — pure implementation. See wav_codec.h. The one RIFF chunk
// // traversal lives here (nextWavChunk); layout parse and content hash both
// The ONE RIFF chunk traversal lives here (nextWavChunk); the layout parse and the // walk with it, so their view of the container cannot drift.
// content hash both walk with it, so their view of the container cannot drift.
#include "core/capture/wav_codec.h" #include "core/capture/wav_codec.h"
@@ -12,8 +11,7 @@ namespace reasampler::capture {
namespace { namespace {
// Little-endian readers. Bounds are checked by the caller before each read; these // Little-endian readers. Caller checks bounds before each read (off + N <= size).
// assume `off + N <= bytes.size()`. memcpy avoids alignment/aliasing UB.
std::uint16_t readU16LE(const std::vector<std::uint8_t>& b, std::size_t off) { 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)); 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; 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 kWaveFormatIeeeFloat = 0x0003;
constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE; constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE;
@@ -37,19 +35,16 @@ constexpr std::uint64_t kFnvOffsetBasis = 14695981039346656037ULL;
constexpr std::uint64_t kFnvPrime = 1099511628211ULL; constexpr std::uint64_t kFnvPrime = 1099511628211ULL;
std::string fnvHex(std::uint64_t h) { std::string fnvHex(std::uint64_t h) {
// 16-digit lowercase hex (zero-padded) for a fixed-length string. char buf[17]; // 16 hex digits, zero-padded
char buf[17];
std::snprintf(buf, sizeof(buf), "%016llx", static_cast<unsigned long long>(h)); std::snprintf(buf, sizeof(buf), "%016llx", static_cast<unsigned long long>(h));
return std::string(buf); 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 // One sub-chunk of a RIFF/WAVE container: header at `headerOffset` (id(4) +
// `headerOffset` (id(4) + size(4)), body at `bodyOffset` with declared `bodySize`. // size(4)), body at `bodyOffset`/`bodySize`. `bodyInBounds` false means the
// `bodyInBounds` is whether the declared body fits inside the buffer — a chunk // declared body runs past the buffer — still reported, but must not be read.
// whose declared size lies past the end is still REPORTED (callers decide how to
// treat it) but its body must not be read.
struct WavChunkView { struct WavChunkView {
std::size_t headerOffset = 0; std::size_t headerOffset = 0;
std::size_t bodyOffset = 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 // 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 // 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 // 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 // would overrun the buffer, the chunk is still reported (return true) and `pos`
// parked past the end so the NEXT call returns false — exactly the process-then- // is parked past the end so the next call returns false.
// break shape the pre-consolidation walkers shared.
bool nextWavChunk(const std::vector<std::uint8_t>& bytes, std::size_t& pos, bool nextWavChunk(const std::vector<std::uint8_t>& bytes, std::size_t& pos,
WavChunkView& out) { WavChunkView& out) {
if (pos + 8 > bytes.size()) return false; 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::uint32_t sampleRate = 0;
std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible
// Walk the sub-chunks after "WAVE" (offset 12) with the shared traversal. A // Walk the sub-chunks after "WAVE" (offset 12). A malformed/truncated file
// malformed/truncated file is "invalid", never an OOB read. // is "invalid", never an OOB read.
std::size_t pos = 12; std::size_t pos = 12;
WavChunkView c; WavChunkView c;
while (nextWavChunk(bytes, pos, 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); channels = readU16LE(bytes, c.bodyOffset + 2);
sampleRate = readU32LE(bytes, c.bodyOffset + 4); sampleRate = readU32LE(bytes, c.bodyOffset + 4);
bitsPerSample = readU16LE(bytes, c.bodyOffset + 14); bitsPerSample = readU16LE(bytes, c.bodyOffset + 14);
// For WAVE_FORMAT_EXTENSIBLE (0xFFFE), read the SubFormat GUID's leading // WAVE_FORMAT_EXTENSIBLE: the real format lives in the SubFormat GUID's
// 2-byte tag at body offset 24 to distinguish float (0x0003) from PCM // leading 2-byte tag at body offset 24, not in fmtTag itself. Body must
// integer (0x0001) and all other sub-formats. Body must be >= 40 bytes to // reach offset 24+16; otherwise leave the tag at 0 (rejected).
// reach GUID offset 24 + 16 bytes of GUID, and the full GUID must fit in
// the buffer; otherwise we leave extensibleSubFormatTag at 0 (rejected).
if (fmtTag == kWaveFormatExtensible) { if (fmtTag == kWaveFormatExtensible) {
if (c.bodySize >= 40 && c.bodyOffset + 40 <= bytes.size()) { if (c.bodySize >= 40 && c.bodyOffset + 40 <= bytes.size()) {
extensibleSubFormatTag = readU16LE(bytes, c.bodyOffset + 24); extensibleSubFormatTag = readU16LE(bytes, c.bodyOffset + 24);
@@ -124,16 +116,13 @@ WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
} }
haveFmt = true; haveFmt = true;
} else if (tagEquals(bytes, c.headerOffset, "data")) { } else if (tagEquals(bytes, c.headerOffset, "data")) {
// The data chunk: PCM starts at bodyOffset, declared length bodySize. // Reject if the declared body runs past the buffer (truncated/lying
// Reject if it runs past the buffer (truncated / lying header). // header), or if data arrived before fmt.
if (!c.bodyInBounds) return out; 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) is float only when its SubFormat sub-tag is
// Extensible tag (0xFFFE): accept only when the SubFormat tag read from // also IEEE-float (0x0003) — PCM-integer-in-extensible must be rejected.
// the GUID at body offset 24 is also 0x0003 (IEEE float). SubFormat tag
// 0x0001 (PCM integer) or anything else with bitsPerSample==32 is NOT
// float and must be rejected to prevent mis-decoding as float.
const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) || const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) ||
(fmtTag == kWaveFormatExtensible && (fmtTag == kWaveFormatExtensible &&
extensibleSubFormatTag == kWaveFormatIeeeFloat); 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) { 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)) { if (isRiffWave(bytes)) {
std::uint64_t h = kFnvOffsetBasis; std::uint64_t h = kFnvOffsetBasis;
auto feedByte = [&](std::uint8_t b) { auto feedByte = [&](std::uint8_t b) {
@@ -295,23 +278,18 @@ std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
bool haveFmt = false; bool haveFmt = false;
bool haveData = false; bool haveData = false;
// Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a feedByte(static_cast<std::uint8_t>('W')); // domain-separation prefix
// whole-file hash of different bytes that happen to be the same length.
feedByte(static_cast<std::uint8_t>('W'));
std::size_t pos = 12; std::size_t pos = 12;
WavChunkView c; WavChunkView c;
while (nextWavChunk(bytes, pos, c)) { while (nextWavChunk(bytes, pos, c)) {
if (tagEquals(bytes, c.headerOffset, "fmt ")) { 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) { if (c.bodyInBounds) {
for (std::uint32_t i = 0; i < c.bodySize; ++i) for (std::uint32_t i = 0; i < c.bodySize; ++i)
feedByte(bytes[c.bodyOffset + i]); feedByte(bytes[c.bodyOffset + i]);
haveFmt = true; haveFmt = true;
} }
} else if (tagEquals(bytes, c.headerOffset, "data")) { } else if (tagEquals(bytes, c.headerOffset, "data")) {
// Feed the entire PCM payload.
if (c.bodyInBounds) { if (c.bodyInBounds) {
for (std::uint32_t i = 0; i < c.bodySize; ++i) for (std::uint32_t i = 0; i < c.bodySize; ++i)
feedByte(bytes[c.bodyOffset + i]); feedByte(bytes[c.bodyOffset + i]);
+38 -103
View File
@@ -1,39 +1,9 @@
#pragma once #pragma once
// wav_codec — the ONE pure owner of the WAV/RIFF byte format (Q-W3, audit §4e: // wav_codec — the pure owner of the WAV/RIFF byte format: chunk walker, layout
// T2-08 / T4-10 / T4-23 consolidation). Chunk walker + layout parse + float32 // parse, float32 build, size-field patch, and the WAV-aware content hash — one
// build + size-field patch + the WAV-aware content hash, in one tested module. // 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
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // w/ float subformat, float32 `data`); anything else parses as invalid.
// vendor/ includes. Standard library only. Builds and unit-tests without REAPER.
//
// Before this module, RIFF container knowledge (chunk-header arithmetic, even-byte
// padding, size fields) was minted at four sites: wav_trim's layout parse,
// capture_paths' content-hash chunk walk, ingest's hand-built float32 writer, and
// capture_realtime's in-place size patch. A drift in any one (e.g. pad-byte
// handling) would desynchronize hashing from decoding — the dedup-by-hash and
// null-test invariants both sit on this. Now every walker/builder/patcher is here,
// on ONE chunk-traversal implementation.
//
// WHY TRIM EXISTS (docs/product/capture-tail.md §The realtime path). The realtime
// backend records a generous tail window, then trims the trailing decay by
// truncating the recorded WAV at a frame boundary. Truncating a WAV correctly is
// not "chop the bytes": the RIFF container's size fields (the top-level RIFF chunk
// size and the `data` sub-chunk size) must be patched to the kept byte count, or
// the file is a corrupt / mis-lengthed WAV. That header arithmetic — chunk walking,
// format verification, and the size-field patch offsets — is exactly the fiddly,
// easy-to-get-wrong logic the discipline unit-tests OUTSIDE the DAW. The REAPER
// shell does only the file I/O: read the bytes, call the pure parse, run the decay
// scan, call the pure plan, patch + write the truncated bytes.
//
// FORMAT ASSUMPTION (flagged for DAW-verify). We record 32-bit float WAV
// (capture.cpp kRenderFormatWavFloat32; realtime records via REAPER's project
// record format, which the manual procedure sets to WAV/32-bit-float). The parser
// therefore verifies canonical PCM/IEEE-float WAV: a RIFF/WAVE container, a `fmt `
// chunk declaring 32-bit float (format tag 3, or tag 0xFFFE WAVE_FORMAT_EXTENSIBLE
// with 32 bits), and a `data` chunk of interleaved little-endian float32. Anything
// else (a different depth, a non-WAV, a compressed source) is reported invalid and
// the shell SKIPS the trim (keeps the untrimmed window) rather than corrupting a
// file it does not understand. This is deliberately conservative.
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
@@ -49,22 +19,20 @@ using audio::AudioSample;
// --- Layout parse ------------------------------------------------------------ // --- Layout parse ------------------------------------------------------------
// The parsed geometry of a canonical 32-bit-float WAV. `valid` is false when the // 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 // bytes are not a WAV we can safely trim; every other field is meaningful only
// is meaningful only when valid. // when valid.
struct WavLayout { struct WavLayout {
bool valid = false; bool valid = false;
std::uint16_t channelCount = 0; // from `fmt ` (the interleave stride) std::uint16_t channelCount = 0; // from `fmt ` (interleave stride)
std::uint32_t sampleRate = 0; // from `fmt ` (for frame<->seconds, if needed) std::uint32_t sampleRate = 0;
// The `data` chunk: byte offset of its first PCM byte within the file, and its // The `data` chunk: PCM byte offset + declared length.
// declared PCM byte length. frameCount = dataByteLength / (channelCount * 4). // frameCount = dataByteLength / (channelCount * 4).
std::size_t dataByteOffset = 0; std::size_t dataByteOffset = 0;
std::size_t dataByteLength = 0; std::size_t dataByteLength = 0;
// Byte offset of the two little-endian uint32 size fields the truncate patch // Offsets of the two LE uint32 size fields the truncate patch rewrites.
// rewrites: the top-level RIFF chunk size (bytes 4..7) and the `data` sub-chunk
// size (the 4 bytes immediately before dataByteOffset).
std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file
std::size_t dataSizeFieldOffset = 0; std::size_t dataSizeFieldOffset = 0;
@@ -74,19 +42,15 @@ struct WavLayout {
} }
}; };
// Parses a WAV byte buffer's header geometry. Returns {valid=false} for anything // Parses a WAV byte buffer's header geometry; {valid=false} for anything not a
// that is not a canonical 32-bit-float RIFF/WAVE with a `fmt ` and a `data` chunk, // canonical float32 RIFF/WAVE, or a `data` length running past the buffer.
// or whose declared `data` length runs past the buffer. Does NOT copy PCM — it only // Does not copy PCM, only locates it. Pure + total (no throw, no UB).
// locates it (extractFloatFrames does the copy). Pure + total (no throw, no UB).
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes); WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes);
// Copies `frameCount` interleaved float frames starting at `startFrame` out of the // Copies `frameCount` interleaved float frames starting at `startFrame` out of
// WAV's `data` region into a flat [f0c0,f0c1,...] buffer (the shape peaks consumes). // the WAV's `data` region into a flat [f0c0,f0c1,...] buffer, clamped to frames
// Clamps to the frames the buffer actually holds — never reads past `data`. Returns // actually present; never reads past `data`. Reads little-endian via memcpy —
// empty for an invalid layout or an out-of-range start. The floats are read // target is x86/ARM-LE only, no big-endian byte-swap.
// little-endian via std::memcpy (no aliasing UB); on a big-endian host they would
// need a byte-swap — flagged, not handled, because the target (Windows/macOS/Linux
// on x86/ARM-LE) is little-endian and REAPER writes LE WAV.
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes, std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
const WavLayout& layout, const WavLayout& layout,
std::size_t startFrame, std::size_t startFrame,
@@ -94,10 +58,8 @@ std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& byt
// --- Truncate plan + size-field patch --------------------------------------- // --- Truncate plan + size-field patch ---------------------------------------
// The plan to truncate a parsed WAV to `keptFrames` frames: the new total file byte // The plan to truncate a parsed WAV to `keptFrames` frames. `valid` is false if
// length and the two size-field values to patch. `valid` is false if the layout is // the layout is invalid or keptFrames exceeds the file's frames (never grow).
// invalid or keptFrames exceeds the file's frames (never GROW a file — the caller
// clamps beforehand; this guards it too).
struct WavTruncatePlan { struct WavTruncatePlan {
bool valid = false; bool valid = false;
@@ -109,64 +71,37 @@ struct WavTruncatePlan {
// the 8-byte "RIFF"+size prefix) // the 8-byte "RIFF"+size prefix)
}; };
// Computes the truncate plan to keep exactly `keptFrames` frames of a parsed WAV. // Computes the truncate plan to keep exactly `keptFrames` frames. The shell
// keptFrames == layout.frameCount() is a valid no-op plan (file unchanged). Pure + // applies it: patch the two size fields (patchU32LE), then truncate to
// total. The shell applies it: patch the two size fields in the byte buffer // newFileByteLength.
// (patchU32LE), then truncate the file to newFileByteLength.
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames); WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames);
// Patches a little-endian uint32 into a byte buffer at `off` — the RIFF/data size // Patches a little-endian uint32 into a byte buffer at `off`. Caller guarantees
// fields the truncate plan names. The caller guarantees off + 4 <= bytes.size() // off + 4 <= bytes.size() (the plan's offsets came from a valid parse of the same
// (the plan's offsets came from a valid parse of the same buffer). // buffer).
void patchU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v); void patchU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v);
// --- Float32 WAV build ------------------------------------------------------- // --- Float32 WAV build -------------------------------------------------------
// Builds a minimal canonical 32-bit-float RIFF/WAVE byte buffer from interleaved // Builds a minimal canonical float32 RIFF/WAVE byte buffer from interleaved
// double samples: RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT, // double samples (narrowed to float by cast). Round-trips through
// 16-byte body), data chunk (interleaved little-endian float32). `nch` channels, // parseWavLayout/extractFloatFrames.
// `rate` Hz, `frameCount` frames (total samples = frameCount * nch). Each double is
// narrowed to float by cast — the bank contract is 32-bit float (see FORMAT
// ASSUMPTION above); the reduction is intentional. The output round-trips through
// parseWavLayout/extractFloatFrames. The ingest shell decodes any non-canonical
// source through REAPER's PCM_source, then writes the bank copy with this.
std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate, std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
std::size_t frameCount, std::size_t frameCount,
const std::vector<double>& interleaved); const std::vector<double>& interleaved);
// --- Content identity (dedup hashes) ----------------------------------------- // --- Content identity (dedup hashes) -----------------------------------------
// Computes a deterministic FNV-1a 64-bit content hash over `len` bytes at `data` // Deterministic FNV-1a 64-bit content hash over `len` bytes, as 16-char lowercase
// and returns it as a 16-character lowercase hex string. Designed to fill // hex. Fills Sample::contentHash for the confirm-on-last-reference dedup guardrail.
// Sample::contentHash so the confirm-on-last-reference guardrail
// (BankBook::hashReferencedElsewhere) can distinguish "no other bank holds this
// file" from "another bank holds the same file." An empty buffer returns the bare
// FNV-1a 64-bit offset basis in hex (a stable, non-empty sentinel that two empty
// files would share, but real WAV files are never empty).
std::string hashBytes(const std::uint8_t* data, std::size_t len); 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 // WAV-aware content hash: hashes only the `fmt ` body + `data` payload, skipping
// RIFF/WAVE file — the `fmt ` chunk body + the `data` chunk payload — skipping all // other chunks. WHY: REAPER's offline renderer embeds a render-varying `bext`
// other RIFF chunks (e.g. `bext` origination timestamp, `iXML`, `LIST`/`INFO`, SMED). // timestamp chunk even with no BWF metadata requested, so two renders of
// // identical audio would otherwise hash differently and never dedup. Prefixed
// WHY: REAPER's offline renderer embeds render-varying metadata chunks (at minimum a // with tag byte 'W' so it can't collide with a same-size hashBytes result.
// `bext` chunk containing the origination date/time) even when the format config blob // Falls back to whole-file hashBytes (no prefix) for a file that doesn't parse.
// requests no BWF metadata. Two renders of identical audio therefore differ in those
// bytes, making whole-file hashes diverge and preventing dedup collapse.
//
// DOMAIN SEPARATION: the FNV-1a input is prefixed with the tag byte 'W' (0x57) before
// the fmt/data bytes are fed in, so a content hash can never equal a whole-file
// hashBytes result for a different file of the same size.
//
// FALLBACK: if `bytes` does not parse as a valid RIFF/WAVE with both a `fmt ` and a
// `data` chunk, the function falls back to whole-file hashBytes (no prefix tag) —
// identical to calling hashBytes(bytes.data(), bytes.size()). This ensures that an
// unrecognized or malformed file still gets a non-empty hash rather than silently
// skipping dedup.
//
// Called by both capture commit paths (offline and realtime) and the ingest import
// in place of the raw hashBytes call. Walks the container with the SAME chunk
// traversal parseWavLayout uses, so hashing and decoding can never desynchronize.
std::string hashWavContent(const std::vector<std::uint8_t>& bytes); std::string hashWavContent(const std::vector<std::uint8_t>& bytes);
} // namespace reasampler::capture } // 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 { 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); } 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. // master_gain.h — dB<->linear<->knob-taper math for the post-mixer master gain.
// NO VST3, NO REAPER, NO SWELL/LICE types. The mirror of trigger_seam: one tiny module owns // One shared formula so the drawn needle, the persisted value, and the audio-thread
// the ONE formula both sides of a seam share — here the editor's Gain knob (normalized 0..1) // multiply can't drift. Norm 0 = true zero gain (not an epsilon); persisted value is
// and the processor's stored/applied linear gain — so the drawn needle, the persisted value, // linear gain, the dB taper is a UI-side view of it. Unity (0 dB) sits at ~0.714 norm.
// and the audio-thread multiply can never drift. // RT: the processor applies the linear gain as one multiply over the summed output;
// // these functions themselves run on UI/state threads only.
// 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.
#pragma once #pragma once
@@ -21,38 +11,28 @@
namespace reasampler::instrument::engine { namespace reasampler::instrument::engine {
// The dB taper endpoints. norm 0 is -inf (true zero); norm just above 0 starts at the // norm 0 is -inf (true zero); norm just above 0 starts at the finite floor kMasterGainMinDb
// finite floor kMasterGainMinDb and sweeps linearly in dB to kMasterGainMaxDb at norm 1. // and sweeps linearly in dB to kMasterGainMaxDb at norm 1.
inline constexpr double kMasterGainMinDb = -60.0; inline constexpr double kMasterGainMinDb = -60.0;
inline constexpr double kMasterGainMaxDb = 24.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(); 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); double masterGainDbFromNorm(double norm);
// Inverse taper: dB -> normalized [0,1]. -infinity (or any dB at or below kMasterGainMinDb, // Anything at or below kMasterGainMinDb (including -inf) collapses to norm 0 — the finite
// including below-floor values like -80 dB) maps to norm 0 (the -inf bottom detent) — the // sweep only covers the range above the floor.
// finite sweep only covers the range above kMasterGainMinDb; everything at or below it collapses
// to the same true-zero bottom. +24 -> 1. Pure.
double masterGainNormFromDb(double db); 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); double masterGainLinearFromNorm(double norm);
// Inverse: LINEAR gain -> normalized [0,1]. linear <= 0 -> 0 (the -inf bottom); a linear at // linear <= 0, or at/below the kMasterGainMinDb floor, collapses to norm 0 — values between
// or below the kMasterGainMinDb floor (e.g. 0.001 = -60 dB, or anything below) also maps to 0 // true-zero and the floor aren't representable on the knob. Out-of-range/non-finite clamps.
// — 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.
double masterGainNormFromLinear(double linear); double masterGainNormFromLinear(double linear);
// The knob's hover/drag value label for a normalized value: "-inf" at the bottom, else a // "-inf" at the bottom, else a signed one-decimal dB string ("-12.0dB", "+2.4dB").
// signed one-decimal dB string ("-12.0dB", "+0.0dB", "+2.4dB"). Writes at most `len` bytes // Writes at most `len` bytes including the terminator.
// including the terminator. Pure.
void formatMasterGainLabel(double norm, char* buf, std::size_t len); void formatMasterGainLabel(double norm, char* buf, std::size_t len);
} // namespace reasampler::instrument::engine } // 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 // pitch_shift — pure implementation. See pitch_shift.h for the contract and regression history.
// 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.
// //
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per input // 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 // `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 // 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 // one window — clamped to the filled span so it never lands in unwritten silence — refined
// down-shifts) — CLAMPED to the filled span so it can never land in unwritten silence (the // by a cross-correlation search over +/- maxLag plus a parabolic peak interpolation for a
// GA2 onset fix) — refined by a cross-correlation search over +/- maxLag PLUS a parabolic // sub-sample lag (an integer-only lag left +/-0.5-sample errors: a sideband comb at the
// peak interpolation for a SUB-SAMPLE lag, so the relocated read point is waveform-aligned // splice cadence on a repitched pure sine). Old and new taps then crossfade over fadeFrames
// to a fraction of a sample (integer-lag splices left +/-0.5-sample errors: a -59 dB // with a raised-cosine, amplitude-complementary pair (in-phase content sums to unity gain).
// sideband comb at the splice cadence on a repitched pure sine — the GA2 "alias lines" on // At unity ratio the delay is frozen mid-band and no splice ever fires.
// 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.
#include "core/instrument/engine/pitch_shift.h" #include "core/instrument/engine/pitch_shift.h"
@@ -55,17 +46,15 @@ void PitchShifter::configure(std::int64_t windowFrames) {
ringLen_ = 2 * window_; ringLen_ = 2 * window_;
ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f); ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f);
// Geometry (all quarters of the window): // Geometry (all quarters of the window):
// - fadeFrames_: the NOMINAL splice crossfade. This window/4 length is only safe when // - fadeFrames_: nominal splice crossfade; only safe while the outgoing tap can't reach
// the outgoing tap cannot reach the writer before the fade ends; splice() scales the // the writer before the fade ends. splice() scales fadeLen_ down by ratio for up-shifts
// live fade length (fadeLen_) down by the current ratio for up-shifts past ~2x, so // past ~2x so ordinary transpositions (+24 st) never read stale data mid-fade.
// ordinary sampler transpositions (+24 st = ratio 4) never read stale data mid-fade. // - maxLag_: alignment search half-range — one window/4 covers a full period of any tone
// - maxLag_: the alignment search half-range — one window/4 covers a full period of any // down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
// 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).
// - dLow_/dHigh_: the 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
// - corrFrames_: the correlation segment length. At an up-splice the reference segment // delay ~dLow_, so dLow_-1 is exactly what exists between tap and writer; 512 bounds
// reads FORWARD from the tap at delay ~dLow_, so dLow_-1 frames is exactly what exists // the splice burst.
// between the tap and the writer — the cap expresses that safety rather than leaving
// it coincidental. 512 bounds the splice burst.
fadeFrames_ = std::max<std::int64_t>(window_ / 4, 1); fadeFrames_ = std::max<std::int64_t>(window_ / 4, 1);
maxLag_ = window_ / 4; maxLag_ = window_ / 4;
dLow_ = window_ / 4; dLow_ = window_ / 4;
@@ -77,10 +66,9 @@ void PitchShifter::configure(std::int64_t windowFrames) {
void PitchShifter::reset() { void PitchShifter::reset() {
if (window_ > 1) { if (window_ > 1) {
// Zero the ring and seed the active tap one window behind the writer — the exact // Seed the active tap one window behind the writer — the exact middle of the safe
// middle of the safe band [dLow, dHigh] = [w/4, 2w - w/4], so unity holds it there // band [dLow, dHigh], so unity holds it there forever with maximal drift room either
// forever and either shift direction has maximal drift room. No history is declared // direction. No history declared (filled_ = 0): follow with prime() or warm().
// (filled_ = 0): follow with prime() or warm() before streaming.
std::fill(ring_.begin(), ring_.end(), 0.0f); std::fill(ring_.begin(), ring_.end(), 0.0f);
writePos_ = 0; writePos_ = 0;
posA_ = static_cast<double>(ringLen_ - window_); posA_ = static_cast<double>(ringLen_ - window_);
@@ -104,15 +92,12 @@ void PitchShifter::reset() {
void PitchShifter::freezeTail() { void PitchShifter::freezeTail() {
if (window_ <= 1 || tailFrozen_) return; if (window_ <= 1 || tailFrozen_) return;
tailFrozen_ = true; tailFrozen_ = true;
// An in-flight crossfade was sized for a RETREATING writer (outgoing tap drains at // 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 // ratio-1 per frame); frozen, it closes at the full ratio instead. Cap the live fade so
// fade so it completes before tap B reaches the parked writer and reads lapped (oldest- // it completes before tap B reaches the parked writer and reads lapped content mid-fade.
// 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.
if (fading_) { if (fading_) {
// Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the // Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the
// freeze frame (no gain step). Compute tOld BEFORE overwriting fadeLen_, then // freeze frame (no gain step). Compute tOld before overwriting fadeLen_.
// re-anchor fadePos_ to the same fractional position in the new (shorter) fade.
const double tOld = const double tOld =
static_cast<double>(fadePos_) / static_cast<double>(fadeLen_); static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
double dB = static_cast<double>(writePos_) - posB_; double dB = static_cast<double>(writePos_) - posB_;
@@ -133,8 +118,8 @@ void PitchShifter::freezeTail() {
void PitchShifter::prime(const AudioSample* src, std::int64_t count) { void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
if (window_ <= 1) return; // pass-through needs no priming if (window_ <= 1) return; // pass-through needs no priming
// Clamp to one window: the intended call primes exactly window() frames, and delay == // Clamp to one window: delay == count must stay inside the safe band so the seed itself
// count must stay inside the safe band so the seed does not itself trigger a splice. // never triggers a splice.
if (count < 0) count = 0; if (count < 0) count = 0;
if (count > window_) count = window_; if (count > window_) count = window_;
std::fill(ring_.begin(), ring_.end(), 0.0f); 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) { void PitchShifter::splice(std::int64_t nominalJump, double delay) {
// Relocate the active tap by `nominalJump` frames of ADDED delay (+window_ = jump toward // Relocate the active tap by `nominalJump` frames of added delay (+window_ = toward older
// older content, -window_ = jump toward the writer), refined by a correlation search so // content, -window_ = toward the writer), refined by a correlation search so the relocated
// the relocated read point is waveform-aligned with the outgoing tap's upcoming content. // read point is waveform-aligned with the outgoing tap's upcoming content. Search is coarse
// The search is coarse (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best, // (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best, then a parabolic
// then a parabolic sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_ // sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_ multiply-adds, once
// multiply-adds, once per splice. // per splice.
const std::int64_t d = static_cast<std::int64_t>(delay); 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 // An up-jump may only relocate into valid history. The deepest slot the search (plus the
// search (and the +/-1-lag parabolic refinement calls at bestLag ± 1, and the interpolator's // parabola's +/-1 probe and the interpolator's read-ahead) can touch is d + jump + maxLag + 2,
// read-ahead) can touch is delay d + jump + maxLag + 2 (maxLag from the coarse/fine search, // so the cap is filled_ - d - maxLag_ - 1 (one sample looser than that derived bound, not
// +1 for the parabola's outer ± 1 probe, +1 for the interpolator's i1 = i0+1 read-ahead), // extra margin — ring indexing wraps via modulo everywhere regardless). In steady state
// so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample LOOSER // (filled_ == ringLen_) this exceeds window_ and the nominal jump is untouched; near a primed
// than that derived cap (not extra margin); ring indexing wraps via modulo everywhere, so // onset it shrinks the jump to what real history exists. The floor of 1 only fires on the
// this never runs off the physical ring_ array. In steady state (filled_ == ringLen_) this is // degenerate reset-without-prime path.
// > 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.
std::int64_t jump = nominalJump; std::int64_t jump = nominalJump;
if (jump > 0) { if (jump > 0) {
const std::int64_t maxJump = filled_ - d - maxLag_ - 1; 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 (++ia >= ringLen_) ia = 0;
if (++ic >= ringLen_) ic = 0; if (++ic >= ringLen_) ic = 0;
} }
// NORMALIZED cross-correlation (standard SOLA): a raw dot product is biased toward // Normalized cross-correlation: a raw dot product biases toward the higher-energy lag,
// the higher-energy lag, so on a decaying tail every up-splice would prefer the // so on a decaying tail every up-splice would prefer the loudest candidate over the
// loudest candidate over the best-ALIGNED one — a small level step per splice that // best-aligned one. The reference segment's energy is constant across lags, so dividing
// the amplitude-complementary fade cannot hide. The reference segment's energy is // by sqrt(Ec) alone ranks identically to the full normalized form.
// 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).
return ec > 0.0 ? s / std::sqrt(ec) : 0.0; 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 // Sub-sample peak: the integer-lag best leaves a residual misalignment of up to half a
// up to half a sample; at the splice cadence that residual phase-modulates a pure tone // sample, which at the splice cadence phase-modulates a pure tone into an audible sideband
// into a ~-59 dB sideband comb (the DAW spectrogram "alias lines"). A parabola through // comb. A parabola through the scores at bestLag-1/bestLag/bestLag+1 locates the peak to a
// the scores at bestLag-1/bestLag/bestLag+1 locates the correlation peak to a fraction of // fraction of a sample; readTap()'s linear interpolation realizes it. The denominator is
// a sample; readTap()'s linear interpolation realizes the fractional tap position. The // negative at a genuine peak — flat correlation (DC/silence) keeps the integer lag, benign.
// denominator is negative at a genuine peak — anything else (flat correlation: DC or
// silence) keeps the integer lag, which is already benign there.
double frac = 0.0; double frac = 0.0;
{ {
const double sM = scoreAt(bestLag - 1); 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 < 0.0) p += len;
while (p >= len) p -= len; while (p >= len) p -= len;
posA_ = p; posA_ = p;
// RATIO-SCALED fade length. At an up-splice the OUTGOING tap starts at ~dLow_ delay and // Ratio-scaled fade length. At an up-splice the outgoing tap keeps draining toward the
// keeps draining toward the writer at (ratio - 1) per output frame; the nominal window/4 // writer at (ratio - 1) per frame; the nominal window/4 fade only keeps it behind the
// fade only keeps it behind the writer for ratios up to 2. Beyond that (e.g. +24 st = // writer for ratios up to 2 — beyond that (e.g. +24 st = ratio 4) it would cross mid-fade
// ratio 4, an ordinary sampler transposition) it would cross mid-fade and play stale // and play stale read-ahead data. Cap the live fade at the drain headroom actually
// read-ahead data at substantial gain — a periodic seam. So cap the live fade at the // available, minus 2 (trigger undershoot + interpolator read-ahead margin). Down-shifts
// frames of drain headroom actually available, minus 2 (1 for the trigger's sub-dLow_ // drain at (1 - ratio) < 1 per frame and can't reach the ring end within window/4 frames,
// undershoot, 1 for the interpolator's read-ahead). Ratios <= ~2 keep the full nominal // so they always keep the full fade.
// 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.
// //
// TAIL-FROZEN (GA3): with the writer parked, the outgoing tap closes on it at the FULL // Tail-frozen: with the writer parked, the outgoing tap closes on it at the full ratio in
// ratio (there is no retreating write head), in EITHER shift direction — so the drain // either shift direction, so the drain rate is ratio_ instead of (ratio_ - 1) and the cap
// rate is ratio_ instead of (ratio_ - 1), and the cap applies at every ratio (unity // applies at every ratio (including unity, since delay now drains at unity too).
// included: splices fire in the frozen tail because the delay now drains at unity too).
fadeLen_ = fadeFrames_; fadeLen_ = fadeFrames_;
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0); const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0);
if (drainRate > 0.0) { if (drainRate > 0.0) {
@@ -316,16 +289,15 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
} }
fading_ = true; fading_ = true;
fadePos_ = 0; fadePos_ = 0;
// Record the decision for a linked follower channel (T1-01): the follower applies this // Record the decision for a linked follower channel — applied verbatim there so both
// verbatim so both channels share one lag and one splice schedule. // channels share one lag and one splice schedule.
lastSplice_ = SpliceEvent{true, jump, bestLag, frac, fadeLen_}; lastSplice_ = SpliceEvent{true, jump, bestLag, frac, fadeLen_};
} }
void PitchShifter::applySplice(const SpliceEvent& ev) { void PitchShifter::applySplice(const SpliceEvent& ev) {
// Follower half of the T1-01 linked lag: relocate + fade with the master's decision, no // Follower half of the 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, // correlation search of our own — the master's jump/fade derive from shared geometry +
// which match ours by the lockstep contract (identical configure/prime/ratio history); // ratio, which match ours by the lockstep contract (identical configure/prime/ratio history).
// the fade length likewise derives only from shared geometry + ratio.
posB_ = posA_; posB_ = posA_;
double p = posA_ - static_cast<double>(ev.jump) + static_cast<double>(ev.lag) + ev.frac; double p = posA_ - static_cast<double>(ev.jump) + static_cast<double>(ev.lag) + ev.frac;
const double len = static_cast<double>(ringLen_); 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) { AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) {
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate) if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
// Copy the linked decision BEFORE clearing lastSplice_ (guards a self-aliased pointer; // Copy the linked decision before clearing lastSplice_ (guards a self-aliased pointer).
// 5 plain fields, negligible on the RT path).
const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{}; const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{};
lastSplice_ = SpliceEvent{}; // cleared every frame; set again if this frame splices 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 // Tail-frozen: the source is exhausted, `in` is padding, not stream — write nothing (the
// ring now holds valid history (capped at the ring length once it has wrapped). // ring keeps its all-real final two windows) and hold the write head; read/splice/fade
// TAIL-FROZEN (GA3): the source is exhausted — `in` is padding, not stream. Write // below run unchanged over the frozen content.
// 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.
if (!tailFrozen_) { if (!tailFrozen_) {
ring_[static_cast<std::size_t>(writePos_)] = in; ring_[static_cast<std::size_t>(writePos_)] = in;
if (filled_ < ringLen_) ++filled_; if (filled_ < ringLen_) ++filled_;
} }
// 2. Read the active tap; while a splice fade is live, crossfade against the outgoing tap. // 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 // Raised-cosine complementary gains (gNew + gOld == 1): correlation-aligned content is in
// in phase, so the sum holds unity amplitude through the fade (equal-power would bulge). // phase, so the sum holds unity amplitude through the fade (equal-power would bulge).
double out = readTap(posA_); double out = readTap(posA_);
if (fading_) { if (fading_) {
const double t = static_cast<double>(fadePos_) / static_cast<double>(fadeLen_); 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_); out = gNew * out + (1.0 - gNew) * readTap(posB_);
if (++fadePos_ >= fadeLen_) fading_ = false; if (++fadePos_ >= fadeLen_) fading_ = false;
} else if (linked != nullptr) { } else if (linked != nullptr) {
// 3a. FOLLOWER (T1-01): no trigger test, no search — splice exactly when and how the // Follower: no trigger test, no search — splice exactly when and how the master did
// master channel did this frame. Lockstep state means our own trigger would have // this frame (lockstep means our own trigger would have fired the same frame anyway).
// fired on the same frame; applying the master's decision keeps the two rings
// sample-aligned (one shared lag, one shared schedule).
if (linkedEv.fired) { if (linkedEv.fired) {
applySplice(linkedEv); applySplice(linkedEv);
} else { } else {
// Self-healing fallback (review rider): the master not firing normally means this // Self-healing fallback: if the processor ever renders a mono block mid-note, this
// channel's own trigger wouldn't fire either (lockstep). But if the processor ever // follower is skipped for that block while the master keeps advancing, and could
// renders a mono block mid-note, this follower channel is skipped for that block // never resync via the `linkedEv.fired` path alone. So also check this follower's
// while the master keeps advancing — its writePos_/filled_ falls behind and, with // own tap distance against the safe band and splice via its own search when it has
// only the `if (linkedEv.fired)` path above, could never resync. So check this // left [dLow_, dHigh_] — never triggers in the normal (non-mono-block) case, since
// follower's OWN tap distance against the safe band and splice via its own search // the master's trigger always fires first.
// when it has left [dLow_, dHigh_], exactly as the master would. Reuses splice() —
// no allocation, no new RT cost. In the normal (non-mono-block) case this branch
// never triggers: the master's trigger fires first and this whole `if` is false.
double d = static_cast<double>(writePos_) - posA_; double d = static_cast<double>(writePos_) - posA_;
const double len = static_cast<double>(ringLen_); const double len = static_cast<double>(ringLen_);
while (d < 0.0) d += len; while (d < 0.0) d += len;
@@ -399,10 +363,10 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
} }
} }
} else { } else {
// 3. Splice scheduling: relocate when the active tap's delay leaves the safe band. // 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- // Up-shifts drain the delay toward 0 -> jump one window older; down-shifts grow it
// shifts grow it toward the ring length -> jump one window TOWARD the writer. At // toward the ring length -> jump one window toward the writer. At unity the delay is
// unity the delay is frozen at window/2 and neither trigger ever fires. // frozen at window/2 and neither trigger ever fires.
double d = static_cast<double>(writePos_) - posA_; double d = static_cast<double>(writePos_) - posA_;
const double len = static_cast<double>(ringLen_); const double len = static_cast<double>(ringLen_);
while (d < 0.0) d += len; 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), // Advance heads: write head one frame (parked while tail-frozen), tap(s) by the shift ratio.
// tap(s) by the shift ratio.
if (!tailFrozen_) { if (!tailFrozen_) {
++writePos_; ++writePos_;
if (writePos_ >= ringLen_) writePos_ = 0; if (writePos_ >= ringLen_) writePos_ = 0;
+86 -144
View File
@@ -1,66 +1,34 @@
#pragma once #pragma once
// pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve" // pitch_shift — per-voice, duration-preserving pitch shifter (the Preserve engine's DSP core).
// engine's DSP core. Time-domain delay-line shifter with CORRELATION-ALIGNED SPLICES // Time-domain delay-line with correlation-aligned splices (SOLA-style): one active read tap
// (SOLA-style): one active read tap chases the write head at the shift ratio; when it drifts // chases the write head at the shift ratio; when it drifts out of its safe delay band it is
// out of its safe delay band it is relocated by a nominal window jump REFINED BY A // relocated by a nominal window jump, refined by a cross-correlation search so the new read
// CROSS-CORRELATION SEARCH so the new read point is waveform-aligned, then the old and new // point is waveform-aligned, then old/new taps crossfade (raised-cosine). Source and output are
// taps are crossfaded (raised-cosine, amplitude-complementary). Source is consumed 1:1 and // both consumed/produced 1:1 — only pitch changes, duration is held (unlike the Varispeed
// output produced 1:1 (duration held); only the PITCH changes — an octave up plays the same // `readPos_ += ratio_` resample path).
// wall-clock length as the root note, unlike the Varispeed `readPos_ += ratio_` resample path.
// //
// WHY CORRELATED SPLICES (GA-Preserve fix, 2026-07). The first S16 implementation was the // Regression history — do not revert any of these:
// naive two-tap OLA: taps hard-locked half a window apart, Hann-crossfaded by write-head // - Correlated splices, vs. the original two-tap OLA (taps hard-locked w/2 apart, Hann
// distance. Its taps read the same stream at delays differing by exactly w/2, so their outputs // crossfaded by write-head distance): that fixed offset gave the two taps a fixed relative
// carried a FIXED relative phase of 2*pi*f_src*(w/2) — arbitrary and source-frequency- // phase, so near-anti-phase source frequencies (roughly half of them) nearly cancelled at
// dependent. Near anti-phase (roughly half of all frequencies) every crossfade midpoint // every crossfade midpoint — a repitched pure sine came out mangled while unity stayed clean.
// nearly CANCELLED: deep periodic AM + phase slew = strong sidebands. A repitched pure sine // Splices must be phase-aligned (snapped to the best waveform match), not just distance-fired.
// came out mangled ("multiple partials" on a spectrogram) while the root stayed clean (unity // - Hand-rolled, not WDL_SimplePitchShifter: its include chain pulls <windows.h> unconditionally,
// freezes the crossfade). The fix is structural: splices must be PHASE-ALIGNED, so each jump // which cannot enter this REAPER/VST3-free core (sampler_core_tests links neither SDK). Swap
// is snapped to the best waveform match within a bounded lag search — a pure sine's jump // to WDL, if ever wanted, happens at the shell, never in this pure core.
// lands on an integer period count and the output stays a single shifted tone. // - 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 // RT discipline: configure() sizes the ring once, off the audio thread. prime()/warm() only
// route (a) `WDL_SimplePitchShifter`. But its include chain // copy into the pre-sized ring (bounded, allocation-free). process() does no allocation and no
// (simple_pitchshift.h -> queue.h -> heapbuf.h -> wdltypes.h) does `#ifdef _WIN32 -> // locks; the correlation search is a bounded burst that fires once per splice cadence
// #include <windows.h>` unconditionally, which CANNOT enter the pure sampler_core module // (window / |ratio-1| frames), never per frame.
// (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.
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
@@ -72,100 +40,76 @@ namespace reasampler::instrument::engine {
using audio::AudioSample; using audio::AudioSample;
// The splice decision made by the most recent process()/processLinked() call — the LINKED-LAG // 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 // stereo contract. A stereo voice runs channel 0 as the master (full correlation search) and
// search) and channel 1 as the FOLLOWER: after the master's process() for a frame, the caller // channel 1 as the follower: after the master's process() for a frame, the caller passes
// passes master.lastSplice() to the follower's processLinked() for the SAME frame, and the // master.lastSplice() to the follower's processLinked() for the same frame, and the follower
// follower applies exactly this decision instead of running its own search. Both channels // applies exactly this decision instead of running its own search. Both channels therefore
// therefore share one lag and one splice schedule (standard stereo SOLA) — per-channel // share one lag and one splice schedule — independent per-channel searches drew an inter-channel
// independent searches re-drew an inter-channel offset of up to +/-maxLag at every splice: // offset of up to +/-maxLag at every splice, causing stereo image wander and comb coloration on
// stereo image wander at the splice cadence plus comb coloration on any mono sum. // a mono sum.
struct SpliceEvent { struct SpliceEvent {
bool fired = false; // a splice was scheduled on this frame 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 std::int64_t lag = 0; // correlation best integer lag
double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5] double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5]
std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen 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 per-channel time-domain splice-aligned pitch shifter. A stereo voice owns two, linked:
// a stereo voice owns two, LINKED: channel 0 is the master, channel 1 follows its splice // channel 0 is the master, channel 1 follows its splice decisions via processLinked() so the
// decisions via processLinked() (see SpliceEvent above) so the two rings stay sample-aligned. // two rings stay sample-aligned.
// //
// The default-constructed shifter is INERT: with no configure() it passes input through // Default-constructed is inert: with no configure() it passes input through unchanged (ratio
// unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is // 1.0, empty ring), so a Varispeed voice that never touches it sees no behavior change.
// byte-identical to the pre-S16 engine.
class PitchShifter { class PitchShifter {
public: public:
// Size the delay ring for `windowFrames` (the nominal splice-jump length; the ring is 2x // Sizes the delay ring for `windowFrames` (the ring is 2x that for splice/search headroom).
// that for splice/search headroom) and derive the fade/search geometry. `windowFrames` // <= 1 degrades to pass-through. Off the audio thread (allocates); resets all running state.
// <= 1 degrades to pass-through (no ring), so a degenerate configure never divides by // A larger window means fewer splices and a deeper alignment search; a primed shifter has no
// zero or wraps a zero span. Called OFF the audio thread (allocates). Resets all running // added latency regardless of window size (see prime()).
// 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.
void configure(std::int64_t windowFrames); void configure(std::int64_t windowFrames);
// Pre-fill the ring with the first `count` frames of the UPCOMING source stream and park // Pre-fills the ring with the first `count` frames of the upcoming source stream and parks
// the tap on src[0] (delay == count, mid safe band at count == window()). The caller then // the tap on src[0]; the caller then feeds process() the stream continuing at src[count].
// feeds process() the stream CONTINUING at src[count]. Output frame 0 is src[0]: ZERO // `count` is clamped to [0, window()]. If the playable source is shorter than one window,
// structural latency at every ratio, and splices always have `count` frames of real // prime only the real span and call freezeTail() immediately after — never pad with silence
// history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()]. // and declare it valid; padded zeros are splice targets and reintroduce the onset gap.
// When the PLAYABLE source is shorter than one window, prime only the real span and call // RT-safe: bounded copy, no allocation. No-op when unconfigured; ratio is left untouched.
// freezeTail() immediately after (Q-W0 T1-03): the GA3 machinery then recycles the real
// short tail. Do NOT pad with silence and declare it valid — padded zeros inside the ring
// are splice targets, re-creating the pre-GA2 burst/gap onset on sub-window material.
// RT-safe: bounded copy into the pre-sized ring, no allocation. No-op when unconfigured.
// The current shift ratio is left untouched.
void prime(const AudioSample* src, std::int64_t count); void prime(const AudioSample* src, std::int64_t count);
// prime()-with-silence: zero the ring, park the tap one window behind the writer, and // Silence-prime: zero the ring, park the tap one window behind the writer, declare that
// declare that window of silence as valid history. Kept for callers with no access to the // window silence as valid history. Kept for callers with no access to the upcoming stream
// upcoming stream (a silence-primed up-shift plays ~a window of silence before speaking // (a silence-primed up-shift plays ~a window of silence before speaking; the Voice path uses
// the pre-GA2 onset; the Voice path uses prime() instead). At unity a warmed shifter is a // prime() instead). At unity a warmed shifter is a bit-exact window() delay.
// bit-exact window() delay. No-op when unconfigured.
void warm(); void warm();
// The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias. // 2^((note - root)/12) plus any per-frame pitch-envelope bias; 1.0 = no shift, no splices
// 1.0 = no shift (pass-through-equivalent output, no splices ever fire). Set per frame is // ever fire. Cheap enough to set per frame. Values <= 0 are ignored (kept at the last valid
// fine (cheap); the tap advance simply uses the current value. Values <= 0 are ignored // ratio) so a bad input never runs the tap backward or stalls it.
// (kept at the last valid ratio) so a bad input never runs the tap backward or stalls it.
void setShiftRatio(double ratio); void setShiftRatio(double ratio);
// Transform ONE input frame into ONE output frame (duration-preserving: 1 in, 1 out). // Transforms one input frame into one output frame (1 in, 1 out). RT-safe: reads/writes the
// RT-safe: reads/writes the pre-sized ring only, no allocation, no lock. When unconfigured // pre-sized ring only, no allocation, no lock. Unconfigured returns `in` unchanged. Otherwise
// (window <= 1) returns `in` unchanged (pass-through). Otherwise writes `in` at the write // writes `in` at the write head, reads the active tap (crossfading against the outgoing tap
// head, reads the active tap (crossfading against the outgoing tap while a splice fade is // during a splice fade), then advances the write head and tap(s). When the active tap leaves
// live), then advances the write head by one and the tap(s) by the shift ratio. When the // its safe delay band, a correlation-aligned splice is scheduled.
// active tap leaves its safe delay band, a correlation-aligned splice is scheduled.
AudioSample process(AudioSample in); AudioSample process(AudioSample in);
// FOLLOWER-mode process (Q-W0 T1-01, the stereo linked lag): identical to process() // Follower-mode process: identical to process() except the splice decision isn't computed
// except the splice decision is NOT computed here — when `master.fired` is true this // here — when `master.fired` is true this frame splices with exactly the master's
// frame splices with exactly the master's jump/lag/frac/fadeLen; otherwise no splice is // jump/lag/frac/fadeLen. The caller must process the master channel first each frame and
// considered. The caller must process the master channel FIRST each frame and pass its // pass its lastSplice() here; both shifters must be configured/primed/ratio'd identically so
// lastSplice() here, with both shifters configured/primed/ratio'd identically — their // their ring state advances in lockstep. RT-safe: same guarantees as process().
// ring state then advances in lockstep, so the follower's own trigger would have fired
// on the same frame anyway; skipping its search only removes the second correlation
// burst (strictly cheaper, never costlier). RT-safe: same guarantees as process().
AudioSample processLinked(AudioSample in, const SpliceEvent& master); 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_; } const SpliceEvent& lastSplice() const { return lastSplice_; }
// TAIL WIND-DOWN (GA3, 2026-07). Call when the SOURCE STREAM IS EXHAUSTED — no real frame // Call once the source stream is exhausted — no real frame remains to feed process().
// remains to feed process(). Freezes the WRITE head: subsequent process() calls ignore // Freezes the write head: subsequent process() calls ignore input and write nothing, but
// their input and write nothing, but read, splice, and crossfade exactly as before over // read/splice/crossfade as before over the ring's frozen (all-real) final two windows, so
// the ring's frozen (all-real) final two windows. WHY: the pre-GA3 tail held the last // every late splice stays waveform-aligned against real content instead of a DC plateau.
// real sample as the feed — a DC plateau with no waveform to correlate on. Splices // Idempotent; RT-safe (flag + bounded arithmetic); cleared by reset()/prime()/warm().
// 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().
void freezeTail(); void freezeTail();
bool tailFrozen() const { return tailFrozen_; } 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 // the writer (the caller just computed it for the trigger test). Records the decision in
// lastSplice_ for a linked follower channel. // lastSplice_ for a linked follower channel.
void splice(std::int64_t nominalJump, double delay); void splice(std::int64_t nominalJump, double delay);
// Apply a master channel's already-computed splice decision verbatim (no search) — // Applies 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_. // the follower half of the linked-lag contract. Mirrors it into lastSplice_.
void applySplice(const SpliceEvent& ev); void applySplice(const SpliceEvent& ev);
// Shared body of process()/processLinked(); `linked` null = master mode (own trigger + // Shared body of process()/processLinked(); `linked` null = master mode (own trigger +
// search), non-null = follower mode (splice iff linked->fired, with linked's decision). // 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 maxLag_ = 0; // correlation search half-range (window_/4)
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so
// the reference read forward from the tap stays behind // 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 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 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 std::int64_t filled_ = 0; // frames of valid history behind the writer; splice()
// + frames streamed, capped at ringLen_). splice() clamps // clamps its up-jump to this so it never lands in
// its up-jump to this so no splice lands in unwritten // unwritten silence
// silence — the GA2 onset-gap fix.
double ratio_ = 1.0; // current shift ratio (>0) double ratio_ = 1.0; // current shift ratio (>0)
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame (T1-01): SpliceEvent lastSplice_{}; // decision of the most recent process*() frame; cleared
// cleared at the top of every frame, set on a splice // at the top of every frame, set on a splice
bool tailFrozen_ = false; // GA3 wind-down: writer frozen (source exhausted); the tap bool tailFrozen_ = false; // writer frozen (source exhausted); tap recycles the
// recycles the ring's frozen real tail, splices still // frozen real tail, drains toward the writer at ratio_
// aligned. With the writer parked, a tap drains toward it // (not ratio_-1) per frame — splice() scales the fade
// at ratio_ (not ratio_-1) per frame — splice() scales the // by that rate
// live fade by that rate.
}; };
} // namespace reasampler::instrument::engine } // namespace reasampler::instrument::engine
+104 -152
View File
@@ -1,16 +1,11 @@
// sampler_core — pure sampler engine implementation. See sampler_core.h for the // sampler_core — pure sampler engine implementation. See sampler_core.h for the contract.
// contract and the design rationale (keymap resolution, pitch ratio, ADSR shape,
// voice allocation + stealing policy). NO VST3 / REAPER / SWELL / vendor includes.
// //
// DOCUMENTED HOT-PATH EXCEPTION to the Phase Q ~600-line file ceiling (Q-W2v, // Documented hot-path exception to the ~600-line file ceiling: this TU deliberately stays
// T4-14/T4-27 — Daniel-settled 2026-07-28): this TU deliberately STAYS WHOLE. // whole. AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called
// AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called // per-voice-per-sample from Voice::advanceFrame, called per-sample from VoiceEngine::render
// per-voice-per-sample from Voice::advanceFrame, which is called per-sample from // — same-TU definition is what lets the compiler inline that stack (no LTO configured). A
// VoiceEngine::render — same-TU definition is what lets the compiler inline that // by-class TU split would put the hottest inner loop across TU boundaries. Do not split
// stack (the build configures NO LTO). A by-class TU split would put the hottest // this file further; the header is split instead (zone_params.h carries the value structs).
// inner loop across TU boundaries — the exact heuristic-(3) dispatch blowout the
// phase forbids. Do NOT "fix" this file's length; the header is split instead
// (zone_params.h carries the shared value structs).
#include "core/instrument/engine/sampler_core.h" #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) { double keyTrackedRatio(int note, int rootNote, double keyTrack) {
// Scale the semitone offset by keyTrack before the ET conversion. keyTrack == 1.0 yields // keyTrack == 1.0 yields (note-root)*1.0, exact in IEEE-754 for an integer-valued double,
// (note-root)*1.0, which is EXACT in IEEE-754 for an integer-valued double, so the argument // so the argument to std::pow is bit-identical to pitchRatio(note, rootNote).
// 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.
const double semis = static_cast<double>(note - rootNote) * keyTrack; const double semis = static_cast<double>(note - rootNote) * keyTrack;
return std::pow(2.0, semis / 12.0); return std::pow(2.0, semis / 12.0);
} }
@@ -105,8 +97,7 @@ double AdsrEnvelope::tick() {
const double out = level_; const double out = level_;
++framesInStage_; ++framesInStage_;
if (framesInStage_ >= params_.attackFrames) { if (framesInStage_ >= params_.attackFrames) {
// S15: Attack -> Hold (holds 1.0 for holdFrames). holdFrames == 0 falls straight // holdFrames == 0 falls straight through Hold on the next tick to Decay.
// through Hold on the next tick to Decay, which is EXACTLY the pre-S15 A->D path.
stage_ = Stage::Hold; stage_ = Stage::Hold;
framesInStage_ = 0; framesInStage_ = 0;
level_ = 1.0; level_ = 1.0;
@@ -115,16 +106,13 @@ double AdsrEnvelope::tick() {
} }
case Stage::Hold: { case Stage::Hold: {
// S15 hold stage: level pinned at 1.0 for holdFrames. holdFrames <= 0 leaves the // holdFrames <= 0 leaves the stage on this same tick (no frame consumed at 1.0
// stage on this same tick (no frame consumed at 1.0 beyond what Attack already // beyond what Attack already emitted) so a zero-length hold emits no extra sample.
// emitted), so hold=0 is byte-identical to the pre-S15 envelope.
if (params_.holdFrames <= 0) { if (params_.holdFrames <= 0) {
stage_ = Stage::Decay; stage_ = Stage::Decay;
framesInStage_ = 0; 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; 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(); return tick();
} }
level_ = 1.0; 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, 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() { double PitchEnvelope::tick() {
@@ -263,10 +251,10 @@ double PitchEnvelope::tick() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void Voice::presizePreserveShifters(std::int64_t windowFrames) { void Voice::presizePreserveShifters(std::int64_t windowFrames) {
// OFF the audio thread (allocates). Both channels are sized so a stereo Preserve voice needs // Off the audio thread (allocates). Both channels are sized so a stereo Preserve voice
// no allocation at note-on; a mono Preserve voice simply never process()es shiftR_. The // needs no allocation at note-on; a mono voice simply never process()es shiftR_. The
// prime scratch (one window, reused per channel) is sized here for the same reason: start() // prime scratch is sized here for the same reason: start() assembles the first window
// assembles the first window of the upcoming source stream into it with zero allocation. // of the upcoming source into it with zero allocation.
shiftL_.configure(windowFrames); shiftL_.configure(windowFrames);
shiftR_.configure(windowFrames); shiftR_.configure(windowFrames);
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f); 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, void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
double keyTrack, const VelocityCurve& velocityCurve, double keyTrack, const VelocityCurve& velocityCurve,
bool declickTakeover) { bool declickTakeover) {
// Takeover declick (Phase S GA fix, rev 2): BEFORE any state reset, record the PRE-CUT // Before any state reset, record the pre-cut reference (last rendered output) and mark
// REFERENCE — the last rendered output — and mark the compensation PENDING iff this // the compensation pending iff this start is a takeover/steal of a sounding voice and the
// start is a takeover/steal of a SOUNDING voice and the caller opted in. The ramp itself // caller opted in. The ramp is seeded on the first frame rendered after the restart, from
// is seeded on the FIRST frame rendered after the restart, from the DIFFERENCE between // the difference between this reference and the new voice's raw output that frame
// this reference and the new voice's raw output that frame (seedDeclick), so the // (seedDeclick), so the boundary frame reproduces the old level exactly regardless of the
// boundary frame reproduces the old level EXACTLY — whatever the new envelope does // new envelope's first value. (An earlier revision gated the add by (1 - newAmp): any
// (Gate attack, zero attack, Trigger's no-fade-in instant-unity onset) and whatever // restart whose new amplitude was instantly ~1 got zero compensation and kept the full
// value the new sample starts on. [Rev 1 seeded the OLD value here and gated the add by // click.) A fresh start (idle voice) clears the declick state. lastOut{L,R}_ are
// (1 newAmp) in the epilogue: every restart whose new amplitude was instantly ~1 got // deliberately not zeroed here: a second same-block takeover (two steals with no frame
// ZERO compensation and kept the full click — exactly the DAW-reported mono-retrig case // rendered between) must record the same pre-cut reference, not a phantom 0.
// 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.
if (declickTakeover && active_) { if (declickTakeover && active_) {
// Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing. // 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_; 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; releasing_ = false;
amplitudeDone_ = false; amplitudeDone_ = false;
note_ = note; note_ = note;
// S-VIEW-9: the velocity->amp transfer curve maps MIDI velocity to gain, ONCE at note-on (the // Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
// per-frame render just multiplies the cached velocityGain_ — no new process-thread work). The // velocityGain_.
// 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.
velocityGain_ = velocityCurve.eval(static_cast<double>(velocity)); velocityGain_ = velocityCurve.eval(static_cast<double>(velocity));
// S-VIEW-6: the key-tracked repitch ratio feeds BOTH engines through baseRatio_ (Varispeed // Feeds both engines through baseRatio_ (Varispeed read-rate bias and Preserve shift
// read-rate bias and Preserve shift amount both derive from it below). keyTrack == 1.0 is // amount both derive from it below).
// the pre-S-VIEW-6 pitchRatio bit-for-bit.
baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack); baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
sample_ = &sample; sample_ = &sample;
@@ -328,25 +309,17 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
playMode_ = p.playMode; playMode_ = p.playMode;
pitchEngine_ = p.pitchEngine; pitchEngine_ = p.pitchEngine;
// Initial read position honors the sample's start-point offset (S11), in BOTH modes. Clamp // Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top)
// into [0, frames): a start at or past the end degrades to 0 (play from the top) rather than // rather than starting a voice already off the end.
// starting a voice already off the end. A negative start (shouldn't occur) is pinned to 0.
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size()); const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
std::int64_t start = sample.startFrame; std::int64_t start = sample.startFrame;
if (start < 0 || start >= frameCount) start = 0; if (start < 0 || start >= frameCount) start = 0;
readPos_ = static_cast<double>(start); readPos_ = static_cast<double>(start);
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset) 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 // Amplitude envelope: Gate = AHDSR (all five fields read from the zone's play.adsr,
// play.adsr); Trigger = the time-boxed fade-in/out over the % play length. // resolved to frames from stored seconds at reload time); 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. ---
if (playMode_ == PlayMode::Gate) { if (playMode_ == PlayMode::Gate) {
env_.configure(p.adsr); env_.configure(p.adsr);
env_.noteOn(); env_.noteOn();
@@ -366,38 +339,34 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
kDefaultFadeCurve); kDefaultFadeCurve);
} }
// --- Pitch envelope (S16): per-voice AD, off by default (offset always 0). ---
pitchEnv_.configure(p.pitchEnv); pitchEnv_.configure(p.pitchEnv);
pitchEnv_.noteOn(); pitchEnv_.noteOn();
// --- Preserve engine (S16, GA2 onset fix): PRIME the ALREADY-SIZED per-channel shifters // Prime the already-sized per-channel shifters with the first window of the actual
// with the first window of the ACTUAL upcoming source stream (loop-unrolled under the // upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
// sustain-loop wrap rule, silence past the sample end — that silence IS the true // the sample end, since that silence is the true stream there). The tap parks on source
// stream there). The tap parks on source frame `start`, so the voice speaks on output // frame `start`, so the voice speaks on output frame 0 at every ratio, and every splice
// frame 0 at EVERY ratio (no ring-fill silence), and every splice has a full window // has a full window of real history to land in — a silence-warmed ring instead makes
// of real history to land in — the fix for the DAW onset zero-gaps (a silence-warmed // every early splice jump into zeros (burst/gap onset). The rings and prime scratch were
// ring made every early splice jump into zeros). The rings and the prime scratch were // allocated off-thread by presizePreserveShifters; this path is a bounded copy, no
// allocated off-thread by presizePreserveShifters (the engine calls it at // allocation. Varispeed voices never touch the shifters, so a Varispeed instrument pays
// construction); this path is a bounded copy — NO allocation here. Varispeed voices // no per-frame shifter cost.
// never touch the shifters (advanceFrame checks configured()), so a Varispeed
// instrument is byte-identical to pre-S16 and pays no per-frame shifter cost. ---
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) { if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
const std::int64_t w = shiftL_.window(); const std::int64_t w = shiftL_.window();
const bool loopWrap = sustainLoopUsable(); const bool loopWrap = sustainLoopUsable();
const SampleLoop& loop = sample.loop; const SampleLoop& loop = sample.loop;
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0; const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured(); 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 // The prime may only carry playable source. The per-frame feed stops at feedBound
// feedBound (playEnd_ for a bounded Trigger span, the sample end for Gate) and // (playEnd_ for a bounded Trigger span, the sample end for Gate) and freezes the
// freezes the writer there (GA3) — but the prime used to pull a FULL window bounded // writer there — but a full window bounded only by frameCount would let a Trigger
// only by frameCount: a Trigger ring held real PCM past the user's chosen stop (an // ring hold real PCM past the user's chosen stop (an up-shifted tap could play it,
// up-shifted tap could play it, transposed, before the voice freed), and a // transposed, before the voice freed), and a shorter-than-window sample would get
// shorter-than-window sample got zero padding declared as valid history (splices // zero padding declared as valid history (splices landing in silence). So bound the
// landing in silence — the pre-GA2 burst/gap onset, re-entered for sub-window // prime by the same playable span and, when that span is shorter than a window,
// material). So bound the prime by the same playable span and, when that span is // freeze the tail immediately after the prime that machinery then recycles the
// shorter than a window, freeze the tail IMMEDIATELY after the prime — the GA3 // real short tail. The sustain-loop path is unbounded by construction (the wrap
// machinery then recycles the real short tail, its designed behavior. The sustain- // keeps q inside the loop forever).
// loop path is unbounded by construction (the wrap keeps q inside the loop forever).
const std::int64_t primeBound = const std::int64_t primeBound =
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount) (playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
? 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); (ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
if (ch == 0) p = q; // capture the end position once from channel 0's walk 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 // 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). // playable span).
feedPos_ = p; feedPos_ = p;
if (!loopWrap && primeCount < w) { 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(); shiftL_.freezeTail();
if (stereoSample) shiftR_.freezeTail(); if (stereoSample) shiftR_.freezeTail();
} }
@@ -447,28 +416,26 @@ void Voice::retune(int note, int rootNote, double keyTrack) {
void Voice::release() { void Voice::release() {
if (!active_) return; if (!active_) return;
// TRIGGER ignores note-off entirely (S15): the one-shot plays through to its play length. if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
if (playMode_ == PlayMode::Trigger) return;
releasing_ = true; releasing_ = true;
env_.noteOff(); env_.noteOff();
} }
void Voice::hardStop() { void Voice::hardStop() {
// CC 120 (All Sounds Off): immediate silence regardless of play mode. Stops Trigger one-shots // Immediate silence regardless of play mode: stops Trigger one-shots that ignore
// that ignore release(), and short-circuits Gate release tails. RT-safe: no allocation. // release(), and short-circuits Gate release tails. RT-safe: no allocation.
active_ = false; active_ = false;
} }
double Voice::tickAmplitude() { double Voice::tickAmplitude() {
double amp; double amp;
if (playMode_ == PlayMode::Gate) { if (playMode_ == PlayMode::Gate) {
// AHDSR is wall-clock (one tick per output frame), independent of the read rate.
amp = env_.tick(); amp = env_.tick();
if (env_.finished()) amplitudeDone_ = true; if (env_.finished()) amplitudeDone_ = true;
} else { } else {
// Trigger fade shape anchored to the SOURCE offset (readPos - startFrame), so the fades // Anchored to the source offset so fades land on the same source frames under either
// land on the same source frames under either engine's read rate. The voice ALSO frees on // engine's read rate. The voice also frees on readPos_ >= playEnd_ in advanceFrame;
// readPos_ >= playEnd_ in advanceFrame; finished() here is the belt to that suspenders. // finished() here is the belt to that suspenders.
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_)); amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
if (trigEnv_.finished()) amplitudeDone_ = true; if (trigEnv_.finished()) amplitudeDone_ = true;
} }
@@ -476,34 +443,26 @@ double Voice::tickAmplitude() {
} }
void Voice::seedDeclick(double newOutL, double newOutR) { 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 // 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 // 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 // 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. // is impossible even if outCurrent rises while the weight is still significant. (An
// [Rev 1 stored the frozen difference (ref x₀); if outₙ rose while that residue was // earlier revision stored the frozen difference (ref x₀), which could exceed full scale
// still large the sum could exceed full scale. The ±2.0 clamp there was the only guard // if outₙ rose while that residue was still large.)
// 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.
(void)newOutL; (void)newOutR; // consumed only for the floor guard below (void)newOutL; (void)newOutR; // consumed only for the floor guard below
declickPending_ = false; declickPending_ = false;
declickWeight_ = 1.0; // ONE weight for both channels (T1-09: the per-R copy was dead state) declickWeight_ = 1.0; // one weight for both channels
// The reference is already clamped to ±1.0 at start() (lines in start(): the ±1 clamp // ref is already clamped to ±1.0 at start(). Activate only when it's above the floor —
// on lastOutL_/R_ before storing into declickRefL_/R_). No secondary clamp needed here. // if ref ≈ 0 there is nothing to blend.
// Activate only when the ref itself is above the floor — if ref ≈ 0 there is nothing to blend.
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor || declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor); declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
} }
AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) { AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
// Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap, // Shared read/advance for the mono and stereo paths: the read-head geometry is computed
// bracketing indices, interpolation partner) is computed ONCE and applied identically to // once and applied identically to every channel — only the PCM value read differs. The
// every channel — only the PCM value read differs. The amplitude + pitch envelopes tick ONCE // amplitude + pitch envelopes tick once per frame and scale all channels equally.
// 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.
if (!active_ || sample_ == nullptr) { if (!active_ || sample_ == nullptr) {
if (stereo) outR = 0.0f; if (stereo) outR = 0.0f;
return 0.0f; return 0.0f;
@@ -516,10 +475,10 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
const bool haveR = stereo && sample_->channelCount() == 2; const bool haveR = stereo && sample_->channelCount() == 2;
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm; 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 // Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). A valid,
// valid, non-zero-length loop exists and the read head has advanced past the loop end, wrap // non-zero-length loop wraps the read head back into [start, end); a zero-length loop is
// it back into [start, end). A zero-length loop is treated as "no loop". Under Preserve the // "no loop". Under Preserve the loop is over the source read (loop the source, shift the
// loop is over the SOURCE read (loop the source, shift the output — S15×S16 contract). // output).
const SampleLoop& loop = sample_->loop; const SampleLoop& loop = sample_->loop;
const bool loopUsable = sustainLoopUsable(); const bool loopUsable = sustainLoopUsable();
if (loopUsable) { 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 frees once the read head reaches playEnd; the envelope also finishes at the
// trigger envelope also finishes at the same frame count; either latches the voice idle. // same count, either latches idle.
const bool triggerRanOff = const bool triggerRanOff =
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_); playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
// Ran off the sample end with no usable loop -> voice is done. Peer path of the // Ran off the sample end with no usable loop -> voice is done, except an in-flight
// epilogue: an in-flight takeover declick RINGS OUT here instead of hard-cutting — // takeover declick rings out here instead of hard-cutting — dropping it would
// dropping it would re-introduce a step on exactly the path the ramp exists for (a // re-introduce a step on exactly the path the ramp exists for (a restart whose new play
// restart whose new play span ends within the ~4 ms ramp). The voice stays active only // span ends within the ramp). With no declick (the common case) this is byte-identical
// until the ramp floors; with no declick (the common case, and the entire opt-out // to the plain idle-out.
// baseline) this is byte-identical to the plain idle-out.
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) { if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
if (declickPending_) seedDeclick(0.0, 0.0); // the new output here is silence if (declickPending_) seedDeclick(0.0, 0.0); // the new output here is silence
if (declickActive_) { if (declickActive_) {
@@ -561,41 +519,35 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
return 0.0f; 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 amp = tickAmplitude();
const double gain = amp * velocityGain_; const double gain = amp * velocityGain_;
const double pitchEnvSemis = pitchEnv_.tick(); const double pitchEnvSemis = pitchEnv_.tick();
// The pitch-envelope bias factor 2^(semis/12). When the envelope is off (semis exactly 0) // 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the pow
// this is 1.0 and we skip the pow entirely — the Varispeed-off path stays a bare ratio read // entirely — no per-frame transcendental on the common path.
// (no per-frame transcendental), byte-identical to pre-S16.
const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0); const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
double outL, outRlocal = 0.0; double outL, outRlocal = 0.0;
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) { if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
// PRESERVE: feed the shifters the SOURCE stream at unity rate (duration held) and // Feed the shifters the source stream at unity rate (duration held) and transpose the
// TRANSPOSE the output by 2^((note-root + pitchEnvSemis)/12). Pitch envelope adds to // output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the shift
// the shift amount, not the read rate — pitch bends, duration unchanged (S16 // amount, not the read rate. The feed runs one window ahead of readPos_ (the rings
// contract). The feed runs one window AHEAD of readPos_ (the rings were primed with // were primed with that window at start()), under the same sustain-loop wrap rule,
// that window at start()), under the SAME sustain-loop wrap rule as the anchor, and // reading integer source frames (nothing to interpolate). Past the last real frame
// reads integer source frames (readPos_ advances by exactly 1.0 under Preserve, so // the shifter's writer is frozen — it recycles the real tail it already holds.
// 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.
if (loopUsable) { if (loopUsable) {
const std::int64_t loopLen = loop.end - loop.start; const std::int64_t loopLen = loop.end - loop.start;
while (feedPos_ >= loop.end) feedPos_ -= loopLen; while (feedPos_ >= loop.end) feedPos_ -= loopLen;
} }
// GA3 tail wind-down (supersedes the GA2 hold-last-sample clamp). feedPos_ runs one // feedPos_ runs one window ahead of readPos_; the last real source frame is
// window AHEAD of readPos_; the last real source frame is playEnd_-1 for Trigger (the // playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
// user's chosen stop) or frameCount-1 for Gate (the sample's own end). Once feedPos_ // the source is exhausted — feeding the held last sample instead would give the
// reaches that bound the source is EXHAUSTED — GA2 fed the held last sample from here, // splice correlation a DC plateau it can't align on (periodic troughs at the splice
// a DC plateau the splice correlation cannot align on (the DAW tail chop: periodic // cadence, growing toward the note end). Freezing the shifter's writer means no
// troughs at the splice cadence, growing toward the note end as the plateau displaced // padding ever enters the ring, so the splice machinery keeps recycling the frozen
// real ring history). Instead FREEZE the shifter's writer: no padding ever enters the // all-real tail — a continuous tone through the voice's own end. The sustain-loop
// ring, and the splice machinery keeps recycling the frozen all-real tail, every jump // path never gets here: the wrap above keeps feedPos_ < loop.end forever.
// 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.
const std::int64_t feedBound = const std::int64_t feedBound =
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount) (playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
? playEnd_ : frameCount; ? playEnd_ : frameCount;
+226 -346
View File
@@ -1,142 +1,101 @@
#pragma once #pragma once
// sampler_core — the HEART of the Phase S MIDI-playback instrument (D3), deliberately // sampler_core — the polyphonic voice engine: bounded-stealing allocation, an ADSR
// free of any VST3 *and* any REAPER type so it compiles and unit-tests OUTSIDE the DAW // amplitude envelope, a key/velocity keymap resolving (note, velocity) -> zone, and
// and outside any plugin host. It owns the pure sampler engine: polyphonic voice // repitch/interpolation from a root note with loop-point-aware sustain.
// 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.
// //
// PURE MODULE (CLAUDE.md §load-bearing split): NO VST3 types, NO REAPER types, NO SWELL, // Shares the `AudioSample` float alias from peaks. Seam fields (root note, loop points)
// NO vendor/ includes, no include from either SDK. Standard library only. The VST3 shell // enter as plain int/frame-index inputs; the core does no file I/O.
// (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.
#include <array> #include <array>
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <vector> #include <vector>
#include "core/audio/peaks.h" // AudioSample (float) #include "core/audio/peaks.h"
#include "core/instrument/engine/zone_params.h" // per-zone play params + mode enums (Q-W2v header split) #include "core/instrument/engine/zone_params.h"
#include "core/instrument/engine/pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core) #include "core/instrument/engine/pitch_shift.h"
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (S-VIEW-9 velocity->amp transfer curve; eval at start) #include "core/instrument/engine/velocity_curve.h"
namespace reasampler { 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 audio::AudioSample;
using instrument::engine::PitchShifter; using instrument::engine::PitchShifter;
using instrument::engine::VelocityCurve; using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint; using instrument::engine::VelocityPoint;
// The per-zone play-parameter VALUE STRUCTS + per-instance mode enums (ChannelMode / // Keymap — the performance map. A note+velocity resolves to at most one zone; a zone
// VoiceMode / MonoTrigger, AdsrParams / TriggerParams / PitchEnvParams / ZonePlayParams, // names which SampleData to play and the root note to repitch from. Tier-0 degenerate
// SampleLoop / SampleData, and their constants) live in zone_params.h (Q-W2v header // case: a single zone spanning [0,127] with the sample's own root. Tier-1: several
// split, T4-14/T4-17) so param-reading TUs stop recompiling on engine-class edits. // zones, each a key range with its own root.
// ---------------------------------------------------------------------------
// 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.
// //
// TIER-2 EXTENSION (velocity layers / round-robin) — designed for, not built: // Tier-2 extension (velocity layers/round-robin) — designed for, not built: a zone
// resolution returns a zone; a zone today owns one sampleIndex. Tier 2 makes a zone // today owns one sampleIndex; Tier 2 would make it own a list of (velocity-range,
// own a *list* of (velocity-range, sampleIndex) layers (and round-robin sets), and // sampleIndex) layers, and resolve() would gain the velocity dimension it already
// resolve() gains the velocity dimension it already receives but currently ignores // receives but currently ignores for selection — no signature change needed.
// 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.
// ---------------------------------------------------------------------------
// A key range [lowNote, highNote] (inclusive both ends) mapping to one sample, with // A key range [lowNote, highNote] (inclusive) mapping to one sample, with the root
// the root note to repitch from (defaults to the sample's own root, overridable in // note to repitch from (defaults to the sample's own root, overridable per zone).
// the performance map per S5). velocityLow/High reserved for Tier-2 layers; today a // velocityLow/High reserved for Tier-2 layers; today a zone accepts the full 1..127
// zone accepts the full 1..127 velocity range (0 is note-off by MIDI convention). // velocity range (0 is note-off by MIDI convention).
struct KeyZone { struct KeyZone {
int lowNote = 0; int lowNote = 0;
int highNote = 127; int highNote = 127;
int rootNote = 60; // repitch reference for this zone 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 // How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 =
// standard 12-tone-ET (default; bit-identical to pre-S-VIEW-6); 0.0 = no tracking (every // no tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root)
// key plays root pitch); 2.0 = double-rate tracking. Scales the (note-root) semitone offset // semitone offset in keyTrackedRatio; rides both engines via the voice's baseRatio_.
// in the repitch math (keyTrackedRatio); rides BOTH engines via the voice's baseRatio_.
double keyTrack = 1.0; double keyTrack = 1.0;
// S-VIEW-9 velocity->amp transfer curve: maps the note-on velocity (0..127) to the voice's amp // Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
// gain, replacing the fixed linear velocity/127. A per-zone performance characteristic (mirror // (never per frame). Default flat y=1 — every velocity plays at unity.
// 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.
VelocityCurve velocityCurve = VelocityCurve::flat(); VelocityCurve velocityCurve = VelocityCurve::flat();
std::size_t sampleIndex = 0; // index into Keymap::samples std::size_t sampleIndex = 0; // index into Keymap::samples
}; };
// Result of resolving a (note, velocity). `matched == false` means the note falls in // `matched == false` means the note falls in no zone — a defined no-play result, not an
// no zone (out-of-zone) — a defined no-play result, NOT an error and NOT voice 0. // error and not voice 0.
struct ZoneResolution { struct ZoneResolution {
bool matched = false; bool matched = false;
std::size_t zoneIndex = 0; // valid only when matched std::size_t zoneIndex = 0; // valid only when matched
}; };
// The keymap: the decoded samples plus the zones that map keys onto them. Owns // Decoded samples plus the zones that map keys onto them. Zones are tested first-match
// resolution. Pure: no host types. Zones are tested first-match in order, so an // in order, so an earlier zone wins an overlap (deterministic, documented).
// earlier zone wins an overlap (deterministic, documented).
struct Keymap { struct Keymap {
std::vector<SampleData> samples; std::vector<SampleData> samples;
std::vector<KeyZone> zones; std::vector<KeyZone> zones;
// Resolves (note, velocity) to a zone. First zone (in order) whose [low,high] // First zone (in order) whose [low,high] contains `note` wins. velocity is accepted
// contains `note` wins. velocity is accepted now (Tier-2 seam) but does not // (Tier-2 seam) but doesn't affect zone choice at Tier 0-1.
// affect zone choice at Tier 0-1. Returns {matched=false} when no zone contains
// the note.
ZoneResolution resolve(int note, int velocity) const; ZoneResolution resolve(int note, int velocity) const;
// Convenience: build the Tier-0 degenerate keymap one sample mapped // The Tier-0 degenerate keymap: one sample mapped chromatically across the whole
// chromatically across the whole keyboard from its own root note. // keyboard from its own root note.
static Keymap singleSampleChromatic(SampleData sample); 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. Pure equal-temperament; no
// 2^((note - rootNote) / 12). note == rootNote -> 1.0 (unity). One octave up -> 2.0, // reference-frequency needed.
// one octave down -> 0.5. Pure equal-temperament; no reference-frequency needed.
double pitchRatio(int note, int rootNote); double pitchRatio(int note, int rootNote);
// The key-tracked pitch ratio (S-VIEW-6): 2^(((note - rootNote) * keyTrack) / 12). The // 2^(((note - rootNote) * keyTrack) / 12) — keyTrack scales the semitone offset before
// keyTrack scalar scales the semitone offset before the ET conversion, so it governs how // the ET conversion. keyTrack == 1.0 is bit-identical to pitchRatio(note, rootNote)
// far playback pitch tracks the keyboard around the root: // ((note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call); 0.0 means every
// keyTrack == 1.0 -> standard 12-tone-ET (BIT-IDENTICAL to pitchRatio(note, rootNote) — // key plays the root pitch; 2.0 doubles the tracking rate. At the root note the offset is
// (note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call). // 0 regardless of keyTrack. Both repitch engines derive from it via the voice's baseRatio_.
// 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_.
double keyTrackedRatio(int note, int rootNote, double keyTrack); double keyTrackedRatio(int note, int rootNote, double keyTrack);
// --------------------------------------------------------------------------- // AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate:
// AHDSR amplitude envelope (S15 grows the S3 ADSR with a HOLD stage). Sample-based // noteOn() enters Attack; noteOff() enters Release from wherever it is.
// (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).
// //
// Segment math (all linear ramps): // Segment math:
// Attack: 0 -> 1 over attackFrames // 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 // Decay: 1 -> sustainLevel over decayFrames
// Sustain: hold sustainLevel until noteOff // Sustain: hold sustainLevel until noteOff
// Release: currentLevel -> 0 over releaseFrames // Release: currentLevel -> 0 over releaseFrames
// A zero-length attack jumps straight to 1 on the first frame; HOLDFRAMES == 0 skips Hold // 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); // entirely (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff
// zero decay jumps to sustain; a noteOff during attack/hold/decay (release-before-sustain) // during attack/hold/decay releases from the current partial level, not from sustainLevel.
// 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.
// ---------------------------------------------------------------------------
class AdsrEnvelope { class AdsrEnvelope {
public: public:
@@ -167,30 +126,22 @@ private:
double releaseFrom_ = 0.0; // level at the moment noteOff() was called double releaseFrom_ = 0.0; // level at the moment noteOff() was called
}; };
// --------------------------------------------------------------------------- // A stateless-shape amplitude function over the play span, evaluated at a source-frame
// S15 Trigger amplitude envelope (per-frame evaluator). The PlayMode / TriggerParams / // offset into the span (not output frames): under Varispeed a transposed voice consumes
// FadeCurve value structs are defined above with the other per-zone params. // 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.
// 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.
class TriggerEnvelope { class TriggerEnvelope {
public: public:
// Configure from the play span + fades. `playLengthFrames` is (playEnd - startFrame): the // `playLengthFrames` is (playEnd - startFrame). Fades are clamped so
// SOURCE-frame length of the play span. Fades are clamped so fadeIn + fadeOut <= playLength // fadeIn + fadeOut <= playLength (fadeOut anchored to the end). A zero/negative play
// (fadeOut anchored to the end). A zero/negative play length finishes immediately. // length finishes immediately.
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames, void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve); std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve);
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame) source frames into the play // Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame). Latches finished() at
// span. Latches finished() once the offset reaches the play length (>= playLength). Pure over // or past playLength. Pure over the offset so it composes with either pitch engine's
// the offset (no internal advance) so it composes with either pitch engine's read rate. // read rate.
double amplitudeAt(double sourceOffset); double amplitudeAt(double sourceOffset);
bool finished() const { return finished_; } bool finished() const { return finished_; }
@@ -203,21 +154,14 @@ private:
bool finished_ = false; bool finished_ = false;
}; };
// --------------------------------------------------------------------------- // tick() returns the current pitch offset in semitones (0 when disabled or past
// S16 pitch envelope (per-frame evaluator). The PitchEngine / PitchEnvParams value structs // attack+decay), advancing one frame. The voice converts it to a ratio multiply
// and the kDefaultPitchEngine / kPreserveWindowMs constants are defined above. // (Varispeed) or a shift-amount add (Preserve).
// ---------------------------------------------------------------------------
// 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.
class PitchEnvelope { class PitchEnvelope {
public: public:
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; } void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
void noteOn() { pos_ = 0; } void noteOn() { pos_ = 0; }
// Advance one frame, return this frame's pitch offset in semitones.
double tick(); double tick();
private: private:
@@ -225,29 +169,19 @@ private:
std::int64_t pos_ = 0; std::int64_t pos_ = 0;
}; };
// Takeover declick (Phase S GA fix, rev 2 — audible click when a sounding voice is // Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback, a
// restarted). A takeover restart HARD-CUTS the sounding tone: the read head and envelope // cross-sample legato restart, or a poly at-cap steal) hard-cuts the old tone in one
// restart in one frame, a step discontinuity that clicks. This is the same physics on EVERY // frame a step discontinuity that clicks. When the caller opts in (start()'s
// restart-of-a-sounding-voice path — the MONO Retrigger takeover/fallback, the mono // declickTakeover), start() records the last rendered output as a pre-cut reference, and
// cross-sample legato restart, and the POLY at-cap voice steal (the editor's preview is a // the first frame after the restart seeds a compensation equal to
// plain engine noteOn since the PreviewCard retirement, so a preview re-fire at cap is just // (reference - that frame's raw new output), summed in ungated and decaying by
// an at-cap steal). When the caller opts in (start()'s declickTakeover; the engine passes // kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless
// it on all of those restart paths when constructed with takeoverDeclick), // of the new envelope's first value, and the residue fades to the -80 dB floor in a few ms.
// the restart smooths the ACTUAL output discontinuity: start() records the last rendered // An earlier revision gated the compensation by (1 - newAmp): any restart whose new
// output as the pre-cut reference, and the FIRST frame rendered after the restart seeds a // amplitude was instantly ~1 (Trigger with no fade-in, zero-attack Gate) got zero
// compensation equal to (reference that frame's raw new output). The compensation is // compensation and kept the full click — the difference-seed has no such hole. Off by
// summed into the output UNGATED and decays by kDeclickDecay per frame, so the boundary // default so the bare core stays byte-identical to the pre-fix engine; the processor
// frame reproduces the old level EXACTLY — zero step whatever the new envelope's first // shell opts in.
// 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.
inline constexpr double kDeclickDecay = 0.95; // per-frame decay of the compensation 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) 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 { class Voice {
public: public:
// Starts this voice on `note` at `velocity`, playing `sample` (a stable reference // Plays `sample` (a stable reference the caller must keep alive — the Keymap owns it),
// the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched // repitched from `rootNote`. AHDSR/play-mode/pitch-engine params are read from
// from `rootNote`. All five AHDSR fields (A/H/D/S/R) are read directly from // sample.play (frames, resolved from stored seconds at keymap build). Preserve shifters
// sample.play.adsr — the per-zone values (in FRAMES) resolved from the stored seconds by // must already be pre-sized (presizePreserveShifters, off-thread) — start() only
// buildTier0Keymap / buildZonedKeymap against the live sample rate. The S15 play MODE + // reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio thread
// Trigger params and the S16 pitch ENGINE + pitch envelope are read from `sample.play`. // inside process(); the warm silence pass settles the OLA taps before the first output
// The Preserve shifters MUST already be pre-sized (presizePreserveShifters, off-thread) — // frame. Byte-identical to the bare engine when sample.play is default.
// start() only reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio // `keyTrack` scales the (note-root) semitone offset feeding the repitch ratio; 1.0 is
// thread inside process(). The warm silence pass settles the OLA taps before the first // standard 12-tone-ET. `velocityCurve` maps note-on velocity to amp gain, evaluated once
// output frame (no cold-start click). Byte-identical to the pre-S15 engine when sample.play // here (off the per-frame path); defaults to flat y=1. `declickTakeover`: when true and
// is default (Gate + Varispeed + no pitch env). // this voice is currently active (a takeover/steal restart, not a fresh start), arms the
// `keyTrack` (S-VIEW-6) scales the (note-root) semitone offset feeding the repitch ratio; // difference-seeded declick compensation on the first frame after the restart (see
// 1.0 (the default) is standard 12-tone-ET, bit-identical to the pre-S-VIEW-6 baseRatio_. // kDeclickDecay above). A fresh start never declicks.
// `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.
void start(int note, int velocity, const SampleData& sample, int rootNote, void start(int note, int velocity, const SampleData& sample, int rootNote,
double keyTrack = 1.0, double keyTrack = 1.0,
const VelocityCurve& velocityCurve = VelocityCurve::flat(), const VelocityCurve& velocityCurve = VelocityCurve::flat(),
bool declickTakeover = false); bool declickTakeover = false);
// MONO LEGATO takeover (Phase S): re-pitch this ACTIVE voice to `note` without touching the // Mono legato takeover: re-pitch this active voice to `note` without touching the
// amplitude envelope, the read position, or the shifter state — pitch moves, no re-attack. // amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
// Both engines pick the new baseRatio_ up on the next frame (Varispeed via the read rate, // engines pick the new baseRatio_ up on the next frame. No-op on an idle voice. Caller
// Preserve via the per-frame setShiftRatio). No-op on an idle voice. The caller guarantees // guarantees the voice is playing the same SampleData the resolved zone names — a
// the voice is playing the SAME SampleData the (note-resolved) zone names — a cross-sample // cross-sample takeover must restart the voice instead.
// takeover must restart the voice instead (see MonoTrigger).
void retune(int note, int rootNote, double keyTrack = 1.0); 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 // Gate off. In Gate mode enters the AHDSR release; in Trigger mode a no-op (Trigger
// TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length). // ignores note-off and plays through to its play length).
void release(); void release();
// HARD STOP — CC 120 (All Sounds Off) semantics. Immediately silences this voice regardless // Hard stop (CC 120 semantics): immediately silences this voice regardless of play mode,
// of play mode: sets active_ = false with no release ramp. Stops a ringing Trigger one-shot // no release ramp. Stops a ringing Trigger one-shot instantly (release() cannot).
// instantly (which release() cannot do). RT-safe: no allocation, no lock. // RT-safe: no allocation, no lock.
void hardStop(); void hardStop();
// True while this voice is producing (or about to produce) sound (including any // True while producing (or about to produce) sound, including any declick ring-out
// declick ring-out tail past the note's playable span). // tail past the note's playable span.
bool active() const { return active_; } bool active() const { return active_; }
// True while this voice is sounding a PLAYABLE NOTE — active AND the amplitude // True while sounding a playable note — active and the amplitude envelope hasn't
// envelope has not yet finished. A voice whose note has run to its end but is still // finished. A voice ringing out a declick tail past note end is active() but not
// ringing out a declick tail is active() but NOT soundingNote(). Use this to // soundingNote(); the Preserve-cap count and the mono-legato takeover predicate must
// distinguish "note is alive" (active) from "note occupies a voice slot" (soundingNote) // ignore a ramp-only past-end voice or a new note-on could be dropped/silently muted.
// 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.
bool soundingNote() const { return active_ && !amplitudeDone_; } 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_; } int note() const { return note_; }
// Monotonic age counter — higher = started earlier relative to others. The voice // Monotonic age counter for the engine's oldest-first stealing policy. Set by the engine.
// engine uses this for its stealing policy (oldest first). Set by the engine.
std::uint64_t startOrder() const { return startOrder_; } std::uint64_t startOrder() const { return startOrder_; }
void setStartOrder(std::uint64_t order) { startOrder_ = order; } void setStartOrder(std::uint64_t order) { startOrder_ = order; }
bool releasing() const { return releasing_; } bool releasing() const { return releasing_; }
// The S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only // The 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 — // meaningful while active().
// 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).
PitchEngine pitchEngine() const { return pitchEngine_; } PitchEngine pitchEngine() const { return pitchEngine_; }
// The SampleData this voice is playing (nullptr when never started). The engine's mono // Identity only, never mutated through; the engine's mono legato path compares it
// legato path compares it against the new note's resolved sample — a same-sample takeover // against the new note's resolved sample to decide retune vs. restart.
// retunes; a cross-sample one restarts. Identity only; callers never mutate through it.
const SampleData* playingSample() const { return sample_; } const SampleData* playingSample() const { return sample_; }
// Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the // Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off
// audio thread (this allocates; also sizes the prime scratch buffer). The engine calls it // the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
// once at construction so start() — which runs on the audio thread inside process() — never // runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
// allocates: start() only prime()s the already-sized rings with the first window of source // Idempotent: a re-presize to the same window is a cheap no-op.
// (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.
void presizePreserveShifters(std::int64_t windowFrames); void presizePreserveShifters(std::int64_t windowFrames);
// Renders one frame's contribution, advancing the read head and envelope by one // Renders one frame's contribution, advancing the read head and envelope by one output
// output frame. Returns 0.0 (and goes idle) once the envelope finishes or the // frame. Returns 0.0 (and goes idle) once the envelope finishes or the sample runs out
// sample runs out with no loop. The value is already velocity- and // with no loop. Already velocity- and envelope-scaled — the engine sums voices directly.
// envelope-scaled — the engine sums voices directly. This is the MONO path (channel // Mono path (channel 0 only).
// 0 only) — byte-identical to the pre-S7 engine, so mono play is unchanged.
AudioSample renderFrame(); AudioSample renderFrame();
// STEREO render: writes THIS frame's per-channel contribution into `l`/`r` and advances // Writes this frame's per-channel contribution into `l`/`r` and advances the read head +
// the read head + envelope by exactly one frame (the same single advance the mono path // envelope by exactly one frame (the envelope ticks once per frame, shared across both
// performs — the envelope ticks ONCE per frame, shared across both channels). For a mono // channels). A mono sample writes the same value to both (dual-mono/centered). Goes idle
// sample (channelCount()==1) both `l` and `r` receive the same value (dual-mono / centered). // on the same conditions as the mono path, writing 0 to both.
// 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.
void renderFrameStereo(AudioSample& l, AudioSample& r); void renderFrameStereo(AudioSample& l, AudioSample& r);
private: private:
// Shared read/advance for both render paths: computes the interpolated per-channel // 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 // 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 // the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects
// latches idle on exhaustion. `stereo` selects whether the second channel is read (and // whether the second channel is read (into `outR`). Returns the channel-0 value.
// returned in `outR`); when false `outR` is left untouched. Returns the channel-0 value.
AudioSample advanceFrame(bool stereo, AudioSample& outR); AudioSample advanceFrame(bool stereo, AudioSample& outR);
// This frame's amplitude in [0,1] from the active envelope. GATE: the AHDSR ticks once per // This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
// output frame (independent of the read rate — envelope time is wall-clock). TRIGGER: the // output frame (envelope time is wall-clock, independent of read rate). Trigger: fade
// fade shape is evaluated at the SOURCE offset (readPos - startFrame) so the fades anchor to // shape is evaluated at the source offset (readPos - startFrame) so fades anchor to
// source frames and compose with either pitch engine. Sets amplitudeDone_ when the envelope // source frames regardless of pitch engine. Sets amplitudeDone_ on finish so
// finishes (Gate: release complete; Trigger: play length reached) so advanceFrame frees the voice. // advanceFrame frees the voice.
double tickAmplitude(); double tickAmplitude();
// True when the sustain loop applies to this voice: GATE mode with a valid, non-empty loop // True when the sustain loop applies: Gate mode with a valid, non-empty loop inside the
// inside the sample (S15 — Trigger one-shots never loop). The single source of truth for // sample (Trigger one-shots never loop). Single source of truth for the wrap rule shared
// the wrap rule shared by the output anchor (readPos_), the Preserve feed (feedPos_), and // by the output anchor, the Preserve feed, and the start()-time ring prime.
// the start()-time ring prime.
bool sustainLoopUsable() const; bool sustainLoopUsable() const;
bool active_ = false; bool active_ = false;
@@ -379,13 +289,13 @@ private:
int note_ = 0; int note_ = 0;
double velocityGain_ = 1.0; double velocityGain_ = 1.0;
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio 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 double readPos_ = 0.0; // fractional frame index into the sample
const SampleData* sample_ = nullptr; const SampleData* sample_ = nullptr;
// S15 play mode + amplitude envelopes. Gate uses env_ (AHDSR); Trigger uses trigEnv_. Only // Gate uses env_ (AHDSR); Trigger uses trigEnv_ — only one active per voice (selected by
// one is active per voice (selected by playMode_ at start). playEnd_ is Trigger's source-frame // playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when
// stop (the voice frees when readPos_ >= playEnd_, mirroring the run-off-end idle). // readPos_ >= playEnd_).
PlayMode playMode_ = PlayMode::Gate; PlayMode playMode_ = PlayMode::Gate;
AdsrEnvelope env_; AdsrEnvelope env_;
TriggerEnvelope trigEnv_; TriggerEnvelope trigEnv_;
@@ -393,20 +303,17 @@ private:
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
bool amplitudeDone_ = false; // set when the active amplitude envelope finished bool amplitudeDone_ = false; // set when the active amplitude envelope finished
// S16 pitch engine + pitch envelope. pitchEngine_ selects Varispeed (ratio bias) vs Preserve // pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
// (source-rate read + shifter). shiftL_/shiftR_ transpose the Preserve output per channel // shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
// (one read head, per-channel shift — S7 compose). pitchEnv_ rides EITHER engine.
// //
// GA2 onset fix: the shifter rings are PRIMED at start() with the first window of the // The shifter rings are primed at start() with the first window of the actual upcoming
// actual upcoming source (loop-unrolled, silence past the end) — output frame 0 is source // source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
// frame `start`, no ring-fill silence, and splices always land in real history. feedPos_ // silence, and splices always land in real history. feedPos_ is the integer source frame
// is the integer SOURCE frame the shifters are fed next; it runs exactly one window AHEAD // fed to the shifters next; it runs exactly one window ahead of readPos_ under the same
// of readPos_ (the wall-clock output anchor) under the same sustain-loop wrap rule. // sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end;
// GA3 tail wind-down: once feedPos_ passes the last real frame (Gate: sample end; // Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
// Trigger: playEnd_) the shifters' writers are FROZEN — no padding enters the rings and // splice machinery recycles the frozen real tail through the note end (see advanceFrame).
// the splice machinery recycles the frozen real tail through the note end (see // primeBuf_ is the presized scratch the prime stream is assembled into.
// advanceFrame). primeBuf_ is the presized scratch the prime stream is assembled into
// (never touched outside start()).
PitchEngine pitchEngine_ = PitchEngine::Varispeed; PitchEngine pitchEngine_ = PitchEngine::Varispeed;
PitchEnvelope pitchEnv_; PitchEnvelope pitchEnv_;
PitchShifter shiftL_; PitchShifter shiftL_;
@@ -414,27 +321,24 @@ private:
std::int64_t feedPos_ = 0; std::int64_t feedPos_ = 0;
std::vector<AudioSample> primeBuf_; std::vector<AudioSample> primeBuf_;
// Seeds the takeover compensation on the FIRST frame after a restart: the ramp is the // 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) — // 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 // applied ungated so the boundary frame reproduces the old level exactly.
// takeover-declick block above kDeclickDecay.
void seedDeclick(double newOutL, double newOutR); void seedDeclick(double newOutL, double newOutR);
// Takeover declick state (see kDeclickDecay above). lastOut{L,R}_ track the voice's most // lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
// recent rendered output (post-gain, incl. any running declick). A takeover/steal start() // records them as declickRef{L,R}_ and sets declickPending_; the first frame after the
// records them as declickRef{L,R}_ (the clamped pre-cut reference) and sets declickPending_; // restart calls seedDeclick to arm the bounded blend:
// the first frame rendered after the restart calls seedDeclick to arm the BOUNDED BLEND: // outₙ = outₙ*(1w) + ref*w, w = declickWeight_ (one weight, shared by both channels so
// outₙ = outₙ*(1w) + ref*w where w = declickWeight_ (ONE weight, deliberately shared // L/R can never diverge), starting at 1.0 and decaying by kDeclickDecay each frame.
// by both channels so L/R can never diverge — Q-W0 T1-09 removed the dead per-R copy) // Algebraically outₙ + w*(ref outₙ), so the boundary frame (w=1) is exactly `ref` and
// starts at 1.0 and decays by // every subsequent output is bounded by max(|ref|, |outₙ|) — mid-ramp overshoot is
// kDeclickDecay each frame. This is algebraically `outₙ + w*(ref outₙ)`, so the // impossible regardless of outₙ rising. (An earlier revision stored the frozen difference
// boundary frame (w=1) is exactly `ref` and every subsequent output is bounded by // (ref x₀); when outₙ rose while that residue was still large, the sum could exceed
// max(|ref|, |outₙ|) — mid-ramp overshoot is impossible regardless of outₙ rising. // full scale by several dB.)
// [Rev 1 stored the frozen difference (ref x₀); when outₙ rose while that residue // lastOut is not zeroed by start() — a second same-block takeover (no frame rendered
// was still large the sum could exceed full scale by up to ~+3.8 dB.] // between) must record the same pre-cut reference, not a phantom 0. The whole declick
// lastOut is NOT zeroed by start() — a second same-block takeover (no frame rendered // state is cleared on a fresh (non-takeover) start.
// 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 declickPending_ = false;
bool declickActive_ = false; bool declickActive_ = false;
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target) double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
@@ -446,50 +350,40 @@ private:
std::uint64_t startOrder_ = 0; std::uint64_t startOrder_ = 0;
}; };
// --------------------------------------------------------------------------- // The polyphonic voice engine: a fixed pool of voices, note-on allocation with bounded
// The polyphonic voice engine: a fixed pool of voices, note-on allocation with // voice stealing, note-off routing, and block rendering (sum of voices).
// bounded voice stealing, note-off routing, and block rendering (sum of voices).
// //
// VOICE-STEALING POLICY (deterministic, documented): when all voices are busy and a // Voice-stealing policy (deterministic, documented): when all voices are busy and a new
// new note-on arrives, steal in this priority order: // note-on arrives, steal in this priority order:
// 1. the oldest voice already in RELEASE (finishing anyway — cheapest to cut), // 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). // 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 // "Oldest" = smallest startOrder (assigned monotonically at note-on) — the standard
// standard hardware-sampler policy: prefer to sacrifice a dying tail, and failing // hardware-sampler policy.
// that, the note that has already had the most time.
// ---------------------------------------------------------------------------
class VoiceEngine { class VoiceEngine {
public: public:
// Builds an engine with `maxVoices` voices (the polyphony bound) playing from // Builds an engine with `maxVoices` voices playing from `keymap` (must outlive the
// `keymap`. The keymap must outlive the engine (the engine holds a reference — it // engine — held by reference, never copies PCM). Play params ride on each zone's
// reads zones and sample data through it, never copies PCM). Every AHDSR field (A/H/D/S/R) // SampleData::play; the engine holds no instrument-wide ADSR.
// + play mode + pitch engine rides on each zone's SampleData::play (in FRAMES, resolved // `preserveVoiceCap` bounds how many Preserve-engine voices may sound at once (the
// 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
// shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is // 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 // dropped rather than glitching; 0 means no separate cap (bounded only by maxVoices).
// maxVoices). `preserveWindowFrames` is the OLA window (in OUTPUT frames) every voice's // `preserveWindowFrames` is the OLA window every voice's Preserve shifters are
// Preserve pitch shifters are PRE-SIZED to at construction (OFF the audio thread), so // pre-sized to at construction (off the audio thread), so note-on never allocates; 0
// note-on (which runs in process()) never allocates; 0 leaves them pass-through (a // leaves them pass-through. The processor derives it from the host sample rate.
// 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.
// //
// `voiceMode` (Phase S): POLY is the pool-with-stealing engine above; MONO drives a single // `voiceMode`: POLY is the pool-with-stealing engine above; MONO drives a single voice
// voice (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger` // (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 // (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a
// takeover without a re-attack). Both default to today's behavior (Poly / Retrigger). The // same-sample takeover without a re-attack). The engine's config is immutable — a
// engine's config is immutable — a mode/count change rebuilds the engine off-thread through // mode/count change rebuilds the engine off-thread through the processor's drain-slot
// the processor's drain-slot reload, so ringing tails survive the swap. // reload, so ringing tails survive the swap.
// //
// `takeoverDeclick` (Phase S GA fix): when TRUE, every RESTART of a SOUNDING voice — // `takeoverDeclick`: when true, every restart of a sounding voice (mono retrigger
// the MONO Retrigger takeover, the retrigger fallback on note-off, the cross-sample // takeover/fallback, cross-sample legato restart, poly at-cap steal) seeds the
// legato restart, and the POLY at-cap voice STEAL — seeds the per-voice declick ramp // per-voice declick ramp (see kDeclickDecay) so the hard cut doesn't click. start()
// (see kDeclickDecay) so the hard cut of the old tone does not click. start() self-gates // self-gates on the voice being active, so a fresh start never ramps. Default false
// 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; the processor shell opts in.
// 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.
VoiceEngine(std::size_t maxVoices, const Keymap& keymap, VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0, std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
VoiceMode voiceMode = VoiceMode::Poly, VoiceMode voiceMode = VoiceMode::Poly,
@@ -507,42 +401,32 @@ public:
// the older tail to ring — matches hardware behavior). No-op if none match. // the older tail to ring — matches hardware behavior). No-op if none match.
void noteOff(int note); void noteOff(int note);
// CC 123 — MIDI All-Notes-Off: clears the MONO held stack and RELEASES every active voice // CC 123 (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 // (Gate enters AHDSR release; Trigger ignores release and plays through). The mono
// through their bounded play length). This is the mono stack's ONLY reset path — a phantom // stack's only reset path — a phantom entry left by a lost note-off would otherwise be
// entry left by a lost note-off would otherwise be resurrected by the fallback and sustain // resurrected by the fallback and sustain forever with no key held. RT-safe.
// forever with no key held. RT-safe (no allocation, bounded by maxVoices).
void allNotesOff(); void allNotesOff();
// CC 120 — MIDI All-Sounds-Off: hard-stops EVERY voice immediately (active_ = false, no // CC 120 (All-Sounds-Off): hard-stops every voice immediately, clears the mono held
// release ramp), clears the MONO held stack, and silences even Trigger one-shots that would // stack, silences even Trigger one-shots that would ignore a release. Panic; CC 123 is
// ignore a release. Use for panic; CC 123 for the softer "let gates release" behavior. // the softer "let gates release." RT-safe, callable from the audio thread.
// RT-safe (no allocation, bounded by maxVoices); callable from the audio thread.
void allSoundsOff(); void allSoundsOff();
// REAL-TIME render (S4): sums all active voices into the caller-provided buffer // Sums all active voices into the caller-provided buffer `out[0..frameCount)`, adding
// `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes — // to whatever is there — never allocates (the audio-thread entry point; the VST3
// this never touches memory it does not own and NEVER allocates). This is the // process callback passes the host's own output buffer). Voices that finish mid-block
// audio-thread entry point: the VST3 process callback passes the host's own output // go idle. `out` must point at least `frameCount` writable samples; null/zero is a no-op.
// 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.
void render(AudioSample* out, std::size_t frameCount); void render(AudioSample* out, std::size_t frameCount);
// REAL-TIME stereo render (S7): sums all active voices per-channel into the caller's two // Stereo overload: sums per-channel into `left`/`right`, same RT discipline. A mono
// buffers `left`/`right` (each `frameCount` writable samples), ADDING to whatever is there // sample plays dual-mono (same value both channels); a stereo sample plays its two
// (the caller clears/mixes). Same RT discipline as the mono overload — no allocation, no // channels. Mono and stereo render are independent output shapes over the same voice
// resize, no lock. A mono sample plays dual-mono (same value to both channels, centered); // pool — the active channel mode picks which one the process callback drives per block.
// 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.
void render(AudioSample* left, AudioSample* right, std::size_t frameCount); void render(AudioSample* left, AudioSample* right, std::size_t frameCount);
// TEST / off-thread convenience: appends `frameCount` summed frames to `out` // Test/off-thread convenience: appends `frameCount` summed frames to `out` (grows it —
// (grows it — DO NOT call on the audio thread; it allocates). Delegates to the // do not call on the audio thread). Delegates to the real-time overload after sizing
// real-time overload after sizing the buffer, so both paths share one mix loop. // the buffer. Does not clear existing contents — appends.
// Does not clear existing contents — appends, matching the pre-S4 contract the
// unit tests rely on.
void render(std::vector<AudioSample>& out, std::size_t frameCount); void render(std::vector<AudioSample>& out, std::size_t frameCount);
// Count of currently active voices (for tests / diagnostics). // 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). // one per the documented policy. Always returns a valid index (maxVoices >= 1).
std::size_t allocateVoice(); 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. // (cheap: bounded by maxVoices) rather than maintained as a running tally.
std::size_t activePreserveVoices() const; std::size_t activePreserveVoices() const;
// --- MONO mode (Phase S): last-note priority over a held-note stack ------------ // Mono mode: last-note priority over a held-note stack. The stack holds every
// The stack holds every currently-held, ZONE-RESOLVING note in press order (top = most // currently-held, zone-resolving note in press order (top = most recent = the sounding
// recent = the sounding note while the voice is gated). An out-of-zone note never joins // note). An out-of-zone note never joins (it cannot sound, so it must not later take
// (it cannot sound, so it must not later take the voice back on a fallback). Re-pressing // the voice back on a fallback). Re-pressing a held note moves it to the top.
// a held note moves it to the top. Fixed-capacity (128 distinct MIDI notes) — no // Fixed-capacity (128 distinct MIDI notes) — no allocation on the audio thread.
// allocation on the audio thread. Velocity is kept per held note so a RETRIGGER fallback // Velocity is kept per held note so a retrigger fallback re-strikes at its original
// re-strikes the fallen-back-to note at ITS original velocity. // velocity.
struct HeldNote { std::uint8_t note; std::uint8_t 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 // Push to the stack and take the voice over (legato retune on a same-sample takeover,
// takeover, else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone // else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone or
// or out-of-range (note outside [0,127] — rejected BEFORE the stack, which stores uint8). // out-of-range (rejected before the stack, which stores uint8). The Preserve cap is
// The S16 Preserve cap is NOT applied in mono — a single voice runs at most one shifter, // not applied in mono — a single voice runs at most one shifter, inherently within any
// inherently within any cap; applying it would wrongly drop a Preserve->Preserve takeover. // cap; applying it would wrongly drop a Preserve->Preserve takeover.
std::size_t monoNoteOn(int note, int velocity); 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 // Pop from the stack; if the released note was sounding, fall back to the most-recent
// most-recent still-held note (retrigger or legato per monoTrigger_), else release. // still-held note (retrigger or legato per monoTrigger_), else release.
void monoNoteOff(int note); void monoNoteOff(int note);
// Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent. // Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent.
void removeHeld(int note); void removeHeld(int note);
std::vector<Voice> voices_; std::vector<Voice> voices_;
const Keymap& keymap_; 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" std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
VoiceMode voiceMode_ = VoiceMode::Poly; VoiceMode voiceMode_ = VoiceMode::Poly;
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger; 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::array<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1
std::size_t heldCount_ = 0; std::size_t heldCount_ = 0;
}; };
// NOTE (preview redesign): the Phase S PreviewCard — a dedicated preview voice isolated // The editor's preview trigger is a synthetic note-on at the loaded capture's root note
// from the MIDI pool — is RETIRED. The editor's preview trigger is now a synthetic note-on // through the same VoiceEngine host MIDI drives, so preview is a real voice: it counts
// at the loaded capture's root note through the SAME VoiceEngine host MIDI drives, so a // against the voice count, can steal/be stolen, and respects Poly/Mono + Retrigger/Legato.
// preview is a real voice: it counts against the voice count, can steal / be stolen, and // There is no dedicated preview voice isolated from the MIDI pool.
// 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.
} // namespace reasampler } // namespace reasampler
+28 -56
View File
@@ -2,20 +2,19 @@
#include "core/instrument/engine/velocity_curve.h" #include "core/instrument/engine/velocity_curve.h"
#include <algorithm> // std::max, std::min, std::abs, std::stable_sort #include <algorithm>
#include <cmath> // std::fabs #include <cmath>
#include <utility> // std::move #include <utility>
namespace reasampler::instrument::engine { namespace reasampler::instrument::engine {
namespace { namespace {
double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); } double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); }
double clampAmp(double a) { return std::clamp(a, kAmpMin, kAmpMax); } 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 // X spans the width for [0,127]; Y spans (height-1) rows for amp [0,1] with amp 1 at the TOP
// spans (height-1) rows for amp [0,1] with amp 1 at the TOP (y increases downward). // (pixel y increases downward, so this axis is inverted relative to amp).
double velPerPixel(const VelocityCurve::Box& box) { double velPerPixel(const VelocityCurve::Box& box) {
const int w = std::max(0, box.width); const int w = std::max(0, box.width);
if (w <= 0) return 0.0; 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) { int ampToY(const VelocityCurve::Box& box, double amp) {
const int h = std::max(0, box.height); const int h = std::max(0, box.height);
if (h <= 1) return box.top; 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); const double frac = (clampAmp(amp) - kAmpMin) / (kAmpMax - kAmpMin);
return box.top + static_cast<int>((1.0 - frac) * static_cast<double>(h - 1) + 0.5); 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 VelocityCurve::flat() {
VelocityCurve c; VelocityCurve c;
c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}}; // y = 1 everywhere (R10-F1 Option A) c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}};
return c; return c;
} }
VelocityCurve VelocityCurve::linear() { VelocityCurve VelocityCurve::linear() {
VelocityCurve c; VelocityCurve c;
c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}}; // y = velocity/127 c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}};
return c; return c;
} }
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) { VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
// Box-clamp every point, then stable-sort by velocity (X-order; stable so coincident-X points // Stable sort so coincident-X points keep their wire order (eval stays well-defined for
// keep their wire order). A stable sort keeps the eval well-defined for duplicate-X knots. // duplicate-X knots).
for (VelocityPoint& p : pts) { for (VelocityPoint& p : pts) {
p.velocity = clampVelocity(p.velocity); p.velocity = clampVelocity(p.velocity);
p.amp = clampAmp(p.amp); p.amp = clampAmp(p.amp);
@@ -65,18 +63,16 @@ VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
[](const VelocityPoint& a, const VelocityPoint& b) { [](const VelocityPoint& a, const VelocityPoint& b) {
return a.velocity < b.velocity; 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(); 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) { if (pts.front().velocity > kVelMin) {
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().amp}); pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().amp});
} else { } else {
pts.front().velocity = kVelMin; // snap a near-0 first point exactly onto the endpoint pts.front().velocity = kVelMin;
} }
if (pts.back().velocity < kVelMax) { if (pts.back().velocity < kVelMax) {
pts.push_back(VelocityPoint{kVelMax, pts.back().amp}); pts.push_back(VelocityPoint{kVelMax, pts.back().amp});
} else { } else {
pts.back().velocity = kVelMax; // snap a near-127 last point exactly onto the endpoint pts.back().velocity = kVelMax;
} }
VelocityCurve c; VelocityCurve c;
c.points_ = std::move(pts); c.points_ = std::move(pts);
@@ -85,19 +81,12 @@ VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
namespace { namespace {
// FritschCarlson monotone-cubic tangent for one interior knot i, given the secant slopes of the // Fritsch-Carlson monotone-cubic tangent: a sign change (or flat) neighbour is a local extremum,
// two adjacent segments (dPrev = secant into knot i, dNext = secant out of knot i). Returns the // so the tangent pins to 0 to avoid overshoot; otherwise the weighted-harmonic-mean tangent,
// limited tangent that keeps the cubic Hermite piece monotone and inside the data range. // 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.
// 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().
double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) { 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 if (dPrev * dNext <= 0.0) return 0.0;
// 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.
const double w1 = 2.0 * spanNext + spanPrev; const double w1 = 2.0 * spanNext + spanPrev;
const double w2 = spanNext + 2.0 * spanPrev; const double w2 = spanNext + 2.0 * spanPrev;
return (w1 + w2) / (w1 / dPrev + w2 / dNext); return (w1 + w2) / (w1 / dPrev + w2 / dNext);
@@ -106,33 +95,23 @@ double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double
} // namespace } // namespace
double VelocityCurve::eval(double velocity) const { double VelocityCurve::eval(double velocity) const {
if (points_.empty()) return kAmpMax; // degenerate (shouldn't occur) -> flat unity if (points_.empty()) return kAmpMax;
if (points_.size() == 1) return clampAmp(points_[0].amp); // 1-point -> that point's amp if (points_.size() == 1) return clampAmp(points_[0].amp);
const double v = clampVelocity(velocity); 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_.front().velocity) return clampAmp(points_.front().amp);
if (v >= points_.back().velocity) return clampAmp(points_.back().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) { for (std::size_t i = 0; i + 1 < points_.size(); ++i) {
const VelocityPoint& a = points_[i]; const VelocityPoint& a = points_[i];
const VelocityPoint& b = points_[i + 1]; const VelocityPoint& b = points_[i + 1];
if (v >= a.velocity && v <= b.velocity) { if (v >= a.velocity && v <= b.velocity) {
const double span = b.velocity - a.velocity; const double span = b.velocity - a.velocity;
// Coincident-X neighbours (a step): jump straight to the later point's amp — the segment // Coincident-X neighbours (a step): zero-width segment, no interior to blend.
// has zero width so there is no interior to blend.
if (span <= 0.0) return clampAmp(b.amp); if (span <= 0.0) return clampAmp(b.amp);
// --- Monotone cubic Hermite (FritschCarlson) interpolation on segment [a,b] --------- // Monotone cubic Hermite (Fritsch-Carlson): provably stays within [a.amp, b.amp]
// Curved (spline) response, not straight lines. The interpolant provably stays within // between the two knots (no overshoot), reproducing a straight line for collinear input.
// [a.amp, b.amp] between the two knots (no bulge below 0 / above 1), and for collinear const double d = (b.amp - a.amp) / span;
// 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
// 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; double mA = d;
if (i > 0) { if (i > 0) {
const VelocityPoint& prev = points_[i - 1]; const VelocityPoint& prev = points_[i - 1];
@@ -141,7 +120,7 @@ double VelocityCurve::eval(double velocity) const {
const double dPrev = (a.amp - prev.amp) / spanPrev; const double dPrev = (a.amp - prev.amp) / spanPrev;
mA = fritschCarlsonTangent(dPrev, d, spanPrev, span); mA = fritschCarlsonTangent(dPrev, d, spanPrev, span);
} else { } else {
mA = 0.0; // coincident-X predecessor (a step at a) -> flat tangent mA = 0.0;
} }
} }
double mB = d; double mB = d;
@@ -152,13 +131,10 @@ double VelocityCurve::eval(double velocity) const {
const double dNext = (next.amp - b.amp) / spanNext; const double dNext = (next.amp - b.amp) / spanNext;
mB = fritschCarlsonTangent(d, dNext, span, spanNext); mB = fritschCarlsonTangent(d, dNext, span, spanNext);
} else { } 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 t = (v - a.velocity) / span;
const double t2 = t * t; const double t2 = t * t;
const double t3 = t2 * 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) { std::size_t VelocityCurve::addPoint(double velocity, double amp) {
const VelocityPoint p{clampVelocity(velocity), clampAmp(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 // First index strictly greater, so a duplicate-X point lands immediately after the existing one.
// duplicate-X point lands immediately after the existing one (a later move can separate them).
std::size_t i = 0; std::size_t i = 0;
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i; while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p); 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 newAmp = clampAmp(amp);
double newVel; double newVel;
if (isFirst) { if (isFirst) {
newVel = kVelMin; // endpoint pinned in X at 0 — only amp moves newVel = kVelMin;
} else if (isLast) { } else if (isLast) {
newVel = kVelMax; // endpoint pinned in X at 127 — only amp moves newVel = kVelMax;
} else { } 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 lo = points_[index - 1].velocity;
const double hi = points_[index + 1].velocity; const double hi = points_[index + 1].velocity;
newVel = std::clamp(clampVelocity(velocity), lo, hi); 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) { VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) {
// The exact inverse of velToX/ampToY (within the one-pixel rounding quantum). Degenerate // Exact inverse of velToX/ampToY (within one pixel); degenerate dims collapse the same way.
// dimensions collapse the same way the forward map does: velToX pins to box.left (velocity 0),
// ampToY pins to box.top (amp 1).
VelocityPoint p; VelocityPoint p;
const int w = std::max(0, box.width); const int w = std::max(0, box.width);
const int h = std::max(0, box.height); 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 // velocity_curve.h — velocity->amp transfer curve. eval(velocity) is called once per note-on
// SWELL/LICE, NO vendor/ includes at the boundary. The mirror of envelope_edit / card_drag: the // in Voice::start(), never per frame. Editor hit-test/inverse-map take an explicit pixel Box
// eval + the clamp/order/inverse-map arithmetic live here, unit-tested outside the DAW; the future // rather than a Rect: this module sits below sampler_core in the link graph and must not gain
// editor shell (reasampler_editor.cpp, S-VIEW-10) draws the box + node handles and feeds each move's // a transitive dependency on editor-layout types.
// 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.
#pragma once #pragma once
#include <cstdint> #include <cstdint>
#include <vector> #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 { namespace reasampler::instrument::engine {
// The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into. // 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 kAmpMin = 0.0;
inline constexpr double kAmpMax = 1.0; inline constexpr double kAmpMax = 1.0;
// One control point: a (velocity, amp) knot the curve passes through. Both fields are box-clamped // A raw-constructed point is NOT auto-clamped (the mutators own that invariant) — build curves
// by the mutators; a raw-constructed point is NOT auto-clamped (the mutators own the invariant), so // through the named constructors / addPoint rather than pushing raw points.
// build curves through the named constructors / addPoint rather than pushing raw points.
struct VelocityPoint { struct VelocityPoint {
double velocity = 0.0; // X, [0,127] double velocity = 0.0; // X, [0,127]
double amp = 0.0; // Y, [0,1] double amp = 0.0; // Y, [0,1]
}; };
// The pick radius (px) around a node's drawn point for the editor hit-test. Mirrors // Pick radius (px) around a node's drawn point for the editor hit-test.
// envelope_edit::kNodeGrabRadius / waveform_view::kMarkerGrabWidth.
inline constexpr int kCurveNodeGrabRadius = 6; inline constexpr int kCurveNodeGrabRadius = 6;
// A velocity->amp transfer curve: an X-ORDERED list of control points spanning [0,127], evaluated by // An X-ordered list of control points spanning [0,127], evaluated by a monotone cubic Hermite
// a MONOTONE cubic Hermite spline (FritschCarlson slope limiting) through the knots — a genuine // spline (Fritsch-Carlson slope limiting) — a genuine curve, not a polyline, that provably never
// curved response (Daniel 2026-07-27: "straight lines sound like shit"), not a polyline. Each // overshoots a segment's amp range. For collinear knots the tangents reduce to the secant slope,
// velocity still maps to exactly one amp: the interpolant is single-valued and provably stays within // so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints (velocity
// each segment's amp range, so the curve never overshoots below 0 or above 1. For COLLINEAR knots the // 0 and 127) are load-bearing: they keep eval total over the domain and are never deletable.
// 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.
class VelocityCurve { class VelocityCurve {
public: 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(); 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(); static VelocityCurve linear();
// Rebuild a curve from a deserialized point list, REPAIRING the invariant defensively (the // Rebuilds from a deserialized point list, repairing the invariant defensively: box-clamps
// deserialization seam, sample_map's zones-payload v7). Each point is box-clamped; the list is // each point, stable-sorts by velocity, forces both endpoints present (synthesized if
// stable-sorted by velocity (X-ordered); endpoints at velocity 0 and 127 are forced present // missing), falls back to flat() if fewer than 2 usable points remain. A corrupt/truncated
// (an absent endpoint is synthesized at the nearest interior amp, or unity for an empty list). // blob yields a well-formed curve, never an invariant-violating one.
// 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.
static VelocityCurve fromPoints(std::vector<VelocityPoint> pts); 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_; } const std::vector<VelocityPoint>& points() const { return points_; }
std::size_t size() const { return points_.size(); } 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, // Degenerate cases (shouldn't occur post-construction): empty curve returns kAmpMax; a
// so an out-of-range note (shouldn't occur) reads the nearest endpoint. Between two adjacent // one-point curve returns that point's amp.
// 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.
double eval(double velocity) const; double eval(double velocity) const;
// --- Editing (for the S-VIEW-10 editor UI) -------------------------------------------------- // Inserted at a velocity duplicating an existing point lands immediately after it, so a
// Insert a new control point, box-clamped, keeping the list X-ordered by velocity. Returns the // subsequent move can separate them. Returns the inserted index.
// 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).
std::size_t addPoint(double velocity, double amp); std::size_t addPoint(double velocity, double amp);
// Move point `index` to (velocity, amp), box-clamped AND X-clamped between its immediate // Box-clamped and X-clamped between immediate neighbours (monotonic-X grammar). The two
// neighbours so it cannot cross them (monotonic-X grammar). The two ENDPOINTS are pinned in X // endpoints are pinned in X (only their amp moves); out-of-range index is a no-op.
// (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.
VelocityPoint movePoint(std::size_t index, double velocity, double amp); 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 // Endpoints (index 0 and last) are not deletable; that or an out-of-range index is a no-op
// to remove either, or an out-of-range index, is a no-op returning false. Returns true iff a // returning false.
// point was removed.
bool deletePoint(std::size_t index); bool deletePoint(std::size_t index);
// --- Editor hit-test + inverse map (mirror of envelope_edit) -------------------------------- // The drawn box, in pixels: X = velocity across the width, Y = amp UP the height (amp 1 at
// The drawn box, in pixels: origin (boxLeft, boxTop), `boxWidth` px wide, `boxHeight` px tall. // top). Passed explicitly rather than a Rect — see header preamble.
// 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.
struct Box { struct Box {
int left = 0; int left = 0;
int top = 0; int top = 0;
@@ -128,43 +73,32 @@ public:
int height = 0; int height = 0;
}; };
// Which control point a grab at (x,y) lands on, given the drawn `box`. Returns the index of the // Index of the first point within the pick radius on both axes, or -1 for a miss. First-match
// first point within the pick radius in BOTH axes, or -1 for a miss. First-match in point order // in point order for determinism.
// for determinism (mirror of nodeAtPoint).
int pointAtPixel(const Box& box, int x, int y) const; 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 // The one point->pixel mapping, exposed so drawing and hit-testing can never drift apart.
// 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).
struct CurvePixel { struct CurvePixel {
int x = 0; int x = 0;
int y = 0; int y = 0;
}; };
static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p); static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p);
// The absolute pixel -> (velocity, amp) inverse (S-VIEW-10): where an empty-space click lands // Exact inverse of pixelFromPoint (within the one-pixel quantum) — where an empty-space click
// as a NEW control point, box-clamped. The exact inverse of pixelFromPoint's mapping (within // lands as a new point. Degenerate box: zero-width reads velocity 0; height <= 1 reads amp 1.
// 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.
static VelocityPoint pointFromPixel(const Box& box, int x, int y); 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` is the curve as of mouse-down (shell snapshots it so the delta is absolute).
// (`grabCurve` — the shell snapshots it on mouse-down so the delta is absolute) and the box. // Maps the pixel delta to velocity/amp over the box, then applies movePoint's clamp. Zero
// Maps the pixel delta to a (velocity, amp) delta over the box, then applies movePoint's clamp // width/height box or out-of-range index returns grabCurve unchanged.
// (box + neighbour X + endpoint X-pin). A zero-width/height box or out-of-range index returns
// `grabCurve` unchanged. Pure — mirror of resolveNodeDrag.
static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index, static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
const Box& box, int dxPixels, int dyPixels); 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; bool equals(const VelocityCurve& other, double eps = 1e-9) const;
private: private:
// Points are always X-ordered with an endpoint at 0 and 127. Constructed only through the named // Always X-ordered with an endpoint at 0 and 127; constructors + deserialize establish the
// constructors + deserialize (see sample_map), which establish that invariant; the mutators // invariant, mutators preserve it.
// preserve it.
std::vector<VelocityPoint> points_; std::vector<VelocityPoint> points_;
}; };
+71 -121
View File
@@ -1,122 +1,88 @@
#pragma once #pragma once
// zone_params.h — the per-zone play-parameter VALUE STRUCTS + per-instance mode enums the // zone_params.h — per-zone play-parameter value structs + per-instance mode enums shared by
// sampler engine, the sample_map resolution layer, the ComponentState codec, and the editor // the engine, sample_map, the ComponentState codec, and the editor. Split out of sampler_core.h
// all share (Q-W2v header split, T4-14/T4-17). Split out of sampler_core.h so a UI or codec // so a UI/codec TU reading a param struct doesn't recompile when a Voice/VoiceEngine member
// TU that reads a param struct no longer recompiles when a Voice/VoiceEngine member changes. // changes. The per-frame evaluator classes (AdsrEnvelope/TriggerEnvelope/PitchEnvelope) and the
// PURE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes — standard library + peaks only. // engine (Keymap/Voice/VoiceEngine) stay in sampler_core.h.
// The per-frame EVALUATOR classes (AdsrEnvelope / TriggerEnvelope / PitchEnvelope) and the
// engine (Keymap / Voice / VoiceEngine) stay in sampler_core.h.
#include <cstdint> #include <cstdint>
#include <vector> #include <vector>
#include "core/audio/peaks.h" // AudioSample (float) #include "core/audio/peaks.h"
namespace reasampler { 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; using audio::AudioSample;
// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7 // Decode-side downmix policy (see root CLAUDE.md — the output bus itself is permanently
// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders // stereo; this only picks mono-downmix vs dual-mono at decode). Never written to the bank.
// per-channel. A PERFORMANCE choice the instrument owns (component state), never written
// to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain
// value so the shell (bus negotiation, state) and the engine share one spelling; the core
// itself never branches on it — the mode only picks which render overload the shell drives.
enum class ChannelMode { Mono, Stereo }; enum class ChannelMode { Mono, Stereo };
// The instrument's per-instance VOICE MODE (Phase S voice redesign). POLY is today's // POLY is the fixed-pool engine with bounded stealing; MONO is a single voice with last-note
// polyphonic engine (fixed pool + 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
// priority over a held-note stack (classic mono synth: a new note takes the voice over; the // the most-recent still-held one). Never a bank fact. Default Poly.
// release of the top note falls back to the most-recent still-held note). A PERFORMANCE
// choice the instrument owns (component state), never a bank fact. Default Poly preserves
// current behavior.
enum class VoiceMode { Poly, Mono }; enum class VoiceMode { Poly, Mono };
// How a MONO takeover treats the envelopes (Phase S — Daniel: explicitly toggleable). // How a MONO takeover treats the envelopes. RETRIGGER restarts amp/pitch envelopes on every new
// RETRIGGER restarts the amplitude (and pitch) envelope on every new mono note. LEGATO keeps // mono note. LEGATO keeps the envelope running across a takeover (pitch moves without a
// the envelope running when a note is taken over while another is held — pitch moves without // re-attack) but only for a SAME-SAMPLE takeover — one read head can't glide between two PCM
// a re-attack (and the fallback on top-note release glides back the same way). Legato applies // streams, so crossing into a different sample always restarts the voice. Meaningless in Poly.
// only to a SAME-SAMPLE takeover: crossing into a zone playing a different sample restarts
// the voice (one read head cannot glide between two PCM streams; a re-attack on a sample
// change is the deterministic, documented fallback). Meaningless in Poly. Default Retrigger.
enum class MonoTrigger { Retrigger, Legato }; enum class MonoTrigger { Retrigger, Legato };
// The user-parameterized polyphony bound (Phase S): a per-instance persisted voice count. // Shared range so the engine, the component-state codec, and the editor control can't drift.
// One spelling shared by the engine, the component-state (de)serializer, and the editor's
// control so the range can never drift apart. Default 16 == the pre-Phase-S fixed pool.
inline constexpr int kMinVoiceCount = 1; inline constexpr int kMinVoiceCount = 1;
inline constexpr int kMaxVoiceCount = 32; inline constexpr int kMaxVoiceCount = 32;
inline constexpr int kDefaultVoiceCount = 16; inline constexpr int kDefaultVoiceCount = 16;
// --------------------------------------------------------------------------- // AHDSR amplitude envelope. holdFrames == 0 is exactly the pre-hold-stage ADSR (back-compat).
// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because
// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching
// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower
// with the rest of the engine machinery; only the value structs need to precede SampleData.
// ---------------------------------------------------------------------------
// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack
// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below.
struct AdsrParams { struct AdsrParams {
std::int64_t attackFrames = 0; 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; std::int64_t decayFrames = 0;
double sustainLevel = 1.0; // 0..1 double sustainLevel = 1.0; // 0..1
std::int64_t releaseFrames = 0; std::int64_t releaseFrames = 0;
}; };
// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's // GATE = classic held note (AHDSR + sustain loop + note-off release). TRIGGER = one-shot:
// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop, // note-off-immune, no sustain loop, plays a % of sample length shaped by fade-in/out. Both
// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone // honor the start point. Per-zone; default Gate so an instrument with no params set plays
// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before. // exactly as before.
enum class PlayMode { Gate, Trigger }; enum class PlayMode { Gate, Trigger };
// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span // Playback covers [startFrame, playEnd), playEnd = startFrame +
// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)), // round(lengthFraction*(frames - startFrame)). Amplitude ramps 0->1 over fadeInFrames at the
// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over // head and 1->0 over fadeOutFrames anchored to playEnd; unity between. Fades clamp so
// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play // fadeIn + fadeOut <= play length. The voice frees when the head reaches playEnd.
// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger.
struct TriggerParams { struct TriggerParams {
double lengthFraction = 1.0; // (0,1] of the post-start span to play 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 fadeInFrames = 0;
std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd std::int64_t fadeOutFrames = 0;
}; };
// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default // EQUAL_POWER (constant-power sin/cos) is the click-free default for Trigger's ramps; LINEAR is
// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool) // the build-time residual.
// so a third curve can join without a signature change.
enum class FadeCurve { EqualPower, Linear }; 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; inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration // VARISPEED: readPos_ += ratio_, pitch and duration coupled (an octave up plays half as long).
// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances // PRESERVE: the read advances at the source rate while a PitchShifter transposes the output
// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length). // (an octave up keeps its length).
enum class PitchEngine { Varispeed, Preserve }; enum class PitchEngine { Varispeed, Preserve };
// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching" // Product default is Preserve, but applied at the state boundary (sample_map deserialize /
// directive). ONE constant to flip if Varispeed should be the default instead. This is the // editor zone-creation) for new/absent zones, NOT here: ZonePlayParams.pitchEngine itself
// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's // defaults to Varispeed so "no params == the bare engine" holds for the core's own regression
// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core // tests (an octave up still halves duration with no params set).
// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16
// engine" holds for the core's own regression tests (an octave up still halves duration in the
// bare engine); the Preserve product default is layered on above at (de)serialization.
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve; inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds // OLA window for the Preserve PitchShifter, in ms at the voice's sample rate; larger = smoother
// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger = // on big transpositions. Onset latency is zero — start() primes the ring with the first window
// smoother on big transpositions. Onset latency is ZERO: start() primes the ring with the first // of real source, so output frame 0 is source frame 0 regardless of window size.
// window of real source, so output frame 0 IS source frame 0 regardless of window size (GA2 fix).
// One knob, resolved at voice allocation.
inline constexpr double kPreserveWindowMs = 50.0; inline constexpr double kPreserveWindowMs = 50.0;
// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always // AD pitch-modulation envelope, off by default (enabled=false -> offset always 0 -> bit-identical
// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to // to the un-modulated engine). At note-on the offset rises to peakSemitones over attackFrames,
// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack // then falls to 0 over decayFrames; a zero attack gives a pure percussive pitch drop.
// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-).
struct PitchEnvParams { struct PitchEnvParams {
bool enabled = false; bool enabled = false;
std::int64_t attackFrames = 0; std::int64_t attackFrames = 0;
@@ -124,69 +90,53 @@ struct PitchEnvParams {
double peakSemitones = 0.0; // signed depth at the peak 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 // Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16 // Varispeed, pitch envelope off) — core regression tests rely on this; the Preserve product
// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope // default is layered on at (de)serialization, see kDefaultPitchEngine.
// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the
// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one
// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine.
struct ZonePlayParams { struct ZonePlayParams {
PlayMode playMode = PlayMode::Gate; PlayMode playMode = PlayMode::Gate;
AdsrParams adsr; // Gate: the AHDSR envelope AdsrParams adsr;
TriggerParams trigger; // Trigger: %-length + fades TriggerParams trigger;
PitchEngine pitchEngine = PitchEngine::Varispeed; PitchEngine pitchEngine = PitchEngine::Varispeed;
PitchEnvParams pitchEnv; // AD pitch modulation, off by default PitchEnvParams pitchEnv;
}; };
// --------------------------------------------------------------------------- // Sample data the core plays: plain decoded PCM + the bank intrinsics that govern playback.
// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that // The shell decodes the on-disk WAV and fills this; the core never touches a file.
// 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) // [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop"
// is the "no sustain loop" marker — a held note past the sample end goes silent // marker — a held note past the sample end goes silent rather than looping a zero span.
// rather than looping a zero span. absent-loop is modeled by leaving hasLoop false.
struct SampleLoop { struct SampleLoop {
bool hasLoop = false; bool hasLoop = false;
std::int64_t start = 0; // first looped frame (inclusive) std::int64_t start = 0;
std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end std::int64_t end = 0;
}; };
// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is // Deinterleaved per-channel: `frames` is channel 0 (always present), `framesR` is channel 1
// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample). // (present only for a stereo sample). Stereo iff `framesR` is non-empty and the same length as
// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise // `frames`; a mismatched length is treated as absent (mono) rather than half-playing. Both
// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both // channels share `readPos_`/`rootNote`/`loop`, so repitch/loop stay per-frame identical across
// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical // channels. `rootNote` is the MIDI note the file was recorded at — plays at unity ratio there.
// across channels; only the sampled value differs. `rootNote` is the MIDI note the file was
// recorded at (S2 intrinsic) — the pitch that plays back at unity ratio.
struct SampleData { struct SampleData {
std::vector<AudioSample> frames; // channel 0 PCM (mono, or L of a stereo sample) std::vector<AudioSample> frames;
std::vector<AudioSample> framesR; // channel 1 PCM (R); EMPTY for a mono sample std::vector<AudioSample> framesR; // empty for a mono sample
int sampleRate = 0; // frames per second (for reference; ratio is int sampleRate = 0; // ratio math is note-relative, so rate cancels for
// note-relative, so rate cancels for repitch). // repitch; still, 0 is invalid — every consumer must
// 0 is explicitly invalid — every consumer must
// receive a real rate before use. // receive a real rate before use.
int rootNote = 60; // MIDI note recorded at (plays at unity here) int rootNote = 60;
SampleLoop loop; // sustain loop, if any SampleLoop loop;
// 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 // Frame offset a voice starts playback at; frame 0 default is the pre-existing behavior.
// an instrument-side per-zone override (the "start point" marker); S15 builds on it // Clamped into [0, frames) at note-on — a start >= sample length is a no-op (starts at 0).
// (both play modes carry a modifiable start). Clamped into [0, frames) at note-on:
// a start >= the sample length is a no-op (voice starts at 0), never out of bounds.
std::int64_t startFrame = 0; 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; ZonePlayParams play;
// 2 iff a matching-length second channel exists; else 1. A framesR of a different // A framesR of a different length than frames is treated as absent — a malformed pair
// length than frames is treated as absent (mono) — a malformed pair never half-plays. // never half-plays.
int channelCount() const { int channelCount() const {
return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1; return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1;
} }
}; };
} // namespace reasampler } // namespace reasampler
+7 -16
View File
@@ -10,20 +10,14 @@
namespace reasampler::instrument::map { namespace reasampler::instrument::map {
std::int64_t parseBankGeneration(const std::string& raw) { std::int64_t parseBankGeneration(const std::string& raw) {
// Whole-string, non-negative decimal parse WITHOUT exceptions or locale // Whole-string, non-negative decimal parse, no exceptions/locale surprises (core/wire's
// surprises — the shared core/wire accumulate (Q-W1, T2-01b). A leading // guarded accumulate). Leading sign, non-digit, empty, or int64 overflow -> absent (0).
// '+' / '-', any non-digit, an empty string, or overflow past int64 max all
// reject to the absent default (0); the guarded accumulate means a
// pathologically long digit run can never wrap into a bogus small value.
std::int64_t value = 0; std::int64_t value = 0;
if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent; if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent;
return value; return value;
} }
std::string formatBankGeneration(std::int64_t generation) { 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); return std::to_string(generation);
} }
@@ -37,23 +31,20 @@ AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& re
AssignConsumeDecision d; AssignConsumeDecision d;
d.consumedGeneration = lastConsumed; // default: nothing changes 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) return d;
if (request->generation <= lastConsumed) 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 // Rule 2: new but not our target -> don't advance the marker, stay eligible.
// advance the marker (stay eligible if focus later lands here). No thundering herd.
if (!isFocusedTarget) return d; if (!isFocusedTarget) return d;
// The request is new AND we are the target: it will be consumed-as-seen either way, so // New and our target: consumed-as-seen either way.
// advance the marker to its generation so it is never re-evaluated.
d.consumedGeneration = request->generation; d.consumedGeneration = request->generation;
// Rule 3: unresolvable (bankId, sampleId) -> DROP silently (reader requirement): marker // Rule 3: unresolvable -> drop silently, marker already advanced above.
// advanced above, but no selection change.
if (!resolves) return d; if (!resolves) return d;
// Rule 4: new, target, resolvable -> apply the selection. // Rule 4: new, target, resolvable -> apply.
d.apply = true; d.apply = true;
d.bankId = request->bankId; d.bankId = request->bankId;
d.sampleId = request->sampleId; d.sampleId = request->sampleId;
+27 -74
View File
@@ -1,21 +1,11 @@
#pragma once #pragma once
// bank_sync — PURE decision logic for the S9 bank-generation change-detection and the // bank_sync — decision logic for bank-generation change-detection and the instrument-side
// S8 instrument-side assignment-request consume. NO VST3, NO REAPER, NO SWELL, NO // assignment-request consume. The instrument polls two "reasampler" ext-state keys off the
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the mirror of // audio thread: the bank-generation counter (has the bank changed?) and the assignment
// sample_map / bridge_marshal splitting the fiddly, testable arithmetic out of a // request (should I switch to a just-ingested sample?). The shell reads the raw strings and
// host-facing shell. // owns cadence (a UI-thread timer, never `process`) + side effects (reloadInstrument,
// // setSelectedSampleId); this module owns only the yes/no decisions, so they're provable
// WHY IT EXISTS (S9/S8 reader seams). The instrument polls two "reasampler" ext-state // without a host. assignment_request.h owns the wire format; this owns the consume decision.
// 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.
#include <cstdint> #include <cstdint>
#include <optional> #include <optional>
@@ -27,41 +17,26 @@ namespace reasampler::instrument::map {
using wire::AssignmentRequest; using wire::AssignmentRequest;
// The S9 bank-generation "generation 0 = never stamped" default. A project saved before // Default for a project with no bank_generation key yet (pre-existing project); the first
// S9 shipped carries no bank_generation key; the bridge read yields an absent/empty value // real bump (>= 1) then reads as a change against this.
// 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).
inline constexpr std::int64_t kBankGenerationAbsent = 0; inline constexpr std::int64_t kBankGenerationAbsent = 0;
// Parse the raw bank-generation ext-state value the bridge read. The writer stamps a // Absent / empty / malformed / negative / overflowing all yield kBankGenerationAbsent (0),
// non-negative decimal integer (formatBankGeneration). Absent / empty / malformed / negative // never a crash or spurious reload. Whole-string parse: trailing garbage rejects the value,
// / overflowing all yield kBankGenerationAbsent (0) — the reader treats any unreadable stamp // so a torn/partial write is ignored until the next clean poll.
// 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).
std::int64_t parseBankGeneration(const std::string& raw); std::int64_t parseBankGeneration(const std::string& raw);
// Format a bank-generation counter for the ext-state stamp. The inverse of // Inverse of parseBankGeneration: plain decimal, no sign, no padding — byte-stable across
// parseBankGeneration for a non-negative value: a plain decimal, no sign, no padding, so // writes of the same value.
// the stamp is byte-stable across writes of the same value.
std::string formatBankGeneration(std::int64_t generation); std::string formatBankGeneration(std::int64_t generation);
// Has the bank generation changed since the reader last saw `seen`? True when `current` // True when `current` differs from `seen` (not just increases — a project switch/reload can
// differs from `seen` — the reader then triggers a reload. Any difference counts (not just // legitimately lower the value, and the reader should still re-read the bank).
// 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).
bool bankGenerationChanged(std::int64_t seen, std::int64_t current); bool bankGenerationChanged(std::int64_t seen, std::int64_t current);
// The verdict of the S8 assignment-request consume decision (below). A pure value the // Verdict of the assignment-request consume decision below. apply and consumedGeneration
// processor shell acts on: apply the selection (or not) and advance the consumed marker // advancing are NOT the same event — a request naming an unresolvable sample is dropped
// (or not). Distinct booleans because the two are NOT the same event — a request may be // (consumed-as-seen) without applying, so the shell doesn't re-evaluate it every poll.
// 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.
struct AssignConsumeDecision { struct AssignConsumeDecision {
bool apply = false; // set this instance's selection to (bankId, sampleId) + reload bool apply = false; // set this instance's selection to (bankId, sampleId) + reload
std::string bankId; // the request's bank (valid only when apply) 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) 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 // Rules, in order: (1) no request or generation <= lastConsumed -> no-op. (2) new but not
// is absent / malformed — nothing pending). // the target -> no-op, marker unchanged (stays eligible later). (3) new, target, but doesn't
// `lastConsumed` — the generation this instance last consumed (persisted in component // resolve -> drop silently, marker still advances (consumed-as-seen, never re-evaluated).
// state so a re-open does not re-apply a request the user already got, // (4) new, target, resolves -> apply + advance marker.
// 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.
AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request, AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request,
std::int64_t lastConsumed, bool resolves, std::int64_t lastConsumed, bool resolves,
bool isFocusedTarget); bool isFocusedTarget);
+2 -3
View File
@@ -6,9 +6,8 @@ namespace reasampler::instrument::map {
std::optional<std::string> decodeGetProjExtState(int apiReturn, std::optional<std::string> decodeGetProjExtState(int apiReturn,
const std::string& buffer) { const std::string& buffer) {
// REAPER returns the length of the stored value; 0 means the key is absent. Guard // 0 return means absent; guard the buffer too so a reused dirty buffer can't
// both the return AND the buffer: a caller that reused a dirty buffer must not // surface stale bytes as a value.
// surface stale bytes as a value when the API reported nothing.
if (apiReturn <= 0 || buffer.empty()) return std::nullopt; if (apiReturn <= 0 || buffer.empty()) return std::nullopt;
return buffer; return buffer;
} }
+6 -27
View File
@@ -1,21 +1,8 @@
// bridge_marshal.hPURE marshalling helper for the REAPER VST-host bridge read. // bridge_marshal — pure GetProjExtState result decode 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.
// //
// Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h: // Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
// int GetProjExtState (ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz); // int GetProjExtState(ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz)
// -- returns the length written (0 when the key is absent). // returns the length written (0 when the key is absent).
#pragma once #pragma once
@@ -26,18 +13,10 @@
namespace reasampler::instrument::map { namespace reasampler::instrument::map {
// Interpret a GetProjExtState result: the int return value (bytes the API reports for // Value only when the API reported non-empty AND the buffer is non-empty — REAPER
// the key) and the buffer it filled. Returns the value only when the API reported a // writes 0 and leaves the buffer untouched for an absent key, so stale buffer
// non-empty result AND the buffer is non-empty — REAPER writes 0 and leaves the buffer // contents must never read as a hit.
// 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).
std::optional<std::string> decodeGetProjExtState(int apiReturn, std::optional<std::string> decodeGetProjExtState(int apiReturn,
const std::string& buffer); 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 } // 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 — the ComponentState envelope + zones-payload binary codec. See
// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7) // 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 + // Every wire format is FROZEN — byte-identical across revisions.
// the pure sample_map value types + core/wire's LE byte codec (T4-20) + velocity_curve
// + master_gain. Every wire format is FROZEN — byte-identical to the pre-split writer.
#include "core/instrument/map/component_state_io.h" #include "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. // 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); } 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, // Append the zones payload — the shared body of the performance blob and the component
// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion // blob, so both write zones identically. Always emits the CURRENT payload version (marker +
// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail // version + extended records: loop/start tail + full play-params tail in SECONDS); the
// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes // marker precedes the zone count so any reader can detect record shape independent of the
// the zone count so any reader can detect the record shape independently of the envelope version // envelope version (see sample_map.h).
// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip
// through EITHER envelope with no envelope bump.
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) { void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
putLE(out, kZonesFormatMarker); putLE(out, kZonesFormatMarker);
putLE(out, kZonesPayloadVersion); putLE(out, kZonesPayloadVersion);
@@ -49,7 +45,7 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
putLE(out, putLE(out,
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride))); 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); out.push_back(z.loopOverride ? 1 : 0);
if (z.loopOverride) { if (z.loopOverride) {
out.push_back(z.loopOverride->hasLoop ? 1 : 0); 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); out.push_back(z.startPoint ? 1 : 0);
if (z.startPoint) putLE(out, asU64(*z.startPoint)); if (z.startPoint) putLE(out, asU64(*z.startPoint));
// S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine). // Play params (PAYLOAD v5): always present. Wall-clock times are SECONDS (doubles);
// Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames / // trigger %-length + fades stay source frames/fraction. Order matches the header's
// fraction. Order matches the header's v5 record spec. // v5 record spec.
const ZonePlaySeconds& pp = z.play; const ZonePlaySeconds& pp = z.play;
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0); out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds 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.decaySeconds));
putLE(out, doubleToBits(pp.adsr.sustainLevel)); putLE(out, doubleToBits(pp.adsr.sustainLevel));
putLE(out, doubleToBits(pp.adsr.releaseSeconds)); 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)); putLE(out, doubleToBits(z.keyTrack));
// PAYLOAD v7 (S-VIEW-9): the per-zone velocity->amp transfer curve, appended last. 4-byte LE // PAYLOAD v7: 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). // control-point count, then per point velocity + amp as doubles (endpoints included).
const std::vector<VelocityPoint>& pts = z.velocityCurve.points(); const std::vector<VelocityPoint>& pts = z.velocityCurve.points();
putLE(out, static_cast<std::uint32_t>(pts.size())); putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& p : pts) { 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 // Read a zones payload from `r` into `map`. Shared by the performance parse and the
// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the // component parse. Detects the format marker: present -> PAYLOAD v2+ (extended records with
// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail — // the loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (no tail — clean
// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read // back-compat lift, overrides default absent). A truncated mid-zone read keeps the zones
// keeps the zones that parsed cleanly and drops the rest. // that parsed cleanly and drops the rest.
// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock frame // `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock
// counts (holdFrames, pitchEnv A/D) to the seconds domain at the read boundary: seconds = frames / // frame counts (holdFrames, pitchEnv A/D) to seconds at the read boundary: seconds = frames
// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed. // / projectRate. Must be > 0 (callers guard). v5+ blobs carry seconds directly; no rate needed.
void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { 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) std::uint32_t pv = 0; // payload version (0 = v1, no marker)
if (r.peekU32() == kZonesFormatMarker) { if (r.peekU32() == kZonesFormatMarker) {
r.u32(); // consume the marker r.u32(); // consume the marker
pv = r.u32(); // payload version pv = r.u32(); // payload version
extended = (pv >= 2); // v2+ carries the loop/start tail 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 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 keyTrackTail = (pv >= 6); // v6+: per-zone keyTrack scalar
const bool curveTail = (pv >= 7); // v7+ (S-VIEW-9): per-zone velocity->amp curve, appended last const bool curveTail = (pv >= 7); // v7+: per-zone velocity->amp curve, appended last
const std::uint32_t count = r.u32(); const std::uint32_t count = r.u32();
for (std::uint32_t i = 0; i < count && r.ok; ++i) { 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 // z.play defaults to the product defaults (Gate + Preserve + tier-0 AHDSR seconds).
// v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1). // A v1/v2 payload (no play tail) lifts every zone to those defaults.
PerformanceZone z; PerformanceZone z;
const std::uint32_t idLen = r.u32(); const std::uint32_t idLen = r.u32();
z.sampleId = r.str(idLen); z.sampleId = r.str(idLen);
@@ -135,10 +131,10 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
if (hasStart) z.startPoint = r.i64(); if (hasStart) z.startPoint = r.i64();
} }
if (legacyV3Play) { if (legacyV3Play) {
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D) // LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv
// were written as frames -> divide by the project sample rate (threaded in as `projectRate`) // A/D) were written as frames -> divide by `projectRate` to reach seconds.
// to reach the seconds domain. Trigger %-length + fades are source-timeline, read as-is. // Trigger %-length + fades are source-timeline, read as-is. A/D/S/R are ABSENT
// A/D/S/R are ABSENT in v3 -> leave the seconds defaults on z.play.adsr. // in v3 -> leave the seconds defaults on z.play.adsr.
assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift"); 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 const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate; z.play.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.sustainLevel = bitsToDouble(r.u64());
z.play.adsr.releaseSeconds = 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 v6: key-tracking scalar, appended after the v5 play tail. A pre-v6 payload
// payload (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an // (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. // already-saved instance repitches BIT-IDENTICALLY.
if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64()); if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64());
// PAYLOAD v7 (S-VIEW-9): the velocity->amp transfer curve, appended after the v6 keyTrack. A // PAYLOAD v7: velocity->amp transfer curve, appended after the v6 keyTrack. A pre-v7
// pre-v7 payload (no field) leaves the PerformanceZone default (VelocityCurve::flat() — R10-F1 // payload (no field) leaves the PerformanceZone default (VelocityCurve::flat(),
// Option A, flat y=1), the deliberate NON-back-compat behavior change for already-saved zones. // Daniel-approved), the deliberate NON-back-compat behavior change for already-saved
// fromPoints repairs the X-order/endpoint invariant defensively; a truncated read (r.ok flips // zones. fromPoints repairs the X-order/endpoint invariant defensively; a truncated
// false mid-curve) leaves the flat default and the mid-zone break below drops the rest. // read leaves the flat default and the mid-zone break below drops the rest.
if (curveTail) { if (curveTail) {
const std::uint32_t ptCount = r.u32(); const std::uint32_t ptCount = r.u32();
std::vector<VelocityPoint> pts; std::vector<VelocityPoint> pts;
// Bound the reserve to what the blob can actually hold (16 bytes/point) so a corrupt huge // 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 — the loop still // count can't trigger a giant allocation before the bounded reads fail.
// stops on r.ok, this only caps the speculative reserve.
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0; 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)); pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) { 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, PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
double projectRate) { double projectRate) {
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. // projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
// For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3 // v5+. The assert inside readZonesPayload fires if a v3 blob has an invalid rate.
// blob is encountered with an invalid rate — the calller guarantees a real rate before use.
PerformanceMap map; PerformanceMap map;
ByteReader r(bytes); ByteReader r(bytes);
const std::uint32_t version = r.u32(); const std::uint32_t version = r.u32();
if (!r.ok) return map; // no version tag -> empty 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. // no length prefix). Lift it to one full-keyboard zone playing that id.
if (version == kSelectionStateVersion) { if (version == kSelectionStateVersion) {
const std::string id = deserializeSelection(bytes); const std::string id = deserializeSelection(bytes);
@@ -238,33 +232,30 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
return map; return map;
} }
// --- Combined component state (v3, S10) -------------------------------------- // --- Combined component state --------------------------------------
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) { std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
std::vector<std::uint8_t> out; std::vector<std::uint8_t> out;
putLE(out, kComponentStateVersion); 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); out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0);
// v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE // v5 addition: last-consumed assignment generation, 8-byte LE two's-complement, follows
// two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that // the mode byte so a v4 reader stopping there is a strict prefix (see the v4 lift below).
// stops at the mode byte is a strict prefix (see the v4 lift below).
putLE(out, asU64(state.lastConsumedAssignGeneration)); putLE(out, asU64(state.lastConsumedAssignGeneration));
// v6 envelope addition (S-VIEW-4): the preview-trigger velocity, 1 byte (MIDI 1..127). Follows // v6 addition: preview-trigger velocity, 1 byte (MIDI 1..127) — a v5 blob is a strict
// the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift). // prefix up to this byte (see the v5 lift).
out.push_back(state.previewVelocity); out.push_back(state.previewVelocity);
// v7 envelope addition (Phase S voice system): voice count (1..32), voice mode (0 = Poly, // v7 addition: voice count (1..32), voice mode (0 Poly/1 Mono), mono trigger
// 1 = Mono), mono trigger (0 = Retrigger, 1 = Legato) — one byte each, following the // (0 Retrigger/1 Legato) — one byte each, a v6 blob is a strict prefix up to here.
// velocity byte so a v6 blob is a strict prefix up to here (see the v6 lift).
const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount
: state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount : state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount
: state.voiceCount; : state.voiceCount;
out.push_back(static_cast<std::uint8_t>(vc)); out.push_back(static_cast<std::uint8_t>(vc));
out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0); out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0);
out.push_back(state.monoTrigger == MonoTrigger::Legato ? 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 // v8 addition: post-mixer LINEAR gain as a double (bit-cast to u64 LE) — a v7 blob is a
// (bit-cast to u64 LE), following the voice bytes so a v7 blob is a strict prefix up to // strict prefix up to here. The WRITER never emits out-of-range: non-finite/negative
// here (see the v7 lift). The WRITER never emits an out-of-range value: non-finite or // falls back to unity; above the +24 dB cap clamps to the cap.
// negative falls back to unity; above the +24 dB cap clamps to the cap.
{ {
double g = state.masterGainLinear; double g = state.masterGainLinear;
const double maxLin = masterGainMaxLinear(); const double maxLin = masterGainMaxLinear();
@@ -272,16 +263,14 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
if (g > maxLin) g = maxLin; if (g > maxLin) g = maxLin;
putLE(out, doubleToBits(g)); putLE(out, doubleToBits(g));
} }
// v9 envelope addition (GA channel-mode auto-default): the channel-mode-EXPLICIT flag, // v9 addition: channel-mode-EXPLICIT flag, 1 byte — a v8 blob is a strict prefix up to
// 1 byte, following the gain double so a v8 blob is a strict prefix up to here (see the // here. 0 = implicit (shell may auto-default from the loaded capture's channel count);
// v8 lift). 0 = implicit (the shell may auto-default the mode from the loaded capture's // 1 = user deliberately toggled the mode (never fought).
// channel count); 1 = the user deliberately toggled the mode (never fought).
out.push_back(state.channelModeExplicit ? 1 : 0); out.push_back(state.channelModeExplicit ? 1 : 0);
// v10 envelope addition (pS self-contained playback): the instance-owned sample-refs // v10 addition: the instance-owned sample-refs table — a v9 blob is a strict prefix up
// table, following the explicit flag so a v9 blob is a strict prefix up to here (see // to here. Wire shape per kSelectionZonesRefsV10Version: entry count, then per entry id
// the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per // + path (length-prefixed), rootNote, loop (hasLoop + start/end, always written),
// entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always // channelCount, displayName (length-prefixed; display-only).
// written), channelCount, displayName (length-prefixed; display-only).
putLE(out, static_cast<std::uint32_t>(state.sampleRefs.size())); putLE(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
for (const SampleRefEntry& e : state.sampleRefs) { for (const SampleRefEntry& e : state.sampleRefs) {
putLE(out, static_cast<std::uint32_t>(e.sampleId.size())); 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, ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
double projectRate) { double projectRate) {
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. // projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
// For v5 and later blobs it is unused. See readZonesPayload for the guard. // v5+. See readZonesPayload for the guard.
ComponentState out; ComponentState out;
ByteReader r(bytes); ByteReader r(bytes);
const std::uint32_t version = r.u32(); 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. // 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 // * v1 (original single-selection: version 1 + id-to-end): restore {id, one
// zone} so the old pick survives as BOTH the selection and a one-zone map. // 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 // * v2 (zones-only): restore {"", zones} — that instance had zones but no separate
// single-capture selection. // single-capture selection.
if (version == kSelectionStateVersion) { if (version == kSelectionStateVersion) {
out.selectionId = deserializeSelection(bytes); out.selectionId = deserializeSelection(bytes);
@@ -337,20 +326,20 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
} }
if (version == kPerformanceStateVersion) { if (version == kPerformanceStateVersion) {
readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag 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 — // BACK-COMPAT: a v3 blob ({selection, zones}, no channel mode) restores as MONO — the id
// the id length + id + zones body starts right after the version tag (no mode byte). // length + id + zones body starts right after the version tag (no mode byte).
if (version == kSelectionZonesV3Version) { if (version == kSelectionZonesV3Version) {
const std::uint32_t idLen = r.u32(); const std::uint32_t idLen = r.u32();
out.selectionId = r.str(idLen); out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map, projectRate); 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): // BACK-COMPAT: a v4 blob ({mode, selection, zones}, no consumed marker): mode byte, then
// mode byte, then the id + zones body — no 8-byte marker. lastConsumedAssignGeneration // the id + zones body — no 8-byte marker. lastConsumedAssignGeneration defaults to 0, so
// defaults to 0, so a first assign still applies for a pre-marker instance. // a first assign still applies for a pre-marker instance.
if (version == kSelectionZonesModeV4Version) { if (version == kSelectionZonesModeV4Version) {
const std::uint8_t modeByte = r.u8(); const std::uint8_t modeByte = r.u8();
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) 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); out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map, projectRate); 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 // BACK-COMPAT: a v5 blob ({mode, marker, selection, zones}, no preview-velocity byte):
// byte): mode byte, then the 8-byte marker, then the id + zones body — no 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 // previewVelocity defaults to kPreviewVelocityDefault (construction default), so an
// pre-S-VIEW-4 instance restores at the mid default. // already-saved instance restores at the mid default.
if (version == kSelectionZonesModeMarkerV5Version) { if (version == kSelectionZonesModeMarkerV5Version) {
const std::uint8_t modeByte = r.u8(); const std::uint8_t modeByte = r.u8();
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) 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); out.selectionId = r.str(idLen);
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
readZonesPayload(r, out.map, projectRate); 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 && if (version != kComponentStateVersion &&
version != kSelectionZonesRefsV10Version && version != kSelectionZonesRefsV10Version &&
@@ -386,10 +375,9 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
return out; // unknown -> empty return out; // unknown -> empty
} }
// v6..v10 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker, // v6..v10 shared prefix: channel-mode byte, 8-byte consumed-assignment marker, 1-byte
// then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated // preview velocity, precede the v3 body. A non-{0,1} mode byte treats as mono
// as mono (conservative default) rather than rejected — a corrupt mode never silences the // (conservative default) rather than rejected — a corrupt mode never silences the instance.
// instance.
const std::uint8_t modeByte = r.u8(); const std::uint8_t modeByte = r.u8();
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds) if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono; 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) out.previewVelocity = (previewVel >= 1 && previewVel <= 127)
? previewVel ? previewVel
: kPreviewVelocityDefault; : kPreviewVelocityDefault;
// v7+ (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the // v7+: the three voice-system bytes. A v6 blob skips them — the construction defaults
// construction defaults {16, Poly, Retrigger} hold, reproducing pre-Phase-S behavior. // {16, Poly, Retrigger} hold, reproducing pre-voice-system behavior.
if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) { if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) {
const std::uint8_t vc = r.u8(); const std::uint8_t vc = r.u8();
const std::uint8_t vm = 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.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly;
out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger; out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
} }
// v8+ (FB1): the master-gain LINEAR double. A v7 blob (pre-FB1) skips it — the construction // v8+: the master-gain LINEAR double. A v7 blob skips it — the construction default
// default (unity) holds, reproducing pre-FB1 output exactly. A non-finite, negative, or // (unity) holds. A non-finite, negative, or above-cap value falls back to unity rather
// above-cap value (a corrupt blob) falls back to unity rather than silencing/blasting. // than silencing/blasting.
if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) { if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) {
const double g = bitsToDouble(r.u64()); const double g = bitsToDouble(r.u64());
if (!r.ok) return out; // truncated inside the gain double — out already carries 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 ? g
: 1.0; : 1.0;
} }
// v9 (GA): the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction // 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 // default (false = implicit) holds, so an already-saved instance's mode is treated as
// un-touched default and the shell may auto-default it from the loaded capture. // the untouched default and the shell may auto-default it from the loaded capture.
if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) { if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) {
const std::uint8_t explicitByte = r.u8(); const std::uint8_t explicitByte = r.u8();
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds) if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
out.channelModeExplicit = (explicitByte == 1); out.channelModeExplicit = (explicitByte == 1);
} }
// v10 (pS self-contained playback): the sample-refs table. A v9-or-older blob skips it — // v10: the sample-refs table. A v9-or-older blob skips it — the EMPTY-table default
// the EMPTY-table default holds, and the shell lifts the refs once via the bridge-resolve // holds, and the shell lifts the refs once via the bridge-resolve path (then re-saves
// path (then re-saves self-contained). A truncated mid-entry read keeps the entries that // self-contained). A truncated mid-entry read keeps the entries that parsed cleanly and
// parsed cleanly and drops the rest (the selection/zones behind it are unreadable anyway). // drops the rest (the selection/zones behind it are unreadable anyway).
if (version >= kSelectionZonesRefsV10Version) { if (version >= kSelectionZonesRefsV10Version) {
const std::uint32_t refCount = r.u32(); const std::uint32_t refCount = r.u32();
for (std::uint32_t i = 0; i < refCount && r.ok; ++i) { 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); e.sampleId = r.str(refIdLen);
const std::uint32_t pathLen = r.u32(); const std::uint32_t pathLen = r.u32();
e.ref.relativePath = r.str(pathLen); e.ref.relativePath = r.str(pathLen);
// Range fallbacks (the refs table is the ONLY copy on the play path, so a // 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 // corrupt field must degrade to the field's default, never poison playback. An
// the previewVelocity/voiceCount posture): an out-of-MIDI-range root falls back // out-of-MIDI-range root falls back to the middle-C default distill() uses; a
// to the middle-C default distill() uses; a negative channel count falls back // negative channel count falls back to 0 = unknown (auto-default then skips it).
// to 0 = unknown (the GA auto-default then skips it).
const std::int32_t root = r.i32(); const std::int32_t root = r.i32();
e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60; e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60;
e.ref.loop.hasLoop = (r.u8() != 0); 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; if (!r.ok) return out;
} }
// v11 (pS-usage): the minted instance guid. A v10-or-older blob skips it — the // v11: the minted instance guid. A v10-or-older blob skips it — the EMPTY default
// EMPTY default holds and the processor mints a fresh identity on first publish. // holds and the processor mints a fresh identity on first publish.
if (version >= kSelectionZonesRefsIdentityV11Version) { if (version >= kSelectionZonesRefsIdentityV11Version) {
const std::uint32_t guidLen = r.u32(); const std::uint32_t guidLen = r.u32();
out.instanceGuid = r.str(guidLen); out.instanceGuid = r.str(guidLen);
+188 -224
View File
@@ -1,20 +1,15 @@
#pragma once #pragma once
// component_state_io — the ComponentState ENVELOPE + zones-payload binary codec for the // 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 // ReaSampler 9000 instrument. Split out of sample_map so both artifacts can share it: the
// VST3, NO REAPER, NO SWELL, NO vendor/ includes — the same boundary sample_map keeps. // 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 // EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, zones
// in one quarter), and it is deliberately shared across BOTH artifacts: the instrument's // payload v1..v7) must be preserved exactly.
// processor reads/writes it at setState/getState, and the EXTENSION's instrument-drop
// path (core/wire/instrument_drop) serializes the same bytes into a transient .vstpreset
// so the payload and the instrument's reader can never drift. Housing it inside
// sample_map made the extension link the whole voice engine (sampler_core + pitch_shift)
// to serialize one preset blob; split out, both artifacts link the codec and only the
// VST links the engine. The codec's own links are velocity_curve + master_gain (wire
// value validation) — never the engine.
//
// EVERY wire format below is FROZEN (byte-identical to the pre-split writer); the full
// version ladders (envelope v1..v11, zones payload v1..v7) are preserved exactly.
#include <cstdint> #include <cstdint>
#include <string> #include <string>
@@ -26,108 +21,86 @@ namespace reasampler::instrument::map {
// --- Performance-map instance state (VST3 setState/getState) ----------------- // --- Performance-map instance state (VST3 setState/getState) -----------------
// //
// The performance map is the instrument's OWN state (D-B), serialized to the VST3 // The performance map is the instrument's OWN state, serialized to the VST3 component-state
// component-state IBStream — NOT written to the "reasampler" bank ext-state (the // IBStream — never written to the "reasampler" bank ext-state. Versioned binary, tolerant
// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of // of truncation/wrong-version (bounded reads, never throws across the host).
// truncation/wrong-version by design (bounded reads, never throws across the host).
// //
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the // Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
// ZONES PAYLOAD. // ZONES PAYLOAD.
// //
// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones // ZONES-PAYLOAD FORMAT VERSIONING is self-describing and envelope-independent: the payload
// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides) // carries its OWN version, so the per-zone record can grow without bumping the envelope
// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState // version. Zone-record extensions and envelope-field additions stay on independent axes
// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the // that can never collide on one version number.
// key composition property: the zone-record extension is versioned inside the map blob, not on // * v1 (original, no marker): 4-byte LE zone count, then per zone: 4-byte LE id length +
// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not // id bytes, 4-byte LE lowNote, 4-byte LE highNote, 1 byte hasRootOverride, 4-byte LE
// collide on a single version number. // rootOverride (iff hasRootOverride). A payload starting with a small u32 (zone count)
// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone: // is v1.
// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote, // * v2: 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone count can
// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride). // equal) + 4-byte LE payload version (== 2), then the v1 body PLUS, per zone record
// A payload starting with a small u32 (the zone count) is v1 — there is no marker. // after rootOverride: 1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE
// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone // loop.start + loop.end (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint
// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended // (int64). The marker lets the reader detect record shape independent of the envelope.
// to each zone record after rootOverride: // * v3 (LEGACY — exists in Daniel's beta projects): marker + version (== 3), v2 body PLUS
// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start, // a per-zone play-params tail (always present): 1 byte playMode (0 Gate/1 Trigger);
// 8-byte LE loop.end (both two's-complement int64); // 8-byte LE adsr.holdFrames (int64, FRAMES at 44.1k nominal); 8-byte LE
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64). // trigger.lengthFraction (double); 8-byte LE trigger.fadeInFrames + fadeOutFrames
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads // (int64); 1 byte pitchEngine (0 Varispeed/1 Preserve); 1 byte pitchEnv.enabled; 8-byte
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope. // LE pitchEnv.attackFrames + decayFrames (int64, FRAMES 44.1k nom); 8-byte LE
// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload // peakSemitones (double). A v1/v2 payload (no v3 tail) lifts each zone to the product
// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint // defaults (Gate + Preserve, no fades, pitch env disabled) — deliberate for
// tail (the S15/S16 per-zone play params — always present, NOT flag-gated): // already-saved instruments. A truncated mid-v3-tail record keeps the zones that parsed.
// 1 byte playMode (0 = Gate, 1 = Trigger); // LEGACY-READ CONVERSION: the v3 wall-clock frame counts (hold, pitchEnv A/D) were
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal; // always written as nominal frames at a baked-in rate; convert to seconds by dividing by
// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE); // the PROJECT sample rate threaded into the v3 lift path at read time (a parameter, no
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); // baked constant). Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R
// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve); // absent in v3 -> tier-0 seconds defaults (0.003/0/1.0/0.060).
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom); // * v5 (CURRENT WRITE FORMAT): marker + version (== 5), v2 body PLUS, per zone record, the
// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double. // full play params with WALL-CLOCK TIMES AS SECONDS (rate-free doubles): 1 byte
// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve + // playMode; 8-byte LE adsr.holdSeconds; 8-byte LE trigger.lengthFraction; 8-byte LE
// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved // trigger.fadeInFrames + fadeOutFrames (int64, unchanged — source-timeline facts); 1
// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest. // byte pitchEngine; 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackSeconds +
// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS // decaySeconds + peakSemitones; 8-byte LE adsr.attackSeconds + decaySeconds +
// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds // sustainLevel + releaseSeconds. v4 (a branch-only frames-tail) was never shipped and is
// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed // intentionally not read. Keymap builders resolve stored seconds to frames at the LIVE
// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames. // sample rate; no rate is baked into storage or the program.
// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060). // BACK-COMPAT: a v1 ENVELOPE blob (the original single-selection format: version tag 1 + id
// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5), // bytes) lifts to a single full-keyboard zone playing that id (no override). A
// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full // truncated/unknown/empty blob deserializes to an EMPTY map.
// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles):
// 1 byte playMode (0 = Gate, 1 = Trigger);
// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double);
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
// 1 byte pitchEngine; 1 byte pitchEnv.enabled;
// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double);
// 8-byte LE pitchEnv.peakSemitones (double);
// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double);
// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double).
// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4
// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader
// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds
// to frames at the LIVE sample rate; no rate is baked into storage or the program.
// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is
// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved
// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes
// to an EMPTY map.
// //
// These two functions serialize the ZONES only. Since S10 the instrument's full component // These two functions serialize the ZONES only; the instrument's full component state is
// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState // {single-capture selection id, zones} — see ComponentState / serializeComponentState below.
// below, the v3 format the processor actually reads/writes. serializePerformance/
// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift.
inline constexpr std::uint32_t kPerformanceStateVersion = 2; 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). // The zones-payload format version and its detection marker. serializePerformance and
// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 // serializeComponentState both emit the CURRENT payload version (v7: marker + version +
// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock // records with the loop/start tail, the full play-params tail in SECONDS, the v6 keyTrack
// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides // scalar, and the v7 velocity->amp curve) so overrides round-trip through EITHER envelope.
// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker + // Readers accept v1 (no marker), v2 (marker + version 2, no play tail), and v3 (legacy play
// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts) // tail, wall-clock frame counts) for back-compat, lifting missing fields to defaults. v4 was
// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The // never shipped and is not read. The marker is a high sentinel no legitimate zone count
// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice, // (bounded by 128 MIDI zones, always tiny) can ever collide with.
// always tiny) can never collide with. // * PAYLOAD v6: identical to v5, PLUS one field appended to each zone record after the
// * 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 (double) — the per-zone key-tracking
// full v5 play-params tail: // scalar (1.0 = 100% ET). A v1-v5 payload (no keyTrack) lifts every zone to keyTrack =
// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET). // 1.0, so already-saved instances are BIT-IDENTICAL — the default reproduces the prior
// A v1v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone // repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the // * PAYLOAD v7 (CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed. // transfer curve appended after the v6 keyTrack field: 4-byte LE control-point count N,
// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp // then per point 8-byte LE velocity + 8-byte LE amp (doubles). The two endpoints
// transfer curve appended to each zone record after the v6 keyTrack field: // (velocity 0 and 127) are always included, so N >= 2. A v1-v6 payload (no
// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp // velocity-curve field) lifts every zone to VelocityCurve::flat() (Daniel-approved).
// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2. // This is a DELIBERATE NON-back-compat behavior change: an already-saved zone's soft
// A v1v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1 // hits play LOUDER than under the old linear velocity/127. A truncated mid-curve record
// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change: // leaves the zone's flat default and keeps the zones that parsed.
// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A inline constexpr std::uint32_t kZonesPayloadVersion = 7; // + per-zone velocity->amp curve
// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed.
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are // (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts
// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a // convert 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 // parameter (frames / projectRate = seconds) — the same rate keymap build already receives,
// already receives, so the seconds domain is consistent across both paths. No constant is baked in. // so the seconds domain is consistent across both paths. No constant is baked in.
// The performance map serialized to bytes for IBStream (getState). // The performance map serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map); 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, PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
double projectRate); 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 // 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 // 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 // project restores an instance's pick AND its zones — and an instance with NO pick and NO
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty // zones restores EMPTY (silence + the "pick a capture" empty state), never auto-playing
// state), never auto-playing sample #1. // sample #1.
// //
// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono, // Format (envelope v11): 4-byte LE version tag (== 11); 1-byte channel-mode field (0
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a // mono/1 stereo); 8-byte LE last-consumed-assignment generation; 1-byte preview-trigger
// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system // velocity (MIDI 1..127); three voice-system bytes (1-byte voice count 1..32, 1-byte voice
// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono // mode 0 Poly/1 Mono, 1-byte mono trigger 0 Retrigger/1 Legato); 8-byte LE master-gain
// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754 // LINEAR value (double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB);
// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte // 1-byte channel-mode-EXPLICIT flag (0 implicit/auto-default, 1 = user deliberately
// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the // toggled — see ComponentState::channelModeExplicit); the SAMPLE-REFS table (instance-owned
// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the // path + intrinsics + display name per referenced sample; wire shape at
// instance-owned path + intrinsics + display name per referenced sample; wire shape at // kSelectionZonesRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes —
// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE // the minted per-instance identity the usage publisher keys its "rsusage_<guid>" ext-state
// length + guid bytes; the minted per-instance identity the usage publisher keys its // record under, see sample_usage.h); 4-byte LE selection-id length + id bytes; then the
// "rsusage_<guid>" ext-state record under, see sample_usage.h), then a 4-byte LE // CURRENT zones payload (identical to serializePerformance's body — its own self-describing
// selection-id length + id bytes, then the CURRENT zones payload (identical to // version). The instance guid is the only v11 addition over v10, as the refs table was the
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block). // only v10 addition over v9 — the envelope grows a field, the zones payload is untouched (a
// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the // PARALLEL track owns zone-record extension under its own versioning — the two version
// only v10 addition over v9 — the envelope grows a field, // numbers are independent axes; do NOT bump the zones-payload version for an envelope
// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own // field). An out-of-range voice byte or a non-finite/out-of-range master-gain double (a
// versioning; the two version numbers are independent axes — do NOT bump the zones-payload // corrupt blob) falls back to the field's default rather than silencing the instance.
// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range // BACK-COMPAT on read (every older blob lifts to channelMode = MONO,
// master-gain double (a corrupt blob) falls back to the field's default rather than silencing // lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, voice
// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to // defaults {16 voices, Poly, Retrigger}, unity master gain, channelModeExplicit = FALSE — a
// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity = // pre-v9 mode byte is treated as the untouched default so the auto-default may follow the
// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity // loaded capture, and a user who HAD deliberately chosen a mode re-toggles once and the
// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the // choice persists explicit from then on — and an EMPTY sample-refs table, which the shell
// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD // lifts once via the bridge-resolve path — and an EMPTY instance guid, which the shell
// deliberately chosen a mode re-toggles once and the choice persists explicit from then on — // re-mints on first publish):
// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path —
// and an EMPTY instance guid (pre-pS-usage), which the shell re-mints on first publish):
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct. // * 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. // * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish).
// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift). // * v9 blob -> the v10 fields minus sampleRefs (empty tablebridge-resolve lift).
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode). // * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: implicit mode.
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain). // * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: unity master gain.
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults). // * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: voice defaults.
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity). // * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: no velocity byte.
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker). // * v4 blob -> {channelMode, 0, mid, selectionId, zones}: no marker.
// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode. // * v3 blob -> {mono, 0, mid, selectionId, zones}: no channel mode.
// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection. // * v2 blob -> {mono, 0, mid, "", zones}: zones but no separate selection.
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift. // * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: single-selection lift.
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state). // * 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 // WHY THE MARKER PERSISTS. The last-consumed assignment generation stops a re-opened
// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user // instance re-applying a stale assign_request the user already got and then manually
// already got and then manually changed away from: on re-open the instance re-reads the pending // changed away from: on re-open the instance re-reads the pending request, and only a
// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see // 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 // bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first
// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the // assign (generation >= 1) still applies. It is the instrument's own state, never written
// bank — the extension owns the assign_request key; the instrument only tracks what it consumed. // to the bank — the extension owns the assign_request key; the instrument only tracks what
// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no // it consumed. The preview-trigger velocity default is a mid MIDI velocity: an older blob
// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default. // with no velocity byte lifts to this, audible-but-not-hot.
inline constexpr std::uint8_t kPreviewVelocityDefault = 64; inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
struct ComponentState { struct ComponentState {
std::string selectionId; // the single-capture pick; "" = no pick std::string selectionId; // the single-capture pick; "" = no pick
PerformanceMap map; // the opt-in zones; empty = no zones PerformanceMap map; // the opt-in zones; empty = no zones
ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E) ChannelMode channelMode = ChannelMode::Mono; // decode mode; default mono
// GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle). // Whether channelMode was DELIBERATELY set by the user (the editor toggle). While
// While false (implicit), the shell auto-defaults the mode from the loaded capture's // false (implicit), the shell auto-defaults the mode from the loaded capture's channel
// channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the // count on reload (stereo capture -> Stereo, mono -> Mono); once true, the user's
// user's choice is never fought. Pre-v9 blobs lift to false (implicit). // choice is never fought. Pre-v9 blobs lift to false (implicit).
bool channelModeExplicit = false; bool channelModeExplicit = false;
std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed std::int64_t lastConsumedAssignGeneration = 0; // last assign_request generation consumed
// S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling // Preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling of
// of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's // channelMode, NOT per-zone), persisted so the Sample-view preview button retains the
// chosen strike velocity across saves. Defaults to kPreviewVelocityDefault. // user's chosen strike velocity across saves.
std::uint8_t previewVelocity = kPreviewVelocityDefault; std::uint8_t previewVelocity = kPreviewVelocityDefault;
// Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT // Voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an // per-zone). Defaults {16, Poly, Retrigger} reproduce pre-voice-system behavior
// older blob lifting to these plays byte-identically. // exactly, so an older blob lifting to these plays byte-identically.
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack) VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato 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; // Post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; up to
// up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output // ~15.849 = +24 dB — master_gain owns the dB taper). PER-INSTANCE output trim applied
// trim applied by process() AFTER the voice sum (engine + drain + preview) — never per // by process() AFTER the voice sum — never per voice, never a keymap fact. Default
// voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically, // unity reproduces pre-master-gain output byte-identically.
// so an older blob lifting to 1.0 plays exactly as it did.
double masterGainLinear = 1.0; double masterGainLinear = 1.0;
// pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics // Self-contained playback: the instance-OWNED sample refs — path + intrinsics for every
// for every bank sample this instance plays (see the SampleRefs block above). setState // bank sample this instance plays (see the SampleRefs block above). setState decodes
// decodes straight from these; NO bridge/extension read is required for playback. A // straight from these; NO bridge/extension read is required for playback. A pre-v10
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve // blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve path
// path once (then re-saves self-contained). // once (then re-saves self-contained).
SampleRefs sampleRefs; SampleRefs sampleRefs;
// pS-usage (v11): the minted per-instance identity the usage publisher keys its // The minted per-instance identity the usage publisher keys its "rsusage_<guid>"
// "rsusage_<guid>" ext-state record under (see sample_usage.h — the prune-protection // ext-state record under (see sample_usage.h — the prune-protection seam). Persisted so
// seam). Persisted so the key is stable across sessions (records do not proliferate // the key is stable across sessions. Empty = never published (a fresh or pre-v11
// per reopen). Empty = never published (a fresh or pre-v11 instance); the processor // instance); the processor mints one on first publish, and RE-mints when the publish
// mints one on first publish, and RE-mints when the publish plan detects this state // plan detects this state was cloned onto another track (FX copy / track duplication).
// was cloned onto another track (FX copy / track duplication — planUsagePublish).
std::string instanceGuid; std::string instanceGuid;
}; };
inline constexpr std::uint32_t kComponentStateVersion = 11; inline constexpr std::uint32_t kComponentStateVersion = 11;
// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed // v10 + the minted instance guid, length-prefixed after the refs table. Mirrors the
// after the refs table). Mirrors the v10/v9/… series so the version branches in // v10/v9/… series so the version branches in deserializeComponentState stay self-describing.
// deserializeComponentState stay self-describing.
inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11; inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table). // v9 + the instance-owned sample-refs table. Wire shape of the refs block (inserted after
// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection // the v9 explicit flag, before the selection id): 4-byte LE entry count, then per entry:
// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE // 4-byte LE id length + id bytes, 4-byte LE path length + path bytes, 4-byte LE rootNote
// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop, // (two's-complement), 1 byte loop.hasLoop, 8-byte LE loop.start + loop.end (int64, written
// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of // regardless of hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName
// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length + // length + bytes (display-only; the editor label's extension-absent fallback).
// displayName bytes (display-only; the editor label's extension-absent fallback).
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10; inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode // Everything through the master gain, no channel-mode explicit flag. Retained so
// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode. // deserializeComponentState can lift a v8 blob to implicit mode.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8; inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8;
// The GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the // v8 + the channel-mode-EXPLICIT flag. Mirrors the v8/v7/v6/… series so the v9-branch check
// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing. // in deserializeComponentState is self-describing.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9; inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9;
// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker + // Selection + zones + channel mode + consumed marker + preview velocity + voice system, no
// preview velocity + voice system, no master gain). Retained so deserializeComponentState can // master gain. Retained so deserializeComponentState can lift a v7 blob to unity master gain.
// lift a v7 blob to unity master gain.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7; inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7;
// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker + // Selection + zones + channel mode + consumed marker + preview velocity, no voice-system
// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a // fields. Retained so deserializeComponentState can lift a v6 blob to the voice defaults
// v6 blob to the voice defaults {16, Poly, Retrigger}. // {16, Poly, Retrigger}.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6; inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6;
// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no // Selection + zones + channel mode + consumed marker, no preview velocity. Retained so
// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity. // deserializeComponentState can lift a v5 blob to a mid velocity.
inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5; inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5;
// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed // Selection + zones + channel mode, no consumed marker. Retained so
// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}. // deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4; inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named // Selection + zones, no channel mode. Retained so deserializeComponentState can lift a v3
// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}. // blob to {mono, selection, zones}.
inline constexpr std::uint32_t kSelectionZonesV3Version = 3; inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
// The full instance state serialized to bytes for IBStream (getState). // 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) -------------------------------- // --- Instance state (VST3 setState/getState) --------------------------------
// //
// The instrument's OWN state is which bank sample it plays (D-B: the selection is a // The instrument's OWN state is which bank sample it plays (a performance choice, held by
// performance choice, held by the instrument, never written back to the bank). It is a // the instrument, never written back to the bank) — a single string id. serialize/
// single string id. serialize/deserialize keep the on-the-wire form explicit and // deserialize keep the on-the-wire form explicit and versioned so it can be extended
// versioned so a future Tier can extend it without breaking already-saved instances. // without breaking already-saved instances.
// //
// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No // Format (v1): 4-byte LE version tag (== 1) followed by the id bytes — no length prefix
// length prefix is needed the id runs to the end of the stream (the host tells us the // needed, the id runs to end of stream. deserializeSelection tolerates a truncated/wrong-
// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob // version/empty blob by returning "" (no selection is SILENCE + the "pick a capture" empty
// by returning "" (no selection — under the S10 policy reversal an empty selection is // state, not the bank's first sample), never throwing across the host boundary. Retained
// SILENCE + the "pick a capture" empty state, not the bank's first sample), never // for the v1->v3 back-compat lift in deserializeComponentState.
// throwing across the host boundary. Retained for the v1→v3 back-compat lift in
// deserializeComponentState; the processor's live state is the v3 ComponentState above.
inline constexpr std::uint32_t kSelectionStateVersion = 1; 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" #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 // Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive, DAW convention:
// (the DAW convention the editor's noteLabel uses). Returns nullopt if it is not a note name. // MIDI 0 == C-1, 60 == C4). Returns nullopt if it is not a note name.
std::optional<int> parseNoteName(const std::string& s) { std::optional<int> parseNoteName(const std::string& s) {
if (s.empty()) return std::nullopt; if (s.empty()) return std::nullopt;
std::size_t i = 0; 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 if (base < 0) return std::nullopt; // not a letter -> not a note name
++i; ++i;
int semitone = base; 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')) { 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; if (s[i] == '#') ++semitone;
else --semitone; else --semitone;
++i; ++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 // note_entry parse + clamp for direct numeric/note-name entry of a zone's low/high/root
// low/high/root MIDI note. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The // MIDI note (a drag on the keyboard strip can't hit a precise note reliably).
// 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.
// //
// WHY IT EXISTS (S12). Low/high/root are draggable on the keyboard strip, but a drag can't // Accepts a plain decimal integer ("60", "+5") or a note name ("C4", "f#3", "Bb-1", DAW
// hit a precise note reliably. This adds a typed field: the user clicks the field, types a // convention: MIDI 0 == C-1, 60 == C4). Out-of-range CLAMPS to [0,127] rather than
// value, and presses Enter; the shell hands the raw string here to parse into a clamped MIDI // rejecting; unparseable input returns nullopt (shell keeps the old value).
// 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).
#pragma once #pragma once
@@ -24,10 +12,7 @@
namespace reasampler::instrument::map { namespace reasampler::instrument::map {
// Parse a typed low/high/root field into a clamped MIDI note [0,127]. Accepts a decimal // Leading/trailing whitespace ignored. Empty or unparseable input returns nullopt.
// 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.
std::optional<int> parseNoteEntry(const std::string& text); std::optional<int> parseNoteEntry(const std::string& text);
} // namespace reasampler::instrument::map } // namespace reasampler::instrument::map
+27 -46
View File
@@ -1,6 +1,5 @@
// sample_map — pure implementation (the RESOLUTION half; the ComponentState codec // sample_map — pure implementation (the resolution half; the ComponentState codec lives
// lives in component_state_io.cpp since Q-W2v). See sample_map.h. NO VST3 / REAPER / // in component_state_io.cpp). See sample_map.h.
// SWELL / vendor includes; standard library + the pure bank_book / wav_codec / sampler_core.
#include "core/instrument/map/sample_map.h" #include "core/instrument/map/sample_map.h"
@@ -12,9 +11,8 @@ namespace reasampler::instrument::map {
namespace { namespace {
// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank // The bank stores loop points as an optional LoopPoints (both-or-neither); the core wants
// stores loop points as an optional LoopPoints (both-or-neither); the core wants a // a SampleLoop with an explicit hasLoop. Absent -> no loop.
// SampleLoop with an explicit hasLoop. Absent -> no loop.
SampleLoop loopFromSample(const Sample& s) { SampleLoop loopFromSample(const Sample& s) {
SampleLoop out; SampleLoop out;
if (s.loop) { if (s.loop) {
@@ -25,41 +23,33 @@ SampleLoop loopFromSample(const Sample& s) {
return out; return out;
} }
// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C // rootNote defaults to middle C (60) when the bank left the intrinsic empty — an
// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on // un-rooted sample plays unity at C4 rather than failing to play.
// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4).
SelectedSample distill(const Sample& s) { SelectedSample distill(const Sample& s) {
SelectedSample out; SelectedSample out;
out.relativePath = s.relativePath; out.relativePath = s.relativePath;
out.rootNote = s.rootNote ? *s.rootNote : 60; out.rootNote = s.rootNote ? *s.rootNote : 60;
out.loop = loopFromSample(s); out.loop = loopFromSample(s);
out.channelCount = s.channelCount; // capture intrinsic; 0 = unknown (older entry) out.channelCount = s.channelCount; // 0 = unknown (older entry)
return out; return out;
} }
// The ONE override-beats-intrinsic fold shared by the bank-side resolvePerformance and the // The ONE override-beats-intrinsic fold shared by resolvePerformance and
// refs-side resolvePerformanceFromRefs (pS): a zone's authored fields + the sample's // resolvePerformanceFromRefs, so the two resolution paths cannot drift.
// intrinsics (already distilled — rootNote carries the middle-C default) -> ResolvedZone.
// Shared so the two resolution paths cannot drift.
ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) { ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) {
ResolvedZone rz; ResolvedZone rz;
rz.relativePath = ref.relativePath; rz.relativePath = ref.relativePath;
rz.lowNote = z.lowNote; rz.lowNote = z.lowNote;
rz.highNote = z.highNote; 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; rz.rootNote = z.rootOverride ? *z.rootOverride : ref.rootNote;
// S-VIEW-6/S-VIEW-9: key tracking + the velocity->amp curve are instrument state — // Key tracking + velocity curve are instrument state — carried straight through.
// carried straight through and applied at play time.
rz.keyTrack = z.keyTrack; rz.keyTrack = z.keyTrack;
rz.velocityCurve = z.velocityCurve; rz.velocityCurve = z.velocityCurve;
// Effective loop / start (S11): the per-zone override wins over the intrinsic; absent // Per-zone override wins over the intrinsic; absent -> intrinsic (loop) / frame 0
// -> the intrinsic (loop) / frame 0 (start). The bank is never mutated (D-B). // (start). The bank is never mutated.
rz.loop = z.loopOverride ? *z.loopOverride : ref.loop; rz.loop = z.loopOverride ? *z.loopOverride : ref.loop;
rz.startFrame = z.startPoint ? *z.startPoint : 0; rz.startFrame = z.startPoint ? *z.startPoint : 0;
// S15/S16 per-zone play params (SECONDS) carry through unchanged; buildZonedKeymap rz.play = z.play; // SECONDS; buildZonedKeymap resolves to frames
// resolves them to frames.
rz.play = z.play;
return rz; return rz;
} }
@@ -67,22 +57,20 @@ ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) {
std::optional<SelectedSample> selectSample(const std::string& banksJson, std::optional<SelectedSample> selectSample(const std::string& banksJson,
const std::string& sampleId) { const std::string& sampleId) {
// POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short- // An empty selection is SILENCE, not the first sample — by design.
// circuit before parsing — no stored id resolves to nothing to play by design.
if (sampleId.empty()) return std::nullopt; if (sampleId.empty()) return std::nullopt;
if (banksJson.empty()) return std::nullopt; if (banksJson.empty()) return std::nullopt;
std::optional<BankBook> book = BankBook::deserialize(banksJson); std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return std::nullopt; // malformed -> nothing to play (never throw) if (!book) return std::nullopt; // malformed -> nothing to play (never throw)
// Search every bank (pool first, then named — banks() is ordinal order) for the // Search every bank (ordinal order) for the stored id; a sample lives in exactly
// stored id. A sample lives in exactly one bank, so first hit wins. // one bank, so first hit wins.
for (const Bank& b : book->banks()) { for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(sampleId)) { if (const Sample* s = b.index.query(sampleId)) {
return distill(*s); return distill(*s);
} }
} }
// A stale stored id (no longer resolves) is SILENCE, not a substituted first sample: // A stale stored id is SILENCE too — the editor's empty state, not a substitution.
// the editor reflects the missing pick with its empty state rather than masking it.
return std::nullopt; return std::nullopt;
} }
@@ -92,7 +80,7 @@ ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplici
return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono; 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) { const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId) {
if (sampleId.empty()) return nullptr; if (sampleId.empty()) return nullptr;
@@ -133,7 +121,7 @@ void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
for (SampleRefEntry& e : refs) { for (SampleRefEntry& e : refs) {
if (e.sampleId == id) { if (e.sampleId == id) {
e.ref = distilled; e.ref = distilled;
e.displayName = found->displayName; // rename sync rides the same refresh e.displayName = found->displayName; // rename sync
updated = true; updated = true;
break; 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 if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
out.sampleRate = sampleRate; out.sampleRate = sampleRate;
if (mode == ChannelMode::Mono) { if (mode == ChannelMode::Mono) {
// MONO mode: the existing downmix policy (average all source channels), one channel out.
out.monoFrames = downmixToMono(interleaved, sourceChannels); out.monoFrames = downmixToMono(interleaved, sourceChannels);
return out; // framesR stays empty return out; // framesR stays empty
} }
// STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0 // extractChannel clamps out-of-range, so a mono source yields L == R (dual-mono).
// 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.
out.monoFrames = extractChannel(interleaved, sourceChannels, 0); out.monoFrames = extractChannel(interleaved, sourceChannels, 0);
out.framesR = extractChannel(interleaved, sourceChannels, 1); out.framesR = extractChannel(interleaved, sourceChannels, 1);
return out; return out;
} }
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) { ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
// seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R, // seconds -> frames at the LIVE rate; source-timeline quantities (trigger %-length +
// pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry // fades) carry through untouched, already frames/fractions.
// through untouched — they are already source frames / fractions. Non-time fields pass as-is.
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)"); 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 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) { const auto secToFrames = [sr](double sec) {
@@ -304,8 +288,7 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
if (!book) return out; // malformed -> nothing (never throw) if (!book) return out; // malformed -> nothing (never throw)
for (const PerformanceZone& z : map.zones) { for (const PerformanceZone& z : map.zones) {
// Look the id up across every bank (pool + named) — a sample lives in exactly // A sample lives in exactly one bank, so first hit wins.
// one bank, so first hit wins.
const Sample* found = nullptr; const Sample* found = nullptr;
for (const Bank& b : book->banks()) { for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(z.sampleId)) { if (const Sample* s = b.index.query(z.sampleId)) {
@@ -314,12 +297,11 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
} }
} }
if (!found) { if (!found) {
// STALE-ID POLICY: drop the zone cleanly, report the id (editor can prune). out.droppedSampleIds.push_back(z.sampleId); // stale: drop, report
out.droppedSampleIds.push_back(z.sampleId);
continue; continue;
} }
// Distill the bank Sample to the same intrinsics shape the refs table carries, then // Distill to the same intrinsics shape the refs table carries, then run the SHARED
// run the SHARED fold — so the bank path and the refs path resolve identically. // fold — so the bank path and refs path resolve identically.
out.zones.push_back(foldZone(z, distill(*found))); out.zones.push_back(foldZone(z, distill(*found)));
} }
return out; return out;
@@ -332,8 +314,7 @@ ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
if (const SelectedSample* r = findRef(refs, z.sampleId)) { if (const SelectedSample* r = findRef(refs, z.sampleId)) {
out.zones.push_back(foldZone(z, *r)); out.zones.push_back(foldZone(z, *r));
} else { } else {
// No ref for this id (never copied, or a pre-v10 blob not yet lifted): drop the // No ref for this id: drop + report, same shape as the bank path's stale-id policy.
// zone cleanly + report — the same shape as the bank path's stale-id policy.
out.droppedSampleIds.push_back(z.sampleId); out.droppedSampleIds.push_back(z.sampleId);
} }
} }
+162 -245
View File
@@ -1,22 +1,10 @@
#pragma once #pragma once
// sample_map — PURE mapping logic for the S4 Tier-0 instrument: turn the live // sample_map — turns the live "reasampler" bank ext-state + a decoded WAV into the plain
// "reasampler" bank ext-state + a decoded WAV into the plain data the sampler core // data the sampler core plays, and (de)serializes the instance's zone/selection state.
// plays, and (de)serialize the instance's selected-sample choice for VST3 component // The bank is read over the live-state seam, audio over the file seam; both raw inputs
// state. NO VST3, NO REAPER, NO SWELL, NO vendor/ includes at the boundary — the // cross the bridge/file boundary in the shell, everything after (bank parse via the shared
// mirror of capture_paths / wav_codec / bridge_marshal splitting the fiddly, testable // bank_book JSON path, sample pick, mono downmix, keymap build) is pure and unit-tested
// arithmetic out of a host-facing shell. // here. Links bank_book, wav_codec, and sampler_core (all pure).
//
// 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.
#include <cstdint> #include <cstdint>
#include <optional> #include <optional>
@@ -29,77 +17,56 @@
namespace reasampler::instrument::map { 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 audio::AudioSample;
using instrument::engine::VelocityCurve; using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint; using instrument::engine::VelocityPoint;
// The bank sample this instance is bound to, distilled from the live "banks" blob: // The bank sample this instance is bound to, distilled from the live "banks" blob: the
// the project-relative WAV path the file seam must resolve+decode, plus the S2 bank // project-relative WAV path the file seam resolves+decodes, plus the bank intrinsics the
// intrinsics the core repitches / loops by. A pure value — no host, no PCM yet. // core repitches/loops by. A pure value — no host, no PCM yet.
struct SelectedSample { struct SelectedSample {
std::string relativePath; // project-relative; the shell resolves it (M4 convention) std::string relativePath; // project-relative; the shell resolves it
int rootNote = 60; // S2 intrinsic; defaults to middle C when the bank left it empty int rootNote = 60; // defaults to middle C when the bank left it empty
SampleLoop loop; // S2 intrinsic; hasLoop=false when the bank left it empty SampleLoop loop; // hasLoop=false when the bank left it empty
int channelCount = 0; // bank intrinsic (capture channel count); 0 = unknown (older int channelCount = 0; // capture channel count; 0 = unknown (older bank entries) —
// bank entries) — the GA channel-mode auto-default skips it // the GA channel-mode auto-default skips it
}; };
// Resolve the bound sample from the live bank blob. `banksJson` is the raw "banks" // `banksJson` is the raw "banks" ext-state value the bridge read (may be empty/malformed —
// ext-state value the bridge read (may be empty / malformed — an unsaved or pre-bank // an unsaved or pre-bank project); `sampleId` is this instance's stored selection.
// project). `sampleId` is this instance's stored selection.
// //
// Precedence, all pure: // Precedence: empty/malformed banksJson -> nullopt. Empty sampleId -> nullopt (no selection
// * empty / malformed banksJson -> nullopt (nothing to play) // is SILENCE, not the bank's first sample — deliberate: the metric is time-to-first-note via
// * sampleId empty -> nullopt (NO selection -> silence) // an explicit pick, and mystery auto-play of sample #1 was the anti-pattern). sampleId found
// * sampleId names a sample in ANY bank -> that sample (searched pool + named) // in any bank -> that sample. sampleId set but not found (stale) -> nullopt, same as no
// * sampleId set but not found (stale) -> nullopt (the sample was deleted/moved; // selection — the editor shows its "pick a capture" empty state rather than masking it.
// 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.
std::optional<SelectedSample> selectSample(const std::string& banksJson, std::optional<SelectedSample> selectSample(const std::string& banksJson,
const std::string& sampleId); const std::string& sampleId);
// GA auto-default rule (pure, tested): given the capture's requested channel count, the // Auto-default rule: given the capture's channel count, current mode, and whether the user
// instance's current mode, and whether the user has explicitly toggled the mode, return // explicitly toggled it, return the mode to apply. Explicit choice is never overridden;
// the mode to apply. Explicit choice is never overridden. An unknown channelCount (0) // channelCount == 0 (unknown) leaves the current mode; >= 2 -> Stereo; == 1 -> Mono.
// 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
ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit); 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 // The instrument must never go silent just because the extension's ext-state hasn't parsed
// ext-state has not parsed yet (or the extension is absent). So the instance persists, in // yet (or the extension is absent). So the instance persists, in its OWN component state, a
// its OWN component state, a small table of everything it needs to PLAY each referenced // table of everything needed to PLAY each referenced bank sample: path + decode intrinsics
// bank sample: the project-relative WAV path + the decode intrinsics (root note, loop, // (root, loop, channel count), keyed by bank sample id. The shell decodes straight from
// channel count) — exactly a SelectedSample, keyed by the bank sample id. On load the // these refs; the bank blob is a browser source that refreshes the table opportunistically
// shell decodes straight from these refs; the bank blob is a BROWSER SOURCE that also // when readable, never a runtime lifeline.
// refreshes this table opportunistically when readable (recapture/root edits stay live),
// never a runtime lifeline.
// //
// POLICY (follows from ownership): a sample deleted from the BANK no longer silences an // Consequence: a sample deleted from the bank no longer silences an instance that carries
// instance that carries its ref — the instance keeps playing while the FILE exists (normal // its ref — it keeps playing while the file exists (normal sampler behavior; prune deleting
// sampler behavior; prune deleting the file yields the defined no-play). This deliberately // the file yields the defined no-play).
// supersedes the S10 stale-id-silence rule, which was an artifact of bank-side resolution. struct PerformanceMap; // defined below; referencedSampleIds spans both selection + zones
struct PerformanceMap; // defined below (Tier 1); referencedSampleIds spans both tiers
struct SampleRefEntry { struct SampleRefEntry {
std::string sampleId; // the bank sample id this ref was copied from (the seam key) 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 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 // Bank display name at copy time — DISPLAY ONLY (editor label fallback when the bank
// back to it when the bank snapshot is unavailable, mirroring the waveform/loop ref // snapshot is unavailable); never consulted by resolution.
// fallback); never consulted by resolution. Empty for a table written before the field
// existed in-session (it back-fills on the next bank refresh).
std::string displayName; std::string displayName;
}; };
using SampleRefs = std::vector<SampleRefEntry>; 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, std::vector<std::string> referencedSampleIds(const std::string& selectionId,
const PerformanceMap& map); const PerformanceMap& map);
// Upsert a ref for each id in `ids` that resolves in the live bank blob (the same // Upsert a ref for each id in `ids` that resolves in the live bank blob, copying the display
// distillation selectSample performs), copying the bank display name alongside the decode // name alongside the decode intrinsics. A miss leaves any existing entry untouched — the
// intrinsics. A miss leaves any existing entry untouched — the instance owns its copy; a // instance owns its copy; a bank deletion never strips a ref. Empty/malformed blob -> no-op.
// bank deletion never strips a ref. Empty/malformed blob -> no-op.
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson, void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
const std::vector<std::string>& ids); const std::vector<std::string>& ids);
// The pre-v10 LEGACY-LIFT terminating decision (pure, so the no-churn rule is provable // Legacy-lift terminating decision: can a refs lift make progress against this bank blob
// without a host): can a refs lift MAKE PROGRESS against this bank blob for the ids the // for the ids the instance references?
// instance references? // * Retry — blob absent/empty/unparseable: not readable yet, keep retrying.
// * Retry — the blob is absent/empty/unparseable: not readable YET, keep retrying (the // * Lift — blob parses and at least one id resolves: copy a ref in (never re-fires once
// project's ext-state may simply not have parsed). // the refs table is non-empty).
// * Lift — the blob parses and at least one id resolves: a lift copies a ref in (the // * Stale — blob parses and no id resolves: provably stale, nothing to lift, ever — the
// refs table then goes non-empty and the lift never re-fires). // shell latches this and stops retrying (no per-tick churn).
// * 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).
enum class LegacyLiftDecision { Retry, Lift, Stale }; enum class LegacyLiftDecision { Retry, Lift, Stale };
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson, LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids); const std::vector<std::string>& ids);
// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table tracks // Keep only the entries whose id is in `ids` (getState hygiene: the persisted table cannot
// exactly what the instance currently plays, so it cannot grow with browsing history). // grow with browsing history).
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids); 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 // One entry in the capture browser's card list: stable id + display name + intrinsics +
// intrinsics + bank the browser draws as a card (peak thumbnail + name + root/key badge, // bank, for a card (peak thumbnail + name + root/key badge, filterable by bank). Peaks are
// filterable by bank). Peaks are NOT here — they are computed shell-side from the decoded // NOT here — computed shell-side from the decoded PCM (reasampler_editor's thumbnail
// PCM (the `Sample` metadata carries no envelope; see reasampler_editor's thumbnail cache, // cache). rootNote is nullopt when the bank left it empty (badge shows no root, never a
// the mirror of bank_panel::thumbnailFor). This carries only what the bank blob already // guessed value). Pure projection over the shared parse — the UI never parses JSON itself.
// 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).
struct SampleChoice { struct SampleChoice {
std::string id; std::string id;
std::string displayName; std::string displayName;
@@ -167,35 +123,27 @@ struct BankChoice {
}; };
std::vector<BankChoice> listBanks(const std::string& banksJson); std::vector<BankChoice> listBanks(const std::string& banksJson);
// Downmix interleaved float frames (the shape wav_codec's extractFloatFrames yields: // Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to the core's MONO contract
// [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per // by AVERAGING channels per frame (`channelCount` is the interleave stride, >= 1) — not
// frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0, // "take L", not summing: a centered mono source stays unity, a hard-panned source is
// documented + surfaced): the S3 core is mono-per-sample by design; bank WAVs preserve // attenuated rather than silenced or doubled. Empty/zero-stride in -> empty out. Pure.
// 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.
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved, std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
int channelCount); int channelCount);
// Deinterleave one channel (`which`, 0-based) out of interleaved frames. `channelCount` is // Deinterleave one channel (`which`, 0-based). `which` clamps to a valid channel (a request
// the interleave stride (>= 1); `which` is clamped to a valid channel (a request past the // past the last channel reads the last channel, so a mono source asked for channel 1 yields
// source's 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.
// channel 0 again — the dual-mono building block). Empty / zero-stride in -> empty out. Pure.
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved, std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which); int channelCount, int which);
// --- Stored (wall-clock SECONDS) per-zone play params ------------------------- // --- Stored (wall-clock SECONDS) per-zone play params -------------------------
// //
// DOMAIN SPLIT (S12 remediation — Daniel's ruling: no hardcoded sample rate in the program). // Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
// The instrument stores and edits WALL-CLOCK performance times as SECONDS, rate-free; the // instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
// engine (sampler_core's ZonePlayParams, on SampleData) receives FRAMES resolved from the // SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
// LIVE sample rate at keymap build. AHDSR (A/H/D/S/R) and the AD pitch envelope (attack/decay) // keymap build. Quantities anchored to the source file's timeline (start point, loop
// are wall-clock — the voice advances them once per OUTPUT frame — so they live here in seconds. // points, Trigger %-length + fades) stay in source frames/fractions, carried through
// Quantities anchored to the source file's timeline (start point, loop points, Trigger %-length // unchanged (TriggerParams reused verbatim).
// 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).
// //
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time. // The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
struct AdsrSeconds { struct AdsrSeconds {
@@ -214,61 +162,51 @@ struct PitchEnvSeconds {
double peakSemitones = 0.0; // signed depth at the peak double peakSemitones = 0.0; // signed depth at the peak
}; };
// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities in // The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities
// frames/fractions (TriggerParams). This is the instrument-owned (D-B), serialized, editor-facing // in frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing
// representation — distinct from sampler_core's engine-facing ZonePlayParams (frames). The keymap // distinct from sampler_core's engine-facing ZonePlayParams (frames).
// builders resolve this to a frame-domain ZonePlayParams against the live sample rate.
struct ZonePlaySeconds { struct ZonePlaySeconds {
PlayMode playMode = PlayMode::Gate; PlayMode playMode = PlayMode::Gate;
AdsrSeconds adsr; // Gate: AHDSR (seconds) AdsrSeconds adsr; // Gate: AHDSR (seconds)
TriggerParams trigger; // Trigger: %-length + fades (source frames) 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 PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
}; };
// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live // 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, // sample rate (frames = round(seconds * rate)). Source-timeline fields carry through
// peak, enabled) carry through unchanged. `sampleRate` must be > 0 (the caller guards this). // unchanged. `sampleRate` must be > 0 (the caller guards this).
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate); ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate);
// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole // 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 // keyboard, repitched from `rootNote`, looped per `loop` (Keymap::singleSampleChromatic).
// (Keymap::singleSampleChromatic) with the S2 intrinsics threaded in. `frames` is channel 0 // `frames` is channel 0 (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono
// (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono sample (the default), // sample. A `framesR` whose length mismatches `frames` is dropped (falls back to mono), so a
// which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length // bad pair never half-plays. `sampleRate` is the WAV's rate. `play` carries the per-zone play
// mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad // params (SECONDS); defaults to the product defaults (Gate + tier-0 AHDSR + Preserve) so a
// pair never half-plays. `sampleRate` is the WAV's rate. // picked single capture plays under the same default engine as a zone would. Resolves the
// `play` carries the S15/S16 per-zone play params (SECONDS) for the single-capture path; it // wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
// 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.
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate, Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop, int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR = {}, std::vector<AudioSample> framesR = {},
const ZonePlaySeconds& play = ZonePlaySeconds{}); 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 // 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 // samples zoned across the keyboard, each with a key range and a root note. A performance
// PERFORMANCE CHOICE (D-B), so it lives in the instrument (VST3 component state), never // choice, so it lives in the instrument (VST3 component state), never written back to the
// written back to the bank. Root note per zone is SEEDED from the S2 bank intrinsic but // bank. Pure value type: names bank samples by id (the stable seam key), holds no PCM — the
// OVERRIDABLE here — the override lives on the zone, never on `Sample`. // shell resolves+decodes each id's WAV, and the pure zone-build stitches the decoded frames
// // + this map into a sampler_core Keymap.
// 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.
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range, // 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 // rootOverride absent -> repitch from the bank sample's own rootNote intrinsic (or middle C
// sample's own S2 rootNote intrinsic (or middle C when the bank left it empty). // 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
// S11 loop/start overrides (instrument-owned, D-B — mirror of rootOverride): the sustain // without writing back to the bank (loopOverride wins when set; startPoint sets the voice's
// loop and the initial read position are FACTS about the file (S2 bank intrinsics), but the // initial read frame, absent -> 0). resolvePerformance folds override-beats-intrinsic into
// instrument may override them per zone WITHOUT writing back to the bank. loopOverride wins // the effective ResolvedZone.
// 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.
struct PerformanceZone { struct PerformanceZone {
std::string sampleId; // bank sample id this zone plays std::string sampleId; // bank sample id this zone plays
int lowNote = 0; // inclusive int lowNote = 0; // inclusive
@@ -277,146 +215,125 @@ struct PerformanceZone {
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0 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 // Key-tracking scalar: how far playback pitch tracks the keyboard around the root. 1.0
// playback pitch tracks the keyboard around the root. 1.0 (100%) is standard 12-tone-ET (the // (100%, standard 12-tone-ET) is the default — a blob predating this field lifts to
// DEFAULT; a pre-S-VIEW-6 blob with no keyTrack tail lifts to exactly 1.0, so already-saved // exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every
// instances are bit-identical); 0.0 = no tracking (every key plays root pitch); 2.0 = double. // key plays root pitch); 2.0 = double. Applied in keyTrackedRatio inside both repitch
// NOT flag-gated — always present in the CURRENT payload (v6). Carried through to KeyZone by // engines.
// resolvePerformance and applied in keyTrackedRatio inside BOTH repitch engines.
double keyTrack = 1.0; double keyTrack = 1.0;
// S-VIEW-9 velocity->amp transfer curve (instrument-owned, D-B — mirror of keyTrack): maps the // Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain,
// note-on MIDI velocity (0..127) to the voice's amp gain, replacing the fixed linear velocity/127. // replacing the old fixed linear velocity/127. Per-zone. Default = flat y=1 (Daniel-
// A per-sound performance characteristic, so it varies PER ZONE. DEFAULT = flat y=1 (R10-F1 // approved): every velocity plays at unity. DELIBERATE non-back-compat behavior change —
// Option A, Daniel-approved): every velocity plays at unity. This is a DELIBERATE, non-back-compat // a blob predating this field lifts to flat y=1, so an already-saved zone's soft hits
// behavior change — a pre-S-VIEW-9 blob (no velocityCurve field) lifts to flat y=1, so an // play LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd
// already-saved zone's soft hits play LOUDER than under the old linear map. Intended; do NOT // in Voice::start.
// 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).
VelocityCurve velocityCurve = VelocityCurve::flat(); VelocityCurve velocityCurve = VelocityCurve::flat();
// S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch // Per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine +
// engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the // AD pitch envelope). Instrument-owned, never a bank fact. Wall-clock times stored in
// loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build // SECONDS (rate-free); keymap build resolves to frames at the live sample rate. Defaults
// resolves them to frames at the live sample rate. Defaults to the PRODUCT defaults for a NEW // for a NEW zone: Gate, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no
// zone: Gate play mode, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades, // fades, Preserve pitch engine, pitch env off. An older zone blob lacking this tail lifts
// PRESERVE pitch engine (S16-F1), pitch env off. An older zone-payload blob (no S15/S16 tail) // to exactly these defaults on read.
// lifts to exactly these defaults on read (see the PAYLOAD versioning).
ZonePlaySeconds play; ZonePlaySeconds play;
}; };
// The instrument's performance map: an ordered list of zones. Order is authoritative for // 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 // overlap resolution first zone in order wins (mirrors the core's first-match
// first-match Keymap::resolve overlaps are neither rejected nor clamped, the earlier // Keymap::resolve); overlaps are neither rejected nor clamped, deterministic by construction.
// zone simply takes the contested keys; documented, deterministic).
struct PerformanceMap { struct PerformanceMap {
std::vector<PerformanceZone> zones; std::vector<PerformanceZone> zones;
bool empty() const { return zones.empty(); } 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 // 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 // control edit. Loading a different sample used to change only the selection id, leaving
// selection id, leaving the previous sample's full-range zone in the map — and since zone // the previous sample's full-range zone in the map — and since zone resolution is
// resolution is FIRST-MATCH in order, that stale zone shadowed every later one forever: the // first-match in order, that stale zone shadowed every later one forever: the engine kept
// engine kept playing the old sample while the editor drew the new one's zone (matched by // playing the old sample while the editor drew the new one's zone. This function is called
// sampleId, order-blind). This function is called at every selection-change site so the zone // at every selection-change site so the zone the editor draws is the zone the engine plays.
// the editor draws is the zone the engine plays.
// //
// Rules (pure, order-preserving where it matters): // Rules (order-preserving where it matters):
// * empty `selectedId` or empty map -> untouched, false. // * empty `selectedId` or empty map -> untouched, false.
// * ANY zone with an authored key range (not the full [0,127]) -> the map is Zone-view // * ANY zone with an authored key range (not full [0,127]) -> Zone-view authorship,
// authorship; first-match order is load-bearing there — untouched, false. The Sample // first-match order is load-bearing there — untouched, false (the Sample face never
// face never creates a narrow zone, so a narrow zone proves deliberate multi-zone intent. // 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 // * else (every zone full-range) -> keep only the first zone bound to `selectedId`
// first zone bound to `selectedId` (the selection's own params are not reset); drop // (params preserved); drop the rest. A selection with no zone yet empties the map.
// 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).
// Returns true iff the map changed (the caller republishes + reloads on true). // Returns true iff the map changed (the caller republishes + reloads on true).
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId); bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId);
// One resolved zone ready for the shell to decode + the pure build to stitch: the bank // One resolved zone ready for the shell to decode + the pure build to stitch: project-
// sample's project-relative WAV path (file seam), the EFFECTIVE root note (override beats // relative WAV path (file seam), effective root note (override beats bank intrinsic beats
// bank intrinsic beats middle-C default), the loop intrinsic, and the key range. Distinct // middle-C default), loop intrinsic, key range. Distinct from PerformanceZone (which names
// from PerformanceZone (which names an id) — this is the id resolved against the live bank. // an id) — this is the id resolved against the live bank.
struct ResolvedZone { struct ResolvedZone {
std::string relativePath; // project-relative; the shell resolves + decodes it std::string relativePath; // project-relative; the shell resolves + decodes it
int lowNote = 0; int lowNote = 0;
int highNote = 127; int highNote = 127;
int rootNote = 60; // effective: override, else bank intrinsic, else 60 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) double keyTrack = 1.0; // carried from PerformanceZone (1.0 = 100% ET)
VelocityCurve velocityCurve = VelocityCurve::flat(); // S-VIEW-9 velocity->amp curve, carried from PerformanceZone VelocityCurve velocityCurve = VelocityCurve::flat(); // carried from PerformanceZone
SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11) SampleLoop loop; // effective: loopOverride, else bank intrinsic
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11) 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) 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` are the zones whose sampleId still resolves, IN MAP ORDER (overlap-order
// zones whose sampleId still resolves to a bank sample, IN MAP ORDER (so overlap-order is // preserved). `droppedSampleIds`: a zone naming a deleted/moved-out sample is dropped
// preserved). `droppedSampleIds` are the ids that no longer resolve (STALE-ID POLICY: a // cleanly — not an error, not silence for the whole map — and reported here so the editor
// zone naming a deleted/moved-out sample is DROPPED cleanly — not an error, not silence // can flag/prune it.
// for the whole map — and its id is reported here so the editor can flag/prune it).
struct ResolvedPerformance { struct ResolvedPerformance {
std::vector<ResolvedZone> zones; std::vector<ResolvedZone> zones;
std::vector<std::string> droppedSampleIds; std::vector<std::string> droppedSampleIds;
}; };
// Resolve a performance map against the live "banks" ext-state blob. Pure: shared // Resolve a performance map against the live "banks" ext-state blob. Each zone's sampleId
// bank_book parse, no host, no PCM. Each zone's sampleId is looked up across every bank // is looked up across every bank; a hit yields a ResolvedZone with the effective root note
// (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride, // and loop intrinsic; a miss appends to droppedSampleIds. Empty/malformed blob or empty map
// else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends // -> empty result.
// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result.
// //
// NOT the live load path since pS: reloadInstrument resolves via resolvePerformanceFromRefs // NOT the live load path reloadInstrument resolves via resolvePerformanceFromRefs (the
// (the instance-owned refs). This bank-side resolver is retained as the TESTED REFERENCE // instance-owned refs). Retained as the TESTED REFERENCE the refs path is verified against
// the refs path is verified against (testResolveFromRefsMatchesBankResolve) — both share // (both share foldZone, so the drift test keeps the shared fold honest).
// foldZone, so the drift test is what keeps the shared fold honest.
ResolvedPerformance resolvePerformance(const std::string& banksJson, ResolvedPerformance resolvePerformance(const std::string& banksJson,
const PerformanceMap& map); const PerformanceMap& map);
// Resolve a performance map against the INSTANCE-OWNED refs table (pS self-contained // The bank-free mirror of resolvePerformance, against the INSTANCE-OWNED refs table
// playback) — the bank-free mirror of resolvePerformance, sharing the same override- // shares the same override-beats-intrinsic fold, so the two paths cannot drift. A zone
// beats-intrinsic fold, so the two paths cannot drift. A zone whose sampleId has no ref // whose sampleId has no ref is dropped + reported (same stale-id shape as the bank path).
// is dropped + reported (same stale-id shape as the bank path). Pure.
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs, ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
const PerformanceMap& map); const PerformanceMap& map);
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` is the // Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` matches
// downmixed frames + sample rate for `zones[i]` (same length + order as `zones`). One // `zones[i]` in length + order. One SampleData per zone (a sample used by two zones is
// SampleData per zone (Tier 1: one sample per key-region; a sample used by two zones is // decoded twice — acceptable here, the shell may dedup by path later). Zone order preserved
// decoded twice — acceptable at this tier, the shell may dedup by path later). Zone order // so first-match overlap resolution matches authored order. A zone whose decoded frames are
// is preserved so first-match overlap resolution matches the map's authored order. A zone // empty is SKIPPED (an unreadable WAV drops the zone, not the map).
// whose decoded frames are empty is SKIPPED (an unreadable WAV drops the zone, not the
// map). Empty zones in -> empty Keymap (silence).
struct DecodedZonePcm { struct DecodedZonePcm {
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode) std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
int sampleRate = 0; // 0 is explicitly invalid; every consumer must int sampleRate = 0; // 0 is explicitly invalid
// receive the WAV's real rate before use.
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
}; };
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones, Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
const std::vector<DecodedZonePcm>& decoded); const std::vector<DecodedZonePcm>& decoded);
// Apply the S7 cross-mode channel policy (D-E) to freshly-decoded interleaved PCM, yielding // Apply the cross-mode channel policy to freshly-decoded interleaved PCM, yielding the 1- or
// the 1- or 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's // 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's float
// float frames (stride = `sourceChannels`); `mode` is the instance's channel mode. // frames (stride = `sourceChannels`); `mode` is the instance's channel mode.
// * MONO mode -> downmix to one channel (the existing policy: average all source // * MONO mode -> downmix to one channel (average all source channels).
// 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, 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).
// * STEREO mode, stereo src -> channels 0 and 1 taken as-is (L/R). A source with >2 channels // Empty/zero-channel input -> empty frames (caller drops the zone or plays silence).
// 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.
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved, DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate); int sourceChannels, ChannelMode mode, int sampleRate);
// The ComponentState envelope + zones-payload binary codec (serializePerformance / // The ComponentState envelope + zones-payload binary codec lives in component_state_io.h:
// serializeComponentState / serializeSelection + the deserializers and every version // it grows on every envelope bump and is consumed by the extension's preset-blob path too,
// constant) lives in component_state_io.h (Q-W2v split, T4-13 ≡ T2-07): the codec grows // so both artifacts share the codec while only the VST links the voice engine.
// 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.
} // namespace reasampler::instrument::map } // 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" #include "core/instrument/map/trigger_seam.h"
+11 -33
View File
@@ -1,25 +1,10 @@
// trigger_seam.hPURE Trigger-mode frames↔fraction converter for the S-VIEW-3 envelope seam. // trigger_seam — converts Trigger fade lengths between the engine domain (TriggerParams:
// NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. // 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)) // 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).
#pragma once #pragma once
@@ -27,25 +12,18 @@
namespace reasampler::instrument::map { namespace reasampler::instrument::map {
// The source-frame length of the Trigger played span: // postStart = max(0, frameCount - startFrame); playLength = round(lengthFraction * postStart).
// postStart = max(0, frameCount - startFrame) // `startFrame` is the effective start point (0 when absent). Returns 0 when postStart == 0
// playLength = round(lengthFraction * postStart) // or lengthFraction <= 0.
// `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.
std::int64_t triggerPlayLength(double lengthFraction, std::int64_t triggerPlayLength(double lengthFraction,
std::int64_t frameCount, std::int64_t frameCount,
std::int64_t startFrame); std::int64_t startFrame);
// Convert a source-frame fade count to a fraction of the play span (PACK direction, draw path). // PACK direction (draw path): frames -> fraction of play span. Not clamped here — the
// Returns 0.0 when playLength == 0 (degenerate sample or zero %-length); the fraction is // caller clamps to [0,1] when filling AmpEnvelope (envelope_edit owns that logic).
// NOT clamped — the caller clamps to [0,1] when filling AmpEnvelope so the overlay clamp logic
// stays in envelope_edit, not here.
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength); double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength);
// Convert a fade fraction to a source-frame count (UNPACK direction, commit path). // UNPACK direction (commit path): fraction -> nearest source frame.
// Rounds to nearest integer frame. Returns 0 when playLength == 0.
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength); std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength);
} // namespace reasampler::instrument::map } // 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 // browser_scroll.cpp — see browser_scroll.h. Pure scroll + search geometry; no host types.
// capture_browser. No host types; only the shared Rect + BrowserLayout.
#include "core/instrument/ui/browser_scroll.h" #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 <algorithm>
#include <cctype> #include <cctype>
@@ -50,11 +49,10 @@ VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int of
return vr; return vr;
} }
if (offset < 0) offset = 0; if (offset < 0) offset = 0;
// First visible ROW: the topmost row whose bottom edge is below the offset. Floor so a row // First row: floored so a row partially scrolled off the top still draws. Last row:
// partially scrolled off the top still draws (its lower part is visible). // 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; 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; const int lastRow = (offset + gridH - 1) / kBrowserCardHeight + 1;
int first = firstRow * columns; int first = firstRow * columns;
int last = lastRow * 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 trackLeft = trackRight - kScrollbarWidth;
const int trackTop = layout.grid.y; const int trackTop = layout.grid.y;
// Thumb height proportional to the visible fraction, floored at a grabbable minimum but // Thumb height proportional to the visible fraction, floored/capped to the track.
// never taller than the track.
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content); int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
thumbH = (std::max)(kMinThumbHeight, thumbH); thumbH = (std::max)(kMinThumbHeight, thumbH);
thumbH = (std::min)(thumbH, gridH); thumbH = (std::min)(thumbH, gridH);
// Thumb top proportional to the offset over the movable track span.
const int trackSpan = gridH - thumbH; // >= 0 const int trackSpan = gridH - thumbH; // >= 0
int thumbTop = trackTop; int thumbTop = trackTop;
if (maxOff > 0 && trackSpan > 0) { 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); const int gridH = (std::max)(0, layout.grid.height);
if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset); 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); int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
thumbH = (std::max)(kMinThumbHeight, thumbH); thumbH = (std::max)(kMinThumbHeight, thumbH);
thumbH = (std::min)(thumbH, gridH); thumbH = (std::min)(thumbH, gridH);
@@ -157,8 +153,6 @@ std::vector<int> filterNameIndices(const std::vector<std::string>& names,
return out; 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) { BrowseModal computeBrowseModal(int w, int h) {
constexpr int kBrowseFooterH = 30; constexpr int kBrowseFooterH = 30;
BrowseModal m; BrowseModal m;
+47 -76
View File
@@ -1,23 +1,16 @@
// browser_scroll.h — PURE scroll + type-to-filter geometry LAYERED over the S10 // browser_scroll.h — scroll + type-to-filter geometry layered over capture_browser. Mirror
// capture_browser. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of // of capture_browser/editor_geometry; the shell draws the clipped card window, scrollbar,
// capture_browser / editor_geometry: the fiddly scroll-window + scrollbar-thumb + search-box // and search field, and routes wheel/drag/keystrokes into these functions.
// 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.
// //
// WHY IT EXISTS (S12). capture_browser (S10) lays out EVERY card top-down and the shell // capture_browser lays out every card top-down and the shell clips at the browser bottom —
// clips at the browser bottom — a bank longer than the panel runs off with no way to reach // a bank longer than the panel has no way to reach the rest. This module adds scroll (a
// it (the S12 gap). This module adds the two things S12 layers over that stable geometry: // vertical pixel offset with max-offset clamp, visible-row window, scrollbar thumb, and
// * SCROLL — a vertical pixel offset into the card grid, with the max-offset clamp, the // thumb-drag<->offset mapping) and search (a case-insensitive name-substring filter that
// visible-row window, a scrollbar thumb rect, and the thumb-drag<->offset mapping so a // composes with capture_browser's bank filter — the shell applies the bank filter first,
// wheel tick or a thumb drag reaches every card; and // then this search narrows within it).
// * 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.
// //
// It holds NO card data and draws nothing — it knows only the browser layout (from // Holds no card data and draws nothing — knows only the browser layout, counts, and the
// capture_browser), COUNTS, and the scroll OFFSET the shell owns as transient UI state. It // scroll offset the shell owns as transient UI state.
// reuses capture_browser's BrowserLayout + the shared Rect (one geometry idiom).
#pragma once #pragma once
@@ -28,96 +21,74 @@
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
// The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell // Width of the vertical scrollbar gutter at the grid's right edge. When content fits (no
// draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so // scroll needed), scrollThumbRect returns empty and the shell may reclaim the gutter.
// 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.
inline constexpr int kScrollbarWidth = 10; inline constexpr int kScrollbarWidth = 10;
// The height (px) of the type-to-filter search box the shell draws ABOVE the tab strip (a // Height of the type-to-filter search box the shell draws above the tab strip.
// thin band spanning the browser width). Exposed so the shell reserves the band and tests // capture_browser's tab strip + grid sit below this band.
// agree. capture_browser's tab strip + grid sit BELOW this band (the shell offsets the
// BrowserLayout it feeds to capture_browser by kSearchBoxHeight).
inline constexpr int kSearchBoxHeight = 22; inline constexpr int kSearchBoxHeight = 22;
// The total pixel HEIGHT the card grid needs to draw all `cardCount` cards at `layout`'s // Total pixel height the card grid needs for `cardCount` cards at `layout`'s column
// column count: the number of ROWS (ceil(cardCount / columns)) times the fixed cell height. // count: rows (ceil(cardCount / columns)) times the fixed cell height.
// Zero cards -> 0. Pure — the content extent the scroll offset ranges over.
int scrollContentHeight(const BrowserLayout& layout, int cardCount); int scrollContentHeight(const BrowserLayout& layout, int cardCount);
// The maximum scroll offset (px): content height minus the visible grid height, floored at 0. // Maximum scroll offset: content height minus 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.
int scrollMaxOffset(const BrowserLayout& layout, int cardCount); int scrollMaxOffset(const BrowserLayout& layout, int cardCount);
// Clamp a proposed scroll offset into [0, scrollMaxOffset]. The shell clamps after every wheel // Clamps a proposed scroll offset into [0, scrollMaxOffset].
// tick / thumb drag so an over-scroll pins to an edge rather than showing past the last card
// or above the first. Pure.
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset); 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 // Half-open range of card indices [first, last) at least partially visible at scroll
// scroll `offset`. The shell draws only these cards (the S12 clip window) rather than every // `offset` (assumed pre-clamped). The shell draws only these cards.
// 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.
struct VisibleRange { struct VisibleRange {
int first = 0; // first card index drawn (inclusive) int first = 0;
int last = 0; // one past the last card index drawn (exclusive) int last = 0;
}; };
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset); 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 // Cell rect of card `index` shifted up by the scroll offset (the shell still adds the
// still adds the browser sub-area origin). Equivalent to capture_browser::cardCellRect with // browser sub-area origin).
// the offset subtracted from top/bottom. Pure — the one place the offset applies to a card.
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset); Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset);
// The vertical scrollbar THUMB rect within the grid's right-edge gutter, sized proportional to // Vertical scrollbar thumb rect within the grid's right-edge gutter, sized proportional
// the visible fraction (grid height / content height) and positioned proportional to the // to the visible fraction and positioned proportional to the scroll offset. Empty when
// scroll offset. Returns an EMPTY rect when the content fits (no scroll needed) — the shell // the content fits. A minimum thumb height keeps a tiny thumb grabbable on a long bank.
// 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.
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset); 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 // Maps a thumb-drag to a new (clamped) scroll offset: `startOffset` shifted by the pixel
// (`startOffset`) and the vertical pixel delta since grab (`dyPixels`), returns the new // delta scaled from thumb-track pixels to content pixels. A degenerate track or fitting
// (clamped) scroll offset: startOffset shifted by the delta scaled from thumb-track pixels to // content pins to startOffset.
// 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.
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels); 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 // Search-box rect: 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; // area. `w` is the browser sub-area width; the shell adds its origin.
// the shell adds its origin. A zero/negative width yields an empty rect. Pure.
Rect searchBoxRect(int w); Rect searchBoxRect(int w);
// True iff `name` contains `query` as a case-insensitive ASCII substring. An EMPTY query // 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 // matches everything.
// 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.
bool nameMatchesQuery(const std::string& name, const std::string& query); bool nameMatchesQuery(const std::string& name, const std::string& query);
// Narrow a list of display `names` to the INDICES whose name matches `query`, preserving // Narrows 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 // order. An empty query returns every index. Kept name-only (indices, not card structs)
// filter, no search" == today's browser). Kept name-only (indices, not card structs) so this // so this module stays free of the sample_map/bank_book chain — the shell applies the
// module stays free of the sample_map/bank_book chain — the shell owns the SampleChoice list // bank filter first, then feeds the surviving display names here.
// and applies the bank filter FIRST, then feeds the surviving display names here (search
// narrows within the bank). Pure.
std::vector<int> filterNameIndices(const std::vector<std::string>& names, std::vector<int> filterNameIndices(const std::vector<std::string>& names,
const std::string& query); 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 // 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 // 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 // covers the full window. Homed here (not editor_geometry) because the search-box
// this single layout so they never drift. Homed here (not editor_geometry) because the // height feeds it.
// search-box height feeds it — browser_scroll already owns the search/scroll geometry.
struct BrowseModal { struct BrowseModal {
Rect title; Rect title;
Rect back; // the "Back" title-band button Rect back;
Rect search; // the type-to-filter box (absolute) Rect search;
Rect content; // the browser sub-area (tabs + grid) — layoutBrowser's origin Rect content; // browser sub-area (tabs + grid) — layoutBrowser's origin
Rect cancel; // footer Cancel Rect cancel;
Rect confirm; // footer Load (confirm) Rect confirm;
}; };
BrowseModal computeBrowseModal(int w, int h); BrowseModal computeBrowseModal(int w, int h);
+3 -3
View File
@@ -8,9 +8,9 @@ namespace reasampler::instrument::ui {
namespace { namespace {
// The left edge of tab i in a strip of the given x-origin and width divided into `count` // Left edge of tab i in a strip divided into `count` equal segments (mirror of
// equal segments (mirror of mode_switch::segmentEdge). Every boundary derives from the same // mode_switch::segmentEdge). Same formula for every boundary so consecutive tabs share
// formula, so consecutive tabs share an exact edge and the last tab reaches x+width exactly. // an exact edge.
int tabEdge(int x, int width, int i, int count) { int tabEdge(int x, int width, int i, int count) {
return x + (i * width) / 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 // capture_browser.h — layout + hit-test for the capture-first editor's default face: a
// face: a scannable grid of capture CARDS with a bank-FILTER tab strip above it. NO VST3, // scannable grid of capture cards with a bank-filter tab strip above it. Mirror of
// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry / // editor_geometry/embed_strip/mode_switch; the shell draws thumbnails/names/badges and
// embed_strip / mode_switch: the fiddly card-grid + tab arithmetic lives here so it is // routes clicks into these functions.
// 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.
// //
// The browser replaces the old text item-list (the named anti-pattern). It lays out N // This module knows only counts and rects — it draws nothing and holds no sample data;
// cards in a fixed-cell grid that wraps across the browser width, and a horizontal tab // the shell owns the SampleChoice list, peak envelopes, and filter state.
// 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".
// //
// Scroll is NOT here (S12 layers it over this module). The browser lays out every card // Scroll is layered on top by browser_scroll — this module lays out every card top-down
// top-down; the shell clips at the browser's bottom until S12 adds a scroll offset. Keeping // and the shell clips at the bottom until a scroll offset is applied.
// 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).
#pragma once #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 { namespace reasampler::instrument::ui {
// Fixed browser metrics, exposed so the shell and tests agree. The card is sized to show a // Fixed browser metrics, exposed so the shell and tests agree.
// peak thumbnail with a name + badge line under it — scannable by eye, not a dense list. inline constexpr int kBrowserTabHeight = 26;
inline constexpr int kBrowserTabHeight = 26; // the bank-filter tab strip band height inline constexpr int kBrowserCardWidth = 132;
inline constexpr int kBrowserCardWidth = 132; // one card cell width (incl. gutter) inline constexpr int kBrowserCardHeight = 84;
inline constexpr int kBrowserCardHeight = 84; // one card cell height (incl. gutter) inline constexpr int kBrowserCardGutter = 8;
inline constexpr int kBrowserCardGutter = 8; // inset between the cell edge and the card inline constexpr int kBrowserThumbHeight = 44;
inline constexpr int kBrowserThumbHeight = 44; // the peak-thumbnail band inside a card
// The browser's regions, derived from the (w x h) area the shell allots it. Both clamp to // Clamped so a degenerate (tiny/zero) size never yields an inverted rect.
// the area so a degenerate (tiny/zero) size never yields an inverted rect.
struct BrowserLayout { struct BrowserLayout {
Rect tabStrip; // top: the bank-filter tabs Rect tabStrip;
Rect grid; // below the tabs: where the capture cards tile Rect grid;
int columns = 1; // cards per row in `grid` (>= 1); derived from grid.width 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 // Divide a (w x h) browser area into its regions and compute the column count. columns =
// inputs -> same layout. The tab strip takes a fixed height at the top (clamped so it never // max(1, grid.width/cardWidth) so a browser narrower than one card still lays out a
// exceeds the area); the grid takes the rest. columns = max(1, grid.width/cardWidth) so a // single column.
// browser narrower than one card still lays out a single column. A zero/negative size
// yields empty rects + columns==1.
BrowserLayout layoutBrowser(int w, int h); BrowserLayout layoutBrowser(int w, int h);
// The cell rect of capture card `index` (0-based) in the grid, laid out left-to-right then // Cell rect of capture card `index` (0-based), left-to-right then top-to-bottom across
// top-to-bottom across `columns`. This is the full CELL (card + gutter); cardContentRect // `columns`. This is the full cell (card + gutter); cardContentRect insets it.
// 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.
Rect cardCellRect(const BrowserLayout& layout, int index); Rect cardCellRect(const BrowserLayout& layout, int index);
// The drawable card rect inside a cell: the cell inset by kBrowserCardGutter on all sides. // 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.
Rect cardContentRect(const BrowserLayout& layout, int index); 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 // 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 // kBrowserThumbHeight (clamped to the card height).
// name + badge go in the remaining strip below. Pure.
Rect cardThumbnailRect(const BrowserLayout& layout, int index); Rect cardThumbnailRect(const BrowserLayout& layout, int index);
// The name/badge sub-rect below the thumbnail: the card content minus the thumbnail band. // Name/badge sub-rect below the thumbnail.
// The shell draws the display name + root/key badge here. Pure.
Rect cardLabelRect(const BrowserLayout& layout, int index); 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 // Card a click at (x, y) lands on, given `cardCount` cards, or -1 for a miss. Only the
// every card (in a gutter, past the last card, or on the tab strip). Only the card CONTENT // card content rect counts as a hit — a click in the inter-card gutter is a miss.
// rect counts as a hit — a click in the inter-card gutter is a miss. Pure.
int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y); 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): // Divides tabStrip into `tabCount` equal segments: one tab per bank plus a leading "All"
// one tab per bank_book bank plus a leading "All" tab the shell prepends, so tabCount == // tab the shell prepends. This module only divides the strip + hit-tests; the shell
// bankCount + 1 in practice. This module only divides the strip + hit-tests; the shell // supplies labels and tracks the active tab.
// 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).
// The rect of tab `index` (0-based) when the strip is divided into `tabCount` equal // Rect of tab `index` when the strip is divided into `tabCount` equal segments. The last
// segments. The last tab absorbs any width remainder so the tabs tile the whole strip with // tab absorbs any width remainder so the tabs tile the whole strip with no gap.
// no gap (mirror of mode_switch's segment split). A negative index or tabCount<=0 yields an
// empty rect. Pure.
Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index); 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); int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y);
} // namespace reasampler::instrument::ui } // 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 // curve_popup.h — sheet geometry + dismissal test for the velocity-curve popup editor.
// editor (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror // Mirror of overflow_menu; the shell draws through the L1 kit and routes clicks via
// of overflow_menu: the size-clamp / centering / title-row arithmetic lives here, unit-tested // these rects.
// 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.
// //
// THE POPUP (CONTEXT.md §S-VIEW r11). Summoned by the mini curve-preview button, a CENTERED // A centered sheet over the Sample face (a lighter wash than Browse's, since this is a
// SHEET over the Sample face (a 0.50-alpha bg/base wash behind it — lighter than Browse's // focused sub-editor, not a view change): width/height each clamp to a fraction of the
// 0.82; a focused sub-editor, not a view change): width clamp(60% of window, 360..520), // window within min/max bounds. A title row sits over the curve box. The curve box rect
// height clamp(55% of window, 260..380). Inside: a ~22px title row ("VELOCITY -> AMP" // here is the border rect — the shell derives the mapping box via its curveBoxFromRect
// micro-caps left, an 18x18 Close button right) over the full-size curve box filling the // formula, so the popup editor and the Zone-panel inline editor share coordinates.
// 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.
#pragma once #pragma once
@@ -19,7 +14,7 @@
namespace reasampler::instrument::ui { 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 kCurvePopupMinW = 360;
inline constexpr int kCurvePopupMaxW = 520; inline constexpr int kCurvePopupMaxW = 520;
inline constexpr int kCurvePopupMinH = 260; 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) inline constexpr int kCurvePopupPad = 8; // sheet inner padding (title inset + box margins)
struct CurvePopupLayout { struct CurvePopupLayout {
Rect sheet; // the bg/panel sheet, centered in the window Rect sheet;
Rect title; // the caption text rect (left part of the title row) Rect title;
Rect close; // the 18x18 Close (x) button, right-anchored in the title row Rect close; // Close (x) button, right-anchored in the title row
Rect curveBox; // the full-size curve editor BORDER rect (shell insets via curveBoxFromRect) 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 // Popup geometry for a (w x h) window: sheet width clamp(60% w, min..max) and height
// clamp(55% h, 260..380) — each additionally capped at the window dimension so a degenerate // clamp(55% h, min..max), each additionally capped at the window dimension so a
// window never yields an overhanging sheet — centered; title row + close button at the top; // degenerate window never yields an overhanging sheet — centered.
// the curve box filling the remainder inside kCurvePopupPad margins. Pure.
CurvePopupLayout computeCurvePopup(int w, int h); CurvePopupLayout computeCurvePopup(int w, int h);
// True when (x, y) lands OUTSIDE the sheet (on the wash) — the click-outside dismissal test. // True when (x, y) lands outside the sheet (on the wash) — the click-outside dismissal
// The shell additionally gates on "no drag in flight" (spec). Pure. // test. The shell additionally gates on "no drag in flight".
bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y); bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+16 -51
View File
@@ -8,8 +8,6 @@ namespace reasampler::instrument::ui {
namespace { 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 kTitleBarHeight = 28;
constexpr int kButtonMargin = 10; constexpr int kButtonMargin = 10;
constexpr int kButtonWidth = 120; constexpr int kButtonWidth = 120;
@@ -17,26 +15,18 @@ constexpr int kButtonHeight = 24;
} // namespace } // 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) { EditorLayout layoutEditor(int w, int h) {
// Clamp the surface to non-negative extents so a degenerate view can't produce // Clamp to non-negative extents so a degenerate view can't produce inverted rects.
// inverted rects.
const int cw = std::max(0, w); const int cw = std::max(0, w);
const int ch = std::max(0, h); const int ch = std::max(0, h);
EditorLayout out; EditorLayout out;
// Title bar spans the top, clamped so it never exceeds the client height.
const int titleH = std::min(kTitleBarHeight, ch); const int titleH = std::min(kTitleBarHeight, ch);
out.titleBar = Rect::ltrb(0, 0, cw, titleH); out.titleBar = Rect::ltrb(0, 0, cw, titleH);
// Canvas is everything below the title bar.
out.canvas = Rect::ltrb(0, titleH, cw, ch); out.canvas = Rect::ltrb(0, titleH, cw, ch);
// Button sits at the top-left of the canvas, inset by a margin, and is clamped to // Button inset from the canvas top-left, clamped so it never overhangs a small view.
// fit inside the canvas so it never overhangs on a small view.
const int bx = out.canvas.x + kButtonMargin; const int bx = out.canvas.x + kButtonMargin;
const int by = out.canvas.y + kButtonMargin; const int by = out.canvas.y + kButtonMargin;
const int bRight = std::min(bx + kButtonWidth, out.canvas.right()); 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) { int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) {
if (rowCount <= 0) return -1; 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 (x < layout.canvas.x || x >= layout.canvas.right()) return -1;
if (y < layout.canvas.y) 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; if (y >= layout.canvas.bottom()) return -1;
const int index = (y - layout.canvas.y) / kSampleRowHeight; const int index = (y - layout.canvas.y) / kSampleRowHeight;
if (index < 0 || index >= rowCount) return -1; 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); const Rect r = sampleRowRect(layout, index);
if (y >= r.bottom()) return -1; if (y >= r.bottom()) return -1;
return index; return index;
@@ -80,9 +66,6 @@ KeymapEditorLayout layoutKeymapEditor(int w, int h) {
out.base = layoutEditor(w, h); out.base = layoutEditor(w, h);
const Rect& canvas = out.base.canvas; 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 canvasW = std::max(0, canvas.width);
const int splitW = canvasW / kZonePanelFraction; // width of the zone panel const int splitW = canvasW / kZonePanelFraction; // width of the zone panel
const int splitX = std::max(canvas.x, canvas.right() - splitW); 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.sampleList = Rect::ltrb(canvas.x, canvas.y, splitX, canvas.bottom());
out.zonePanel = Rect::ltrb(splitX, canvas.y, canvas.right(), canvas.bottom()); out.zonePanel = Rect::ltrb(splitX, canvas.y, canvas.right(), canvas.bottom());
// "Add Zone" button spans the top of the zone panel, clamped to its height.
const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height)); const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height));
out.addZoneButton = out.addZoneButton =
Rect::ltrb(out.zonePanel.x, out.zonePanel.y, out.zonePanel.right(), Rect::ltrb(out.zonePanel.x, out.zonePanel.y, out.zonePanel.right(),
out.zonePanel.y + addH); out.zonePanel.y + addH);
// Zone rows stack below the button.
out.zoneRowArea = Rect::ltrb(out.zonePanel.x, out.addZoneButton.bottom(), out.zoneRowArea = Rect::ltrb(out.zonePanel.x, out.addZoneButton.bottom(),
out.zonePanel.right(), out.zonePanel.bottom()); out.zonePanel.right(), out.zonePanel.bottom());
return out; return out;
@@ -138,10 +119,8 @@ ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int
const Rect row = zoneRowRect(layout, index); const Rect row = zoneRowRect(layout, index);
if (y >= row.bottom()) return ZoneHit{}; if (y >= row.bottom()) return ZoneHit{};
// Seven mini-buttons pinned to the right edge, right-to-left: // Seven mini-buttons pinned to the right edge, each kZoneCtrlWidth wide, in slot
// delete, root+, root-, high+, high-, low+, low- // order 0..6; a click left of the leftmost is the label ("select").
// 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.
const ZoneField fields[7] = { const ZoneField fields[7] = {
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown, ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp, 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); 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 { namespace {
// Fixed band metrics (formerly the editor shell's anon-ns constants). constexpr int kHeroMinHeight = 150; // elastic hero's floor
constexpr int kHeroMinHeight = 150; // the elastic hero's floor (r11)
constexpr int kClusterHeight = 52; // root strip + preview + vel knob + curve btn + channel toggle constexpr int 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 // Cluster's fixed right-anchored run: Preview button, vel knob cell, curve button, Mono|Stereo.
// preview-velocity knob cell, the mini curve-preview button, Mono|Stereo).
constexpr int kPreviewBtnW = 64; constexpr int kPreviewBtnW = 64;
constexpr int kVelCellW = 48; // the Vel knob cell (deck cell grammar) constexpr int kVelCellW = 48;
constexpr int kCurveBtnSize = 28; // the square curve-preview button constexpr int kCurveBtnSize = 28;
// The S7 mono/stereo toggle segments.
constexpr int kChanSegW = 52; constexpr int kChanSegW = 52;
constexpr int kChanSegH = 18; constexpr int kChanSegH = 18;
} // namespace } // namespace
// r11 band order: title (fixed) -> hero (ELASTIC: absorbs all height left after the fixed // Band order: title (fixed) -> hero (elastic, absorbs remaining height, floor
// bands, floor kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom- // kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom-anchored). A
// anchored). When the window is too short for the floor (below the checkSizeConstraint // window too short for the floor keeps the hero at its floor and clips lower bands.
// minimum — a defensive case), the hero keeps its floor and the lower bands clip past the
// window bottom gracefully.
SampleBands computeSampleBands(int w, int h, int deckH) { SampleBands computeSampleBands(int w, int h, int deckH) {
SampleBands b; SampleBands b;
const int titleH = (std::min)(kTitleHeight, h); const int titleH = (std::min)(kTitleHeight, h);
@@ -214,7 +183,6 @@ SampleBands computeSampleBands(int w, int h, int deckH) {
return b; return b;
} }
// Draw + hit-test both derive from this ONE formula.
ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize) { ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize) {
ClusterRects r; ClusterRects r;
const int stripTop = cluster.y + (cluster.height - kStripBandHeight) / 2; 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()); 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 // Sits below the "+ Add Zone" affordance (top+4, height 20) with a 12px gap.
// gap, padded kPad horizontally. All call sites use this formula.
Rect zonesStripArea(const Rect& content) { Rect zonesStripArea(const Rect& content) {
const int stripTop = content.y + 4 + 20 + 12; // addR.bottom() + 12 const int stripTop = content.y + 4 + 20 + 12; // addR.bottom() + 12
return Rect::ltrb(content.x + kPad, stripTop, content.right() - kPad, return Rect::ltrb(content.x + kPad, stripTop, content.right() - kPad,
stripTop + kStripBandHeight); stripTop + kStripBandHeight);
} }
// Anchored off zonesStripArea.bottom() so the legend top tracks the strip bottom // Anchored off zonesStripArea.bottom() so the legend top tracks the strip bottom.
// without re-inlining the strip arithmetic here.
Rect noteEntryFieldsArea(const Rect& content) { Rect noteEntryFieldsArea(const Rect& content) {
const int stripBottom = zonesStripArea(content).bottom(); const int stripBottom = zonesStripArea(content).bottom();
const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom() + 8) const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom() + 8)
@@ -292,9 +258,8 @@ Rect zonesControlPanel(const Rect& content) {
content.bottom() - 4); content.bottom() - 4);
} }
// FB2 (R11-F2 parity): the deck lays out from the panel top (top-anchored), with a // Top-anchored; reserves a column at the panel's right for the curve-preview button so
// column at the panel's right reserved for the mini curve-preview button so no deck row // no deck row starts inside it.
// starts inside it.
Rect zonesDeckArea(const Rect& content) { Rect zonesDeckArea(const Rect& content) {
const Rect panel = zonesControlPanel(content); const Rect panel = zonesControlPanel(content);
return Rect::ltrb(panel.x, panel.y, panel.right() - kCurveBtnSize - kPad, panel.bottom()); 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_geometry.h — view geometry + hit-test for the VST3 IPlugView LICE editor. The
// editor (Phase S1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. // IPlugView shell owns window/bitmap/SWELL plumbing; the rectangle math and hit-testing
// // live here so they can be unit-tested outside the DAW.
// 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.
#pragma once #pragma once
@@ -16,101 +8,80 @@
namespace reasampler::instrument::ui { 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 Rect = ::reasampler::ui::Rect;
using ::reasampler::ui::contains; using ::reasampler::ui::contains;
// The regions the spike editor draws, derived from the current view size. All are // Title band + one button + remaining canvas, clamped so a degenerate (too-small) view
// clamped to the client area so a degenerate (too-small) view never yields a region // never yields a region spilling outside the surface.
// that spills outside the surface.
struct EditorLayout { struct EditorLayout {
Rect titleBar; // top band: the plugin name + a live-state readout Rect titleBar;
Rect button; // a single clickable button (proves hit-test routing) Rect button;
Rect canvas; // the remaining surface below the title bar Rect canvas;
}; };
// Divide a (w x h) client area into the spike editor's regions. Pure: the same // Divide a (w x h) client area into the editor's top-level regions. Pure.
// 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.
EditorLayout layoutEditor(int w, int h); EditorLayout layoutEditor(int w, int h);
// The editor's hit-test targets. kNone means the point landed on inert surface.
enum class HitTarget { enum class HitTarget {
kNone, kNone,
kButton, kButton,
}; };
// Classify a click at (x, y) against a layout. The button wins only when the point is // Classify a click at (x, y) against a layout.
// inside the button rect; everything else (including the title bar and empty canvas)
// is kNone in the spike.
HitTarget hitTest(const EditorLayout& layout, int x, int y); 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 // A vertical stack of fixed-height rows below the title bar; clicking a row selects that
// below the title bar; clicking a row selects that sample. This is the pure geometry: // sample. Pure geometry only — the shell draws names and routes the click.
// 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.
// The fixed row height (px) for one sample entry. Exposed so the shell and tests agree.
inline constexpr int kSampleRowHeight = 22; inline constexpr int kSampleRowHeight = 22;
// The rectangle for row `index` (0-based) of the sample list, laid out top-down inside // Rect for row `index` (0-based), laid out top-down inside the layout's canvas. Rows
// the layout's canvas. Rows beyond what the canvas can show are still computed (the // beyond what the canvas can show are still computed (the shell clips at paint time); a
// shell clips at paint time); a negative index yields an empty rect. Pure. // negative index yields an empty rect.
Rect sampleRowRect(const EditorLayout& layout, int index); 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 // Row index a click at (x, y) lands on given `rowCount` rows, or -1 for a click outside
// outside the list (above the first row, past the last, or on the title bar). Pure. // the list.
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y); 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 // Splits the canvas into a LEFT bank-sample list (the sample-selection rows above, reused
// Tier 0, reused for the "sample to add / fallback pick") and a RIGHT zone panel listing // as the "sample to add / fallback pick") and a RIGHT zone panel listing the performance
// the performance map's zones. An "Add Zone" button sits at the top of the zone panel; // map's zones. An "Add Zone" button sits at the top of the zone panel; each zone row
// each zone row carries small nudge/delete controls so the user can set the range and // carries nudge/delete mini-buttons (LICE has no native numeric entry field).
// 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.
// Fixed metrics for the zone panel, exposed so the shell and tests agree.
inline constexpr int kZoneRowHeight = 24; inline constexpr int kZoneRowHeight = 24;
inline constexpr int kZonePanelFraction = 2; // zone panel gets the RIGHT 1/2 of the canvas 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 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 // Clamps every rect to the canvas so a degenerate view still yields in-bounds rects.
// canvas so a degenerate view yields in-bounds rects.
struct KeymapEditorLayout { struct KeymapEditorLayout {
EditorLayout base; // title bar + canvas (the sample list uses base.canvas.x half) EditorLayout base;
Rect sampleList; // LEFT column: the bank-sample rows (sampleRowRect is relative here) Rect sampleList; // LEFT column
Rect zonePanel; // RIGHT column: the "Add Zone" button + the zone rows Rect zonePanel; // RIGHT column
Rect addZoneButton; // top of the zone panel 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); KeymapEditorLayout layoutKeymapEditor(int w, int h);
// The rectangle for bank-sample row `index` inside the LEFT sample list column of a // Rect for bank-sample row `index` inside the LEFT column. Negative index -> empty.
// keymap layout. Same fixed height as the Tier-0 list; laid out top-down inside
// sampleList. Negative index -> empty. Pure.
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index); 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); int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y);
// The rectangle for zone row `index` inside the zone panel's zoneRowArea. Negative // Rect for zone row `index` inside zoneRowArea. Negative index -> empty.
// index -> empty. Pure.
Rect zoneRowRect(const KeymapEditorLayout& layout, int index); Rect zoneRowRect(const KeymapEditorLayout& layout, int index);
// A zone row's interactive fields. The row is a horizontal strip: a label on the left, // A zone row's interactive fields: a label on the left, then seven fixed-width
// then seven fixed-width mini-buttons on the right (left-to-right: low-, low+, high-, high+, // mini-buttons on the right (low-, low+, high-, high+, root-, root+, delete). kZoneNone
// root-, root+, delete). kZoneNone means the click missed a control // means the click missed a control (e.g. the label) — the shell may still treat that as
// (e.g. on the label) — the shell may still treat that as "select this zone". // "select this zone".
enum class ZoneField { enum class ZoneField {
kZoneNone, kZoneNone,
kLowDown, kLowDown,
@@ -122,101 +93,90 @@ enum class ZoneField {
kDelete, kDelete,
}; };
// The result of hit-testing a click against the zone rows: which zone row (or -1) and // Which zone row (or -1) and which field within it a click landed on. A click on
// which field within it. A click on the "Add Zone" button is reported separately by // "Add Zone" is reported separately by addZoneHitTest.
// addZoneHitTest — this covers only the zone rows.
struct ZoneHit { struct ZoneHit {
int zoneIndex = -1; int zoneIndex = -1;
ZoneField field = ZoneField::kZoneNone; ZoneField field = ZoneField::kZoneNone;
}; };
// Classify a click at (x, y) against `zoneCount` zone rows. Returns {-1, kZoneNone} for a // Classify a click at (x, y) against `zoneCount` zone rows. {-1, kZoneNone} for a miss.
// click outside every zone row. Within a row, the seven mini-buttons occupy fixed-width // Within a row, the seven mini-buttons occupy fixed-width slots on the right edge; a
// slots on the right edge (left-to-right: low-, low+, high-, high+, root-, root+, delete); // click left of those slots is {index, kZoneNone} (the label area — "select").
// a click left of those slots is {index, kZoneNone} (the label area — "select"). Pure.
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y); 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); 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 // The capture-first editor's band/cluster/zone-surface layout math. Draw and hit-test
// reasampler_editor shell where it had accreted untestable (the §2 scope gap). Draw and // both derive every rect from these formulas so they can never drift; the shell only
// hit-test both derive every rect from these ONE formulas so they can never drift; the // draws + routes. The Browse-modal layout lives in browser_scroll (its search box
// shell only draws + routes. The Browse-modal layout lives in browser_scroll (its search // height feeds it).
// box height feeds it — dependency-clean placement beside its scroll/search siblings).
// Shared band metrics (the shell's remaining direct uses: horizontal padding + the
// title-band height; everything else is internal to the layout functions below).
inline constexpr int kPad = 8; inline constexpr int kPad = 8;
inline constexpr int kTitleHeight = 26; inline constexpr int kTitleHeight = 26;
inline constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons 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 // Sample-face bands (top->bottom): TITLE (name + Browse/Zone nav), a full-width elastic
// FULL-WIDTH ELASTIC HERO (absorbs all height left after the fixed bands, floor // HERO (absorbs all height left after the fixed bands, floored), the root+preview
// kHeroMinHeight), the ROOT + PREVIEW CLUSTER, and the bottom-anchored KNOB DECK // CLUSTER, and the bottom-anchored knob DECK (height `deckH` from knob_deck's wrap). A
// (height `deckH` from the pure knob_deck wrap). When the window is too short for the // window shorter than the hero floor clips the lower bands past the window bottom.
// hero floor (below the checkSizeConstraint minimum — defensive), the hero keeps its
// floor and the lower bands clip past the window bottom gracefully.
struct SampleBands { struct SampleBands {
Rect title; // top: name + Browse/Zone nav buttons Rect title;
Rect navBrowse; // the "Browse" title-band button Rect navBrowse;
Rect navZone; // the "Zone" title-band button Rect navZone;
Rect hero; // the FULL-WIDTH ELASTIC hero waveform + S-VIEW-3 envelope overlay Rect hero; // waveform + envelope overlay
Rect cluster; // root strip + preview + vel knob + curve button + channel toggle 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); SampleBands computeSampleBands(int w, int h, int deckH);
// The r11 cluster sub-rects: the root strip keeps the left side at REMAINDER width; the // Cluster sub-rects: the root strip keeps the left side at remainder width; the right
// right side is the fixed-width right-anchored run (Preview 64 · Vel knob cell 48 · curve // side is the fixed-width right-anchored run (Preview · vel knob cell · curve button ·
// preview button 28 · Mono|Stereo). `knobSize` is the deck knob square (knob_deck's // Mono|Stereo). `knobSize` is the deck knob square, passed in so this module does not
// kDeckKnobSize — passed in so this module does not depend on knob_deck). // depend on knob_deck.
struct ClusterRects { struct ClusterRects {
Rect rootStrip; // remainder-width fenced root strip Rect rootStrip;
Rect preview; // the preview-trigger button Rect preview;
Rect velCell; // the radial preview-velocity knob cell (knob + label band) Rect velCell; // preview-velocity knob cell (knob + label band)
Rect velKnob; // the knob square at the cell's top Rect velKnob;
Rect velLabel; // the label band beneath it Rect velLabel;
Rect curveBtn; // the mini curve-preview button (opens the popup) Rect curveBtn; // opens the curve-preview popup
}; };
ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize); ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize);
// The S7 mono/stereo toggle: a two-segment control right-anchored in `area`, vertically // Mono/stereo toggle: a two-segment control right-anchored in `area`, vertically centered.
// centered. Returns {mono-segment, stereo-segment}, side by side.
struct ChannelToggleRects { struct ChannelToggleRects {
Rect mono; Rect mono;
Rect stereo; Rect stereo;
}; };
ChannelToggleRects channelToggleRects(const Rect& area); 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); Rect zoneContentArea(int w, int h);
// The Zone/Browse "Back" title-band button (right-anchored — the same slot the Sample // Zone/Browse "Back" button — the same slot the Sample face's Zone nav button occupies.
// face's Zone nav button occupies).
Rect zoneBackRect(int w, int h); Rect zoneBackRect(int w, int h);
// The "+ Add Zone" affordance at the top of the Zone content, and the "Delete" button // "+ Add Zone" affordance and the "Delete" button beside it (Delete only draws/hits
// beside it (Delete only draws/hits when a zone is selected). // when a zone is selected).
Rect zoneAddRect(const Rect& content); Rect zoneAddRect(const Rect& content);
Rect zoneDeleteRect(const Rect& addR); Rect zoneDeleteRect(const Rect& addR);
// The Zone-view keyboard strip rect: below the "+ Add Zone" affordance with a 12px gap, // Zone-view keyboard strip rect: below "+ Add Zone" with a 12px gap, padded kPad
// padded kPad horizontally. // horizontally.
Rect zonesStripArea(const Rect& content); Rect zonesStripArea(const Rect& content);
// The S12 numeric-entry field ROW area inside the Zones legend (a band to the right of // Numeric-entry field row area inside the Zones legend, and the rect of field `f`
// the sample label), and the rect of field `f` (0=low, 1=high, 2=root) within it — // (0=low, 1=high, 2=root) within it — three equal segments left-to-right. Out-of-range
// three equal segments left-to-right. An out-of-range index yields an empty rect. // index yields an empty rect.
Rect noteEntryFieldsArea(const Rect& content); Rect noteEntryFieldsArea(const Rect& content);
Rect noteEntryFieldRect(const Rect& fields, int f); Rect noteEntryFieldRect(const Rect& fields, int f);
// The per-zone parameter panel below the strip + the one-line legend, running to the // Per-zone parameter panel below the strip + legend, running to the content bottom; the
// content bottom; the FB2 knob-deck area within it (a column at the right reserved for // knob-deck area within it (a right column reserved for the curve-preview button); and
// the mini curve-preview button); and that button's rect (the cluster's 28px square, // that button's rect (right-anchored at the panel top).
// right-anchored at the panel top).
Rect zonesControlPanel(const Rect& content); Rect zonesControlPanel(const Rect& content);
Rect zonesDeckArea(const Rect& content); Rect zonesDeckArea(const Rect& content);
Rect zonesCurveButton(const Rect& content); Rect zonesCurveButton(const Rect& content);
+5 -8
View File
@@ -8,17 +8,15 @@ namespace reasampler::instrument::ui {
namespace { namespace {
// Clamp a MIDI note to [0, kEmbedKeyCount-1].
int clampNote(int n) { int clampNote(int n) {
if (n < 0) return 0; if (n < 0) return 0;
if (n > kEmbedKeyCount - 1) return kEmbedKeyCount - 1; if (n > kEmbedKeyCount - 1) return kEmbedKeyCount - 1;
return n; return n;
} }
// Map a key boundary in [0, kEmbedKeyCount] to an x pixel inside a band of the given // Maps a key boundary (0..128) to an x pixel; keyEdge==128 maps to the band's right. A
// left/width. keyEdge is a boundary (0..128), so keyEdge==128 maps to the band's right. // zone's left uses floor(low) and its right uses floor(high+1), tiling adjacent zones
// Integer math, floored — a zone's left uses floor(low) and its right uses floor(high+1), // without a seam.
// which tiles adjacent zones without a seam.
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) { int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
if (keyEdge <= 0) return bandLeft; if (keyEdge <= 0) return bandLeft;
if (keyEdge >= kEmbedKeyCount) return bandLeft + bandWidth; if (keyEdge >= kEmbedKeyCount) return bandLeft + bandWidth;
@@ -33,9 +31,8 @@ EmbedLayout layoutEmbed(int w, int h) {
EmbedLayout out; EmbedLayout out;
// The level band takes a fixed height at the bottom, but never so much that the keymap // Fixed height at the bottom, but never so much that the keymap falls below its
// above it falls below its minimum (or that the band exceeds the area). On a very short // minimum; on a very short area the band yields to the keymap entirely.
// area the band yields to the keymap entirely.
int bandH = std::min(kEmbedLevelBandHeight, ch); int bandH = std::min(kEmbedLevelBandHeight, ch);
if (ch - bandH < kEmbedKeymapMinHeight) { if (ch - bandH < kEmbedKeymapMinHeight) {
bandH = std::max(0, ch - 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, // embed_strip.h — layout + hit-test for the embedded TCP/MCP strip. Mirror of
// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry / // editor_geometry/mode_switch; the embed shell marshals REAPER's embed messages (paint
// mode_switch: the fiddly rectangle math for the compact inline keymap/level strip lives // bitmap + mouse coords) into these functions.
// 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.
// //
// The strip is a single compact band REAPER draws inline in the track/mixer control panel // A single compact band REAPER draws inline in the track/mixer control panel via the
// (context TCP or MCP) via the Cockos embedded-UI surface. It shows: // Cockos embedded-UI surface: each performance zone as a horizontal segment across the
// * the zone layout — each performance zone as a horizontal segment across the keyboard // keyboard span (MIDI 0..127 mapped to the strip width), plus a thin activity level band
// span (MIDI 0..127 mapped to the strip width), so the keymap reads at a glance; and // at the bottom. Interaction is zone selection only — no editing.
// * 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.
#pragma once #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 { 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; inline constexpr int kEmbedKeyCount = 128;
// Fixed metrics for the strip, exposed so the shell and tests agree. // 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 kEmbedLevelBandHeight = 4;
inline constexpr int kEmbedKeymapMinHeight = 6; // keymap area collapses no smaller inline constexpr int kEmbedKeymapMinHeight = 6;
// One zone rendered on the strip: its inclusive MIDI key range. This is the minimal // One zone rendered on the strip: its inclusive MIDI key range the minimal projection
// projection of a PerformanceZone the strip needs (it does not carry sample ids or PCM // of a PerformanceZone the strip needs (no sample ids or PCM). Expected in [0,127] with
// the shell resolves labels; the strip only lays out ranges). lowNote/highNote are // low <= high; layout clamps defensively regardless.
// expected in [0,127] with low <= high, but the layout clamps defensively so a malformed
// zone never yields an out-of-strip rect.
struct EmbedZone { struct EmbedZone {
int lowNote = 0; int lowNote = 0;
int highNote = 127; int highNote = 127;
}; };
// The strip's regions, derived from the (w x h) embed area REAPER reports. Both clamp to // Clamped to the area so a degenerate (tiny) size never yields a region spilling outside
// the area so a degenerate (tiny) size never yields a region spilling outside the surface. // the surface.
struct EmbedLayout { struct EmbedLayout {
Rect keymap; // top: the zone-segment band (the compact keymap) Rect keymap; // top: zone-segment band
Rect levelBand; // bottom: the thin level/activity indicator Rect levelBand; // bottom: level/activity indicator
}; };
// Divide a (w x h) embed area into the strip's regions. Pure: same inputs -> same layout. // Divide a (w x h) embed area into the strip's regions. The level band takes a fixed
// The level band takes a fixed height at the bottom (clamped so it never exceeds the area // height at the bottom (clamped so it never starves the keymap below
// or starves the keymap below kEmbedKeymapMinHeight); the keymap takes the rest. A zero or // kEmbedKeymapMinHeight); the keymap takes the rest.
// negative size yields empty rects (no inversion).
EmbedLayout layoutEmbed(int w, int h); EmbedLayout layoutEmbed(int w, int h);
// The horizontal sub-rectangle of the keymap band for a zone spanning [lowNote, highNote] // Horizontal sub-rect of the keymap band for a zone spanning [lowNote, highNote]
// (inclusive). The 128-key span maps linearly across keymap.width; the returned rect // (inclusive). Spans the half-open pixel range so adjacent zones tile without a gap or
// spans the half-open pixel range [x(lowNote), x(highNote+1)) so adjacent zones (e.g. // overlap. Notes clamp to [0,127] and low clamps to <= high.
// 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.
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote); 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 // Zone a click at (x, y) lands on, given zones in draw order, or -1 for a miss. When
// outside the keymap band or on a key not covered by any zone. When zones overlap on a // zones overlap on a key, the first covering zone in order wins — mirroring the sampler
// key, the FIRST covering zone in order wins — mirroring the sampler core's first-match // core's first-match Keymap::resolve, so selection agrees with playback.
// Keymap::resolve and the editor's zone order, so selection agrees with playback. Pure.
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x, int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
int y); int y);
// The filled portion of the level band for a 0..1 level. Clamps level to [0,1]; the // Filled portion of the level band for a 0..1 level (clamped); left sub-rect of levelBand
// returned rect is the left sub-rectangle of levelBand whose width is level * band width // whose width is level * band width.
// (rounded down). level <= 0 -> empty rect; level >= 1 -> the whole band. Pure.
Rect levelFillRect(const EmbedLayout& layout, double level); Rect levelFillRect(const EmbedLayout& layout, double level);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+19 -43
View File
@@ -9,34 +9,28 @@ namespace reasampler::instrument::ui {
namespace { namespace {
// Matches envelope_overlay::timeToX. Zero when the area is degenerate (no motion).
// Seconds represented by one horizontal pixel under the overlay's linear time base. Zero when the
// area is degenerate (the caller then produces no motion). Matches envelope_overlay::timeToX.
double secondsPerPixel(const Rect& area, double totalSeconds) { double secondsPerPixel(const Rect& area, double totalSeconds) {
const int w = std::max(0, area.width); const int w = std::max(0, area.width);
if (w <= 0 || totalSeconds <= 0.0) return 0.0; if (w <= 0 || totalSeconds <= 0.0) return 0.0;
return totalSeconds / static_cast<double>(w); return totalSeconds / static_cast<double>(w);
} }
// Seconds per pixel for a GATE time-node drag (FA2): the reciprocal of the overlay's // Reciprocal of the overlay's gatePxPerSecond, matching gatePolyline's scale exactly so a
// param-domain gatePxPerSecond(area) scale — sample-length-free, matching // dragged handle tracks the cursor 1:1.
// 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.
double gateSecondsPerPixel(const Rect& area) { double gateSecondsPerPixel(const Rect& area) {
const double pps = gatePxPerSecond(area); const double pps = gatePxPerSecond(area);
return pps > 0.0 ? 1.0 / pps : 0.0; 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 // Matches envelope_overlay::levelToY (spans height-1 rows for [0,1]).
// pixel is 1/(height-1). Zero when degenerate. Matches envelope_overlay::levelToY.
double levelPerPixel(const Rect& area) { double levelPerPixel(const Rect& area) {
const int h = std::max(0, area.height); const int h = std::max(0, area.height);
if (h <= 1) return 0.0; if (h <= 1) return 0.0;
return 1.0 / static_cast<double>(h - 1); 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) { bool isDraggable(EnvNode n) {
switch (n) { switch (n) {
case EnvNode::Origin: 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 // Guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds in
// write: the degenerate baseline polyline carries a ReleaseEnd vertex regardless of mode, so a // Trigger mode (and vice versa). Applied by both the hit-test and the drag resolver.
// 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.
bool nodeInMode(EnvNode n, EnvMode m) { bool nodeInMode(EnvNode n, EnvMode m) {
switch (n) { switch (n) {
case EnvNode::AttackEnd: case EnvNode::AttackEnd:
@@ -64,7 +56,7 @@ bool nodeInMode(EnvNode n, EnvMode m) {
return m == EnvMode::Trigger; return m == EnvMode::Trigger;
case EnvNode::Origin: case EnvNode::Origin:
case EnvNode::ReleaseStart: case EnvNode::ReleaseStart:
return false; // never draggable in any mode (isDraggable filters these anyway) return false;
} }
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) { NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y) {
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds); const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds);
// NEAREST draggable, mode-matching node within the pick radius wins (Chebyshev distance // 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 // ties go to the earlier draw-order node. Only matters for Trigger's zero-fade-out
// coincide (the forward map enforces kGateNodeSepPx separation), so the tie-break only // coincidence (FadeOutStart overlaps LengthEnd and wins).
// 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.
NodeHit best; NodeHit best;
int bestDist = kNodeGrabRadius + 1; int bestDist = kNodeGrabRadius + 1;
for (const EnvVertex& v : poly) { for (const EnvVertex& v : poly) {
if (!isDraggable(v.node) || !nodeInMode(v.node, env.mode)) continue; 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)); 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; bestDist = dist;
best = NodeHit{true, v.node}; best = NodeHit{true, v.node};
} }
@@ -101,16 +90,12 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
const double secPerPx = secondsPerPixel(area, totalSeconds); const double secPerPx = secondsPerPixel(area, totalSeconds);
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
const double dSec = static_cast<double>(dxPixels) * secPerPx; 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); const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(area);
switch (node) { switch (node) {
// --- Gate: each cumulative-time node edits its OWN segment duration. Non-negative // Gate: each cumulative-time node edits its own segment duration. Non-negative durations
// durations ARE the monotonic-in-time guarantee (a node can never cross a neighbour // ARE the monotonic-in-time guarantee (a segment can never go negative, so a node can
// because every segment stays >= 0), so the [0, max] clamp is the whole constraint. // never cross a neighbour) — the [0, max] clamp is the whole constraint.
case EnvNode::AttackEnd: case EnvNode::AttackEnd:
out.attackSeconds = out.attackSeconds =
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds); 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); out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
break; break;
case EnvNode::DecayEnd: { case EnvNode::DecayEnd: {
// Sustain node: X sets decay time, Y sets sustain level (drag DOWN = higher y = lower // X sets decay time, Y sets sustain level (drag down = higher y = lower level).
// level, so subtract the level delta).
out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds); out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
const double lvlPerPx = levelPerPixel(area); const double lvlPerPx = levelPerPixel(area);
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx; 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); std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds);
break; break;
// --- Trigger: fades + length are FRACTIONS. X pixels convert to a fraction of the PLAYED // 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 // span (fades) or the whole sample (length). fadeIn + fadeOut <= 1 keeps the two fade
// two fade nodes never cross (each clamps against the other), and length in [0, max]. // nodes from crossing (each clamps against the other).
//
// 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.
case EnvNode::FadeInEnd: { case EnvNode::FadeInEnd: {
if (dxPixels == 0) break; // zero-motion grab: no param change, no division if (dxPixels == 0) break; // zero-motion grab: no param change, no division
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds; 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 // envelope_edit.h — node hit-test + pixel-delta -> clamped-param inverse map for the draggable
// draggable envelope nodes. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror // envelope nodes. Mirror of card_drag/waveform_view: drag arithmetic lives here, unit-tested
// of card_drag / waveform_view: the drag arithmetic + clamp/monotonic constraints live here, // outside the DAW; the shell draws handles, captures the grab, and feeds pixel deltas back in.
// 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.
// //
// TWO SURFACES, ONE MODEL. envelope_overlay owns the paramspolyline FORWARD map (draw); this // envelope_overlay owns the params->polyline forward (draw) map; this module owns the inverse
// module owns the pixel→params INVERSE map (edit) + node hit-test. Both read/write the SAME // (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the zone
// AmpEnvelope fields (the shell re-reads the zone every paint — no listener chain), so a node // every paint), so a node drag and a slider edit are two views on one source of truth.
// drag and a slider edit are two views on one source of truth and can never diverge.
// //
// THE INVARIANT (S-VIEW-F2). A drag can NEVER produce a param a slider couldn't: // A drag can never produce a param a slider couldn't: nodes are monotonic in time (clamped
// * MONOTONIC IN TIME — a node clamps between its time predecessor and successor, so attack-end // between time predecessor/successor) and range-clamped to the same per-param [min,max] the
// can't pass hold-end, decay can't pass release, etc. Each segment stays >= 0. // slider uses (EnvClampBounds, caller-supplied since those maxima live shell-side).
// * 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.
// //
// WHICH AXES. Time-only nodes (AttackEnd, HoldEnd, ReleaseEnd; FadeInEnd, FadeOutStart, // Time-only nodes drag on X; DecayEnd (the sustain node) drags on both axes (X = decay time,
// LengthEnd) drag on X only. The sustain node (DecayEnd) drags on BOTH axes — its X sets the // Y = sustain level). Origin and the drawing-only ReleaseStart are not draggable. A node is only
// decay time, its Y sets the sustain level (the standard ADSR-editor grammar). Origin and the // editable in its own mode (Gate nodes ignore drags in Trigger mode and vice versa).
// 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.
#pragma once #pragma once
@@ -47,60 +24,46 @@
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
// The pick radius (px) around a node's drawn point: a grab within this many pixels (in BOTH x and // Pick radius (px) around a node's drawn point, in both x and y. Mirrors waveform_view's
// y) of a node handle grabs it. Mirrors waveform_view's kMarkerGrabWidth — wide enough to grab a // kMarkerGrabWidth.
// small handle comfortably, narrow enough that adjacent nodes stay distinguishable.
inline constexpr int kNodeGrabRadius = 6; inline constexpr int kNodeGrabRadius = 6;
// The per-param clamp bounds the shell supplies (the SAME maxima its sliders map 0..1 onto). All // Per-param clamp bounds the shell supplies the same maxima its sliders map [0,1] onto.
// are upper bounds in the param's own domain; the lower bound is 0 (each stage >= 0), and the // Lower bound is always 0; the monotonic-in-time constraint tightens further at edit time.
// monotonic-in-time constraint tightens these further at edit time. Defaults are conservative // Defaults are placeholders; the shell overrides with its live slider domain.
// placeholders; the shell OVERRIDES them with its live slider domain so the clamp matches exactly.
struct EnvClampBounds { struct EnvClampBounds {
double maxAttackSeconds = 4.0; // upper bound of the attack slider double maxAttackSeconds = 4.0;
double maxHoldSeconds = 4.0; double maxHoldSeconds = 4.0;
double maxDecaySeconds = 4.0; double maxDecaySeconds = 4.0;
double maxReleaseSeconds = 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 maxFadeInFraction = 1.0;
double maxFadeOutFraction = 1.0; double maxFadeOutFraction = 1.0;
double maxLengthFraction = 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 // Which node a grab at (x, y) lands on, given the current envelope/rect/duration (the same
// duration (the same inputs buildEnvelopePolyline drew from, so the grab tests the drawn handles). // inputs buildEnvelopePolyline drew from). `hit` is false for a point off every draggable node;
// Returns EnvNode::Origin's NON-membership as a miss via the bool return: `hit` is false for a // Origin/ReleaseStart and nodes from the other mode never hit. Nearest node within the radius
// point off every DRAGGABLE node. Origin and ReleaseStart are never returned (not draggable), // wins (Chebyshev distance); an exact tie goes to the earlier draw-order node — this only matters
// and a node from the OTHER mode is never returned (the degenerate baseline's ReleaseEnd vertex // for Trigger's zero-fade-out coincidence (FadeOutStart overlaps LengthEnd and wins, so the fade
// is not grabbable in Trigger mode). The NEAREST node within the radius wins (Chebyshev // can be dragged open from zero). Gate nodes never coincide (forward map enforces
// distance); an exact tie goes to the earlier draw-order node (FA2 — deterministic). Gate nodes // kGateNodeSepPx), so every Gate handle is independently grabbable.
// 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.
struct NodeHit { struct NodeHit {
bool hit = false; bool hit = false;
EnvNode node = EnvNode::Origin; // meaningful only when hit == true EnvNode node = EnvNode::Origin; // meaningful only when hit == true
}; };
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y); 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` — // Resolves a drag of `node` to a new AmpEnvelope. `grabEnv` is the envelope as of grab time (the
// the shell snapshots it on WM_LBUTTONDOWN so the delta is absolute, not accumulated), the overlay // shell snapshots it on button-down so the delta is absolute, not accumulated); `dxPixels`/
// rect + sample duration (the pixel↔param maps), the caller's clamp bounds, and the pixel delta // `dyPixels` is the pixel delta since grab.
// since grab (`dxPixels`, `dyPixels`), returns the envelope the node should now describe: // * X delta -> the node's time param, shifted via the same linear map as timeToX, clamped to
// * X delta -> the node's TIME param, shifted proportionally (same linear map as timeToX), // [0, per-param max] and to its monotonic-in-time neighbours.
// clamped to [0, per-param max] AND to its monotonic-in-time neighbours (>= predecessor time, // * Y delta -> the level param, only for DecayEnd; clamped to [0,1]. Ignored for time-only nodes.
// <= successor time). For a cumulative-time node the shift lands on that node's OWN segment // * A non-draggable node, an other-mode node, a zero-size area, or totalSeconds <= 0 returns
// duration (e.g. dragging HoldEnd changes holdSeconds, not attack). // `grabEnv` unchanged.
// * Y delta -> the LEVEL param, but ONLY for the sustain node (DecayEnd); clamped to [0,1]. // Only the dragged node's param(s) change. Pure.
// 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).
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area, AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
double totalSeconds, const EnvClampBounds& bounds, double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels); int dxPixels, int dyPixels);
+18 -29
View File
@@ -8,15 +8,14 @@
namespace reasampler::instrument::ui { 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) { int timeToX(const Rect& area, double totalSeconds, double t) {
const int w = std::max(0, area.width); const int w = std::max(0, area.width);
if (w <= 0 || totalSeconds <= 0.0) return area.x; if (w <= 0 || totalSeconds <= 0.0) return area.x;
if (t < 0.0) t = 0.0; if (t < 0.0) t = 0.0;
// Linear map, clamped on BOTH sides (FA2 bounds invariant): t past totalSeconds pins to the // Clamp in double space before the int cast — a huge t would overflow a 32-bit long
// last in-bounds column area.right()-1. Clamp in DOUBLE space BEFORE the integer cast — a huge // (Windows) and wrap to the wrong edge.
// t would overflow a 32-bit long (Windows) and wrap to the WRONG edge — then round.
double px = (t / totalSeconds) * static_cast<double>(w); double px = (t / totalSeconds) * static_cast<double>(w);
if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1); if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1);
return area.x + static_cast<int>(px + 0.5); return area.x + static_cast<int>(px + 0.5);
@@ -33,9 +32,7 @@ int gateTimedWidth(const Rect& area) {
double gatePxPerSecond(const Rect& area) { double gatePxPerSecond(const Rect& area) {
const int timedW = gateTimedWidth(area); const int timedW = gateTimedWidth(area);
if (timedW <= 0) return 0.0; if (timedW <= 0) return 0.0;
// Usable width = timed region minus the four per-segment separation bases and the last // Minus the four per-segment separation bases and the last in-bounds column, floored at 1.
// 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).
const double usable = const double usable =
std::max(1.0, static_cast<double>(timedW - 1 - 4 * kGateNodeSepPx)); std::max(1.0, static_cast<double>(timedW - 1 - 4 * kGateNodeSepPx));
return usable / (4.0 * kGateStageMaxSeconds); return usable / (4.0 * kGateStageMaxSeconds);
@@ -46,8 +43,8 @@ int levelToY(const Rect& area, double level) {
if (h <= 0) return area.y; if (h <= 0) return area.y;
if (level < 0.0) level = 0.0; if (level < 0.0) level = 0.0;
if (level > 1.0) level = 1.0; if (level > 1.0) level = 1.0;
// Level 1 -> top row, level 0 -> bottom row (bottom-1 under the half-open convention). The // Level 1 -> top row, level 0 -> bottom row; spans (h-1) px so both endpoints land on a
// range spans (h-1) pixels so both endpoints land ON a drawable row. // drawable row.
const int span = h - 1; const int span = h - 1;
const long dy = static_cast<long>((1.0 - level) * static_cast<double>(span) + 0.5); const long dy = static_cast<long>((1.0 - level) * static_cast<double>(span) + 0.5);
return area.y + static_cast<int>(dy); 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; return v;
} }
// One Gate vertex from a pixel offset inside the area (the Gate schematic works in px space — // Gate works in px space (timed px + the fixed sustain-plateau reserve) rather than the plain
// timed px + the fixed sustain-plateau reserve — not through the plain timeToX map). Clamps x in // timeToX map; clamps in double space before the int cast for the same overflow reason as above.
// DOUBLE space to the last in-bounds column BEFORE the integer cast (FA2 bounds invariant; a
// huge px would overflow a 32-bit long on Windows and wrap to the WRONG edge).
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) { EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
const int w = std::max(1, area.width); const int w = std::max(1, area.width);
if (px < 0.0) px = 0.0; if (px < 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) { 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 a = std::max(0.0, env.attackSeconds);
const double h = std::max(0.0, env.holdSeconds); const double h = std::max(0.0, env.holdSeconds);
const double d = std::max(0.0, env.decaySeconds); const double d = std::max(0.0, env.decaySeconds);
const double r = std::max(0.0, env.releaseSeconds); const double r = std::max(0.0, env.releaseSeconds);
const double sus = clamp01(env.sustainLevel); const double sus = clamp01(env.sustainLevel);
// BOUNDED SCHEMATIC (FA2): A/H/D and R map onto the TIMED region (canvas minus the reserved // A/H/D/R map onto the timed region at the param-domain scale, each segment getting a
// sustain-plateau width) at the PARAM-DOMAIN scale — sample-length-free — and every segment // kGateNodeSepPx base so nodes never coincide even at the tier-0 zero-hold/zero-decay
// gets a kGateNodeSepPx base so consecutive nodes never coincide (every node individually // defaults. The sustain plateau is the fixed reserve between DecayEnd and ReleaseStart.
// grabbable at any params, incl. the tier-0 zero-hold/zero-decay defaults). The sustain
// plateau is the fixed reserve between DecayEnd and ReleaseStart.
const int W = std::max(1, area.width); const int W = std::max(1, area.width);
const double sustainPx = static_cast<double>(W - gateTimedWidth(area)); const double sustainPx = static_cast<double>(W - gateTimedWidth(area));
const double sep = static_cast<double>(kGateNodeSepPx); 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 xPlateau = xDecay + sustainPx; // ReleaseStart (schematic note-off)
double xRelease = xPlateau + sep + r * pps; // ReleaseEnd double xRelease = xPlateau + sep + r * pps; // ReleaseEnd
// Right-edge overrun (a stored stage beyond the schematic domain): compress from the RIGHT // Overrun beyond the schematic domain compresses from the right, preserving minimum gaps so
// preserving the minimum gaps, so trailing nodes stay individually separated instead of // trailing nodes stay separated instead of piling on the last column. This re-floor only
// piling on the last column. The re-floor pass only bites when the canvas is too narrow to // bites when the canvas is too narrow to hold the gaps at all — gateVtx's clamp wins then.
// hold the minimum gaps at all — then gateVtx's [0, W-1] clamp wins (in-bounds > separation).
const double xMax = static_cast<double>(W - 1); const double xMax = static_cast<double>(W - 1);
if (xRelease > xMax) { if (xRelease > xMax) {
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, std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds) { 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); const double len = clamp01(env.lengthFraction);
double fadeIn = clamp01(env.fadeInFraction); double fadeIn = clamp01(env.fadeInFraction);
double fadeOut = clamp01(env.fadeOutFraction); double fadeOut = clamp01(env.fadeOutFraction);
// Fades cannot overlap: clamp so fadeIn + fadeOut <= 1 (of the played span), mirroring the // Fades cannot overlap; trim fade-out first, matching the engine's TriggerParams clamp.
// engine's TriggerParams clamp. Trim the LATER fade (fade-out) first, matching the engine.
if (fadeIn + fadeOut > 1.0) fadeOut = std::max(0.0, 1.0 - fadeIn); if (fadeIn + fadeOut > 1.0) fadeOut = std::max(0.0, 1.0 - fadeIn);
const double playSeconds = len * totalSeconds; 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, std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds) { double totalSeconds) {
if (area.width <= 0 || area.height <= 0 || totalSeconds <= 0.0) { if (area.width <= 0 || area.height <= 0 || totalSeconds <= 0.0) {
// Degenerate surface: a two-point flat baseline at level 0 so the shell always has a line. // Degenerate surface: flat two-point baseline so the shell always has a line.
return {vtx(EnvNode::Origin, area, 1.0, 0.0, 0.0), return {vtx(EnvNode::Origin, area, 1.0, 0.0, 0.0),
vtx(EnvNode::ReleaseEnd, area, 1.0, 1.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) return env.mode == EnvMode::Gate ? gatePolyline(env, area)
: triggerPolyline(env, area, totalSeconds); : 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.h — amp-envelope -> polyline geometry for the Sample-view envelope overlay.
// envelope overlay. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of // Engine-free by design (no sample_map/sampler_core dependency); mirror of waveform_view /
// waveform_view / param_slider: the params→pixel polyline math lives here, unit-tested outside // param_slider. The shell packs the zone's AdsrSeconds/TriggerParams into AmpEnvelope and draws
// the DAW, while the editor shell (reasampler_editor.cpp) traces the polyline in an accent hue // the polyline plus a handle at each node (envelope_edit does the hit-test).
// 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.
#pragma once #pragma once
@@ -64,166 +12,96 @@
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
// The play mode the overlay draws — a LOCAL mirror of sampler_core's PlayMode kept here so the // Local mirror of sampler_core's PlayMode, kept here so this module stays engine-free.
// geometry module stays engine-free (the shell maps the zone's PlayMode to this). Same two cases.
enum class EnvMode { Gate, Trigger }; enum class EnvMode { Gate, Trigger };
// Which breakpoint a polyline vertex / node is. The shell draws a draggable handle at each of // Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(sustain) -> ReleaseStart -> ReleaseEnd.
// these; envelope_edit hit-tests against them. Kept in one enum shared by overlay + edit so the // Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd).
// forward map (draw) and inverse map (edit) name the same nodes. // Shared by envelope_overlay (forward/draw map) and envelope_edit (inverse/edit map).
//
// 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.
enum class EnvNode { enum class EnvNode {
Origin, // t=0, level 0 (both modes) — not draggable (fixed anchor) Origin, // t=0, level 0 — not draggable
AttackEnd, // Gate: top of the attack ramp (level 1) — X sets attackSeconds AttackEnd, // Gate: attack ramp top — sets attackSeconds
HoldEnd, // Gate: end of the hold plateau (level 1) — X sets holdSeconds HoldEnd, // Gate: hold plateau end — sets holdSeconds
DecayEnd, // Gate: decay settles to sustain — the SUSTAIN node (X sets decaySeconds, DecayEnd, // Gate: decay settles to sustain — sets decaySeconds (X) and sustainLevel (Y)
// Y sets sustainLevel) ReleaseStart, // Gate: sustain plateau end — drawing-only, not draggable
ReleaseStart, // Gate: end of the sustain plateau / start of the release (sustain level) — ReleaseEnd, // Gate: release tail end — sets releaseSeconds
// a DRAWING vertex only, not a draggable handle (release is edited at FadeInEnd, // Trigger: fade-in top — sets fadeInFraction
// ReleaseEnd; this vertex sits a fixed sustain-plateau width right of FadeOutStart, // Trigger: fade-out start — sets fadeOutFraction
// DecayEnd — the schematic note-off — Y = sustain level) LengthEnd, // Trigger: playEnd terminal — sets lengthFraction
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
}; };
// The amp-envelope params the overlay draws — the small view struct the shell packs from the // Amp-envelope params the overlay draws. Trigger's fadeIn/fadeOutFraction are derived from
// zone's stored AdsrSeconds / TriggerParams. Engine-free by design (no sampler_core include). // TriggerParams' frame counts, not a direct field copy — see the trigger_seam gotcha in
// // core/instrument/CLAUDE.md.
// 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.
struct AmpEnvelope { struct AmpEnvelope {
EnvMode mode = EnvMode::Gate; EnvMode mode = EnvMode::Gate;
// Gate (AHDSR), seconds + a dimensionless sustain level. // Gate (AHDSR): seconds, plus a dimensionless sustain level.
double attackSeconds = 0.003; double attackSeconds = 0.003;
double holdSeconds = 0.0; double holdSeconds = 0.0;
double decaySeconds = 0.0; double decaySeconds = 0.0;
double sustainLevel = 1.0; double sustainLevel = 1.0;
double releaseSeconds = 0.060; double releaseSeconds = 0.060;
// Trigger, fractions of the play span (fadeIn/fadeOut) and of the post-start length. // Trigger: fractions of the played span.
// NOTE: fadeInFraction/fadeOutFraction are DERIVED from TriggerParams::fadeInFrames/ double lengthFraction = 1.0;
// fadeOutFrames — see the TRIGGER SEAM note above. A converter is owed on both the double fadeInFraction = 0.0;
// pack (draw) and unpack (commit) paths; these fields are NOT a direct TriggerParams copy. double fadeOutFraction = 0.0;
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)
}; };
// One polyline vertex: a pixel point plus which node it is. The shell draws a line through the // One polyline vertex: pixel point plus which node it is. level is redundant with y, carried for
// points in order (the amp curve) and a draggable handle at each vertex whose node is not Origin. // inspection.
// Level is carried alongside (0..1) for callers that want to label/inspect; it is redundant with y.
struct EnvVertex { struct EnvVertex {
EnvNode node = EnvNode::Origin; EnvNode node = EnvNode::Origin;
int x = 0; // pixel x inside the overlay rect int x = 0;
int y = 0; // pixel y inside the overlay rect (top = level 1, bottom = level 0) int y = 0;
double level = 0.0; // 0..1, the vertex's amplitude (redundant with y; for inspection) double level = 0.0;
bool operator==(const EnvVertex& o) const { bool operator==(const EnvVertex& o) const {
return node == o.node && x == o.x && y == o.y && level == o.level; 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 // Fraction of canvas width reserved for the Gate sustain-plateau display; the remaining width
// plateau is a fixed-width schematic region between DecayEnd and ReleaseStart; the remaining // carries A/H/D/R at the param-domain scale. Shared with envelope_edit.
// 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).
inline constexpr double kGateSustainDisplayFraction = 0.15; inline constexpr double kGateSustainDisplayFraction = 0.15;
// The minimum pixel separation between consecutive Gate polyline nodes: every Gate segment gets // Minimum pixel separation between consecutive Gate nodes, so zero-duration stages (tier-0
// this many px as a base, PLUS its time-proportional extent, so zero-duration stages (tier-0 // defaults) still render as distinct, grabbable handles. Larger than envelope_edit's grab
// defaults: hold 0, decay 0) still render as distinct, individually grabbable handles. Chosen // radius (6) so a click can never tie between neighbours.
// 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.
inline constexpr int kGateNodeSepPx = 8; inline constexpr int kGateNodeSepPx = 8;
// The Gate schematic's per-stage time domain (seconds): the timed region represents the four // Gate schematic's per-stage time domain (seconds) the timed region represents four stages
// stages end-to-end at this maximum each (4 x this total). MIRRORS the shell's stage-slider // end-to-end at this max each. Must match the shell's stage-slider ceiling so a maxed slider
// ceiling (kEnvTimeMaxSeconds in reasampler_editor.cpp) — keep the two equal so a stage at its // lands exactly at the canvas edge.
// 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.
inline constexpr double kGateStageMaxSeconds = 2.0; inline constexpr double kGateStageMaxSeconds = 2.0;
// The pixel width of the Gate timed region: area.width minus the sustain-plateau reserve, // Pixel width of the Gate timed region (area width minus the sustain reserve), floored at 1 for
// floored at 1 px so the px<->seconds scale never degenerates for a non-empty area. Returns 0 // a non-empty area; 0 for a zero/negative-width area.
// for a zero/negative-width area. Shared by gatePolyline and envelope_edit's gate drag scale.
int gateTimedWidth(const Rect& area); int gateTimedWidth(const Rect& area);
// Pixels per second of the Gate timed region under the PARAM-DOMAIN scale: the timed width, // Pixels per second of the Gate timed region, independent of the sample's actual duration.
// minus the four per-segment kGateNodeSepPx bases and the last in-bounds column, spread over // Shared by buildEnvelopePolyline and envelope_edit's drag inverse so a dragged handle tracks
// 4 x kGateStageMaxSeconds. Independent of the sample's duration. Returns 0 for a // the cursor 1:1.
// 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.
double gatePxPerSecond(const Rect& area); double gatePxPerSecond(const Rect& area);
// Map an amp envelope to its polyline vertices inside `area`, over a sample of `totalSeconds` // Maps an amp envelope to polyline vertices inside `area` over a sample of `totalSeconds`
// wall-clock duration. `area` is the waveform rect (left/top inclusive, right/bottom exclusive); // duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); vertices
// y maps level 0..1 across [area.bottom()-1 .. area.y] (level 1 at the TOP). The polyline reads // are in draw order, Origin first.
// left-to-right in draw order, Origin first.
// //
// TIME BASE (FA2). // Gate's x-axis is a bounded schematic independent of totalSeconds (does NOT line up with the
// * Gate: a bounded schematic, INDEPENDENT of totalSeconds. The canvas splits into a TIMED // waveform under it); Trigger's x-axis is PCM-aligned wall-clock. Every vertex is clamped inside
// region of gateTimedWidth(area) px — where attack/hold/decay run from t=0 and the release // the canvas: x in [area.x, area.right()-1], y in [area.y, area.bottom()-1]. A degenerate area
// ramp runs after the plateau, at the gatePxPerSecond(area) PARAM-DOMAIN scale, each segment // or totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0].
// 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.
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area, std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds); double totalSeconds);
// Map a time (seconds) to a pixel x inside `area`: t=0 -> area.x, t=totalSeconds -> // Maps a time (seconds) to a pixel x inside `area`, linear and clamped at both ends. Shared
// area.right()-1, linear, CLAMPED on both sides (t < 0 pins to area.x; t past totalSeconds pins // with envelope_edit's node hit-test so the drawn handle and its grab region agree.
// to area.right()-1 — the in-bounds invariant, FA2). A zero-width area or totalSeconds <= 0 yields
// area.x. Pure — the shared time->x map the Trigger polyline and the node hit-test
// (envelope_edit) use, so the drawn handle and its grab region agree.
int timeToX(const Rect& area, double totalSeconds, double t); 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 // Maps a level [0,1] to a pixel y inside `area` (level 1 at the top, 0 at the bottom row),
// (so the full-amplitude line sits at the top edge and silence at the bottom pixel row). level is // clamped. Shared with envelope_edit's node hit-test.
// clamped to [0,1]. A zero-height area yields area.y. Pure — the shared level->y map the polyline
// and the node hit-test share.
int levelToY(const Rect& area, double level); int levelToY(const Rect& area, double level);
} // namespace reasampler::instrument::ui } // namespace reasampler::instrument::ui
+8 -22
View File
@@ -14,10 +14,8 @@ int clampNote(int n) {
return n; return n;
} }
// Map a key BOUNDARY in [0, kStripKeyCount] to an x pixel inside a band of the given // Maps a key boundary (0..128) to an x pixel. Key N's left is keyEdgeToX(N), right is
// left/width. keyEdge is a boundary (0..128): 0 -> band left, 128 -> band right. Integer // keyEdgeToX(N+1) — tiles adjacent keys/zones without a seam. Mirrors embed_strip::keyEdgeToX.
// 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).
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) { int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
if (keyEdge <= 0) return bandLeft; if (keyEdge <= 0) return bandLeft;
if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth; if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth;
@@ -37,8 +35,7 @@ StripLayout layoutStrip(int w, int h) {
int keyLeftX(const StripLayout& layout, int note) { int keyLeftX(const StripLayout& layout, int note) {
const Rect& band = layout.keys; const Rect& band = layout.keys;
const int bandWidth = std::max(0, band.width); const int bandWidth = std::max(0, band.width);
// note is a KEY here (0..127); its left edge is boundary `note`. Callers pass note+1 to // note is a key (0..127); callers pass note+1 to get its right edge, 128 -> band right.
// get a key's right edge, and 128 maps to the band right.
const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note); const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note);
return keyEdgeToX(band.x, bandWidth, edge); 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; if (!contains(band, x, y)) return -1;
const int bandWidth = std::max(0, band.width); const int bandWidth = std::max(0, band.width);
if (bandWidth <= 0) return -1; if (bandWidth <= 0) return -1;
// Invert keyEdgeToX: the key whose half-open [leftX, rightX) contains x. Floor-divide // Inverts keyEdgeToX: the key whose half-open [leftX, rightX) contains x.
// the pixel offset back to a key; clamp defensively (a point on band.right()-1 maps to 127).
const int offset = x - band.x; const int offset = x - band.x;
int note = (offset * kStripKeyCount) / bandWidth; int note = (offset * kStripKeyCount) / bandWidth;
return clampNote(note); 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) { Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) {
int lo = clampNote(lowNote); int lo = clampNote(lowNote);
int hi = clampNote(highNote); 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 leftX = keyLeftX(layout, lo);
const int rightX = keyLeftX(layout, hi + 1); const int rightX = keyLeftX(layout, hi + 1);
return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom()); 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; if (!contains(bar, x, y)) return ZoneGrab::kNone;
const int barW = bar.width; const int barW = bar.width;
// A narrow bar (< 2*edge) has no body: split at the midpoint, LOW edge wins the tie so // A narrow bar has no body: split at the midpoint, low edge wins the tie.
// a click exactly on the midpoint resizes low (deterministic).
if (barW < 2 * kStripEdgeGrabWidth) { if (barW < 2 * kStripEdgeGrabWidth) {
const int mid = bar.x + barW / 2; const int mid = bar.x + barW / 2;
return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge; 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) { bool isNaturalKey(int note) {
// Clamp to the valid MIDI range before indexing.
const int n = note < 0 ? 0 : (note > kStripKeyCount - 1 ? kStripKeyCount - 1 : note); 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] = { static constexpr bool kNatural[12] = {
true, // 0 C true, // 0 C
false, // 1 C# false, // 1 C#
@@ -129,13 +120,8 @@ int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) {
if (dxPixels == 0) return clampNote(startNote); if (dxPixels == 0) return clampNote(startNote);
const int bandWidth = std::max(0, layout.keys.width); const int bandWidth = std::max(0, layout.keys.width);
if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion
// Proportional shift: same linear mapping as keyAtPoint/keyEdgeToX so click and drag // Same linear mapping as keyAtPoint/keyEdgeToX (exact rational), not a truncated-integer
// agree across the full strip, even on non-divisible-by-128 widths. The proportional // bandWidth/128 key width — that drifted at the far end of the strip.
// 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.
const int half = bandWidth / 2; const int half = bandWidth / 2;
int shift; int shift;
if (dxPixels > 0) { 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 // keyboard_strip.h — layout + hit-test + drag math for the capture-first editor's
// editor's keyboard strip. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. // keyboard strip. Mirror of editor_geometry/embed_strip/mode_switch; the shell draws
// The mirror of editor_geometry / embed_strip / mode_switch: the fiddly rectangle + // and marshals mouse events into these functions.
// 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.
// //
// The strip maps the full 128-key MIDI span across a horizontal band (the same key-span // The strip maps the full 128-key MIDI span across a horizontal band (the same idiom
// idiom embed_strip uses). It serves TWO faces of the S10 editor: // embed_strip uses) and serves two faces: the single-capture fast path (a root marker,
// * the SINGLE-CAPTURE fast path (default): one loaded capture with a ROOT MARKER on // click-a-key or drag it to set root) and the opt-in zones panel (each zone drawn as a
// the strip, click-a-key (or drag the marker) sets the capture's root note; and // bar with edge-grab resize handles + a body move-handle).
// * 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.
#pragma once #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 { namespace reasampler::instrument::ui {
// The full MIDI key span the strip maps across its width: 128 keys (0..127). Named // Named distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips
// distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips stay // stay independent.
// independent — the editor strip may grow octave labels/metrics the embed strip never does.
inline constexpr int kStripKeyCount = 128; inline constexpr int kStripKeyCount = 128;
// The width (px) of an edge-grab hit region at each end of a zone bar: a drag started // Pixel width of a zone bar's edge-grab region. A zone narrower than 2x this has no
// within this many pixels of the bar's left/right edge resizes that edge; a drag started // body move-handle (both edges win their halves).
// 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.
inline constexpr int kStripEdgeGrabWidth = 6; inline constexpr int kStripEdgeGrabWidth = 6;
// The strip's regions, derived from the (w x h) band the shell allots it. The keys band // The keys band takes the whole strip area today; clamped so a degenerate size never
// takes the whole area today (a future octave-label lane can carve a sub-band here without // yields an inverted rect.
// changing callers). Clamped so a degenerate (tiny/zero) size never yields an inverted rect.
struct StripLayout { 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 // Divide a (w x h) strip area into its regions. Pure.
// negative size yields empty rects (no inversion).
StripLayout layoutStrip(int w, int h); 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 // x pixel of the LEFT edge of key `note` (0..127) under the linear 128-key map; key N
// span maps linearly across keys.width; key N occupies the half-open pixel range // occupies [keyLeftX(N), keyLeftX(N+1)). note==128 maps to the band's right edge.
// [keyLeftX(N), keyLeftX(N+1)). Notes are clamped to [0,127]; note==128 maps to the band's
// right edge (so a key's right edge is keyLeftX(note+1)). Pure.
int keyLeftX(const StripLayout& layout, int note); int keyLeftX(const StripLayout& layout, int note);
// The half-open pixel rect of a single key `note` (0..127): [keyLeftX(note), // Half-open rect of a single key `note`, clamped to [0,127].
// keyLeftX(note+1)) horizontally, the full keys-band height. A malformed (out-of-range)
// note clamps to [0,127]. Pure.
Rect keyRect(const StripLayout& layout, int note); Rect keyRect(const StripLayout& layout, int note);
// The rect of the ROOT MARKER for the single-capture fast path: the key cell of `rootNote`, // Root-marker rect for the single-capture fast path; equivalent to
// drawn as a highlighted key. Equivalent to keyRect(layout, rootNote) — a named entry point // keyRect(layout, rootNote) but named so the intent reads at the call site.
// 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.
Rect rootMarkerRect(const StripLayout& layout, int rootNote); 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 // MIDI note a point (x, y) lands on, or -1 outside the keys band.
// click-to-set-root (single capture) and click-a-key-sets-zone-root (zones). Pure.
int keyAtPoint(const StripLayout& layout, int x, int y); int keyAtPoint(const StripLayout& layout, int x, int y);
// The horizontal sub-rect of the keys band for a zone spanning [lowNote, highNote] // Horizontal sub-rect for a zone spanning [lowNote, highNote] inclusive. Notes clamp to
// (inclusive): [keyLeftX(low), keyLeftX(high+1)) horizontally, the full band height. Notes // [0,127] and low clamps to <= high, so a malformed zone never yields an inverted rect.
// 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.
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote); 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 // Which part of a zone bar a grab landed on: an edge resizes that boundary, the body
// edits: an edge resizes that boundary; the body moves the whole span; none means the grab // moves the whole span, kNone means the grab missed the bar.
// missed the bar entirely (the shell may treat that as a key-click to set the root, or as a
// deselect).
enum class ZoneGrab { enum class ZoneGrab {
kNone, // the point is not on this zone's bar kNone,
kLowEdge, // within kStripEdgeGrabWidth of the bar's LEFT edge -> resize low kLowEdge,
kHighEdge, // within kStripEdgeGrabWidth of the bar's RIGHT edge -> resize high kHighEdge,
kBody, // on the bar but not an edge -> move the whole span kBody,
}; };
// Classify a grab at (x, y) against ONE zone's bar (low..high). Returns kNone when the // Classify a grab at (x, y) against one zone's bar. A narrow bar (< 2*kStripEdgeGrabWidth)
// point is off the bar (or off the keys band). On the bar: kLowEdge/kHighEdge when within // resolves the near half to each edge (no body); the low edge wins a tie at the exact
// kStripEdgeGrabWidth of that edge, else kBody. A narrow bar (< 2*kStripEdgeGrabWidth) // midpoint.
// 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.
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y); 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, // Zone (index into the parallel `lows`/`highs` arrays, draw order) whose bar a grab
// plus which part of it, or {-1, kNone} for a point off every bar. First covering zone in // lands on, plus which part, or {-1, kNone} for a miss. First covering zone in draw
// draw order wins (first-match, mirroring the core's Keymap::resolve + embed_strip). The // order wins.
// 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).
struct ZoneBarHit { struct ZoneBarHit {
int zoneIndex = -1; int zoneIndex = -1;
ZoneGrab grab = ZoneGrab::kNone; ZoneGrab grab = ZoneGrab::kNone;
@@ -108,24 +72,13 @@ struct ZoneBarHit {
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs, ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
int count, int x, int y); int count, int x, int y);
// Resolve a drag to a new MIDI note. Given the note the grabbed field held at grab time // Resolves a drag to a new MIDI note: `startNote` shifted by round(dxPixels / keyWidth),
// (`startNote`) and the horizontal pixel delta since grab (`dxPixels`), returns the note // clamped to [0,127]. The one arithmetic behind edge-resize, body-move (apply to both
// the field should now hold: startNote shifted by round(dxPixels / keyWidth), clamped to // edges with the same delta to preserve span), and root-marker drag.
// [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.
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels); 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 // True when `note` (clamped to [0,127]) is a natural (white) key in 12-tone equal
// temperament; false when it is an ACCIDENTAL (black) key. Notes out of the [0,127] // temperament; false for an accidental (black) key.
// 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.
bool isNaturalKey(int note); bool isNaturalKey(int note);
} // namespace reasampler::instrument::ui } // 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 innerLeft = box.x + kDeckGroupPadX;
const int innerRight = box.right() - 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); out.caption = Rect::ltrb(innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH);
if (g.captionToggle.id >= 0) { if (g.captionToggle.id >= 0) {
const int segW = g.captionToggle.segWidth; 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 // knob_deck.h — knob-deck layout + hit-test for the Sample-face knob deck. Engine-free
// (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary, and — like // like param_slider: cells and toggles carry opaque shell-owned control ids. Mirror of
// param_slider — NO engine types: cells and toggles carry opaque shell-owned control ids. // action_bar/param_slider; the knob primitive itself (value<->needle-angle, drag) is
// The mirror of action_bar / param_slider: the fiddly group-box / caption-row / cell-grid // param_slider's — a knob cell here is just a rect the shell composes it into.
// 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.
// //
// THE DECK (CONTEXT.md §S-VIEW r11). A horizontal run of FENCED GROUPS, left -> right, each // The deck is a horizontal run of fenced groups, left->right, each a bordered box with a
// a hairline-bordered bg/panel box with a CAPTION ROW (micro-caps caption left; the group's // caption row (caption left, the group's compact mode toggle right-anchored) over a knob
// compact mode toggle right-anchored IN the caption row — this is where the not-full-width // row of fixed cells (knob centered, label band beneath). A group may also place one
// toggles live) over a KNOB ROW of fixed 48x58 cells (28px knob centered, 12px label band // two-segment toggle in the knob row after its cells. Groups that must keep stable
// beneath). A group may additionally place one 18px-tall two-segment toggle IN the knob row // geometry across a mode flip reserve blank cells (id -1) so a mode flip never reflows
// after its cells (the VOICE group's Retrig|Legato — same Mono/Stereo segment grammar, // neighbouring groups.
// 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.
// //
// WRAP (deterministic): groups place left-to-right with kDeckGroupGap between; a group that // Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group
// does not fit the remaining width starts a new deck row (whole groups only, never split). // that does not fit the remaining width starts a new row (whole groups only, never
// The first group of a row always places even if wider than the row (degenerate width). // split); the first group of a row always places even if wider than the row.
// deckHeight() exposes the resulting height so the shell can bottom-anchor the deck band and
// give the ELASTIC HERO the rest (r11 band order).
#pragma once #pragma once
@@ -31,7 +22,7 @@
namespace reasampler::instrument::ui { 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 kDeckCellW = 48; // one knob cell
inline constexpr int kDeckCellH = 58; inline constexpr int kDeckCellH = 58;
inline constexpr int kDeckKnobSize = 28; // knob diameter inside the cell 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 // 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). // is a reserved blank cell (geometry held, never hit). `captionWidth` is the px the shell
// `captionWidth` is the px the shell reserves for the caption text (this module does not // reserves for the caption text (this module does not measure text).
// measure text — the house constant-metrics pattern).
struct DeckGroupDesc { struct DeckGroupDesc {
int id = 0; // shell group id (opaque here) int id = 0; // shell group id (opaque here)
int captionWidth = 60; int captionWidth = 60;
@@ -96,21 +86,20 @@ struct DeckLayout {
int height = 0; // rowCount * kDeckGroupH + (rowCount-1) * kDeckRowGap; 0 for no groups 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 // 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. // knob row (cells + gap + row toggle), plus horizontal padding.
int deckGroupWidth(const DeckGroupDesc& g); int deckGroupWidth(const DeckGroupDesc& g);
// The number of deck rows the groups occupy at `availWidth` under the greedy whole-group // Number of deck rows the groups occupy at `availWidth` under the greedy whole-group wrap.
// wrap (a group that does not fit the remaining row width starts a new row; the first group // 0 for an empty list.
// of a row always places). 0 for an empty group list. Pure — the wrap is deterministic.
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth); int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth);
// The total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). 0 for an // Total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). The shell
// empty list. The shell bottom-anchors a band of exactly this height. Pure. // bottom-anchors a band of exactly this height.
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth); int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth);
// Lay the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's rule. // Lays the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's
// Every rect is absolute. Pure — same inputs, same layout. // rule. Every rect is absolute.
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top, DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
int availWidth); int availWidth);
@@ -124,10 +113,9 @@ struct DeckHit {
int segment = -1; // 0/1 for a toggle hit; -1 otherwise 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 // The deck element a point lands on: a knob cell (the whole cell, not just the knob
// bare knob circle; the shell anchors the vertical drag wherever the grab lands), a caption- // circle the shell anchors the vertical drag wherever the grab lands), a caption-toggle
// toggle segment, or a row-toggle segment. Blank cells (id -1) and everything else miss. // segment, or a row-toggle segment. Blank cells (id -1) and everything else miss.
// Pure — the shell's routing entry point.
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y); DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
} // namespace reasampler::instrument::ui } // 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 // param_slider.cpp — see param_slider.h. Pure control-surface geometry; no host types.
// editor parameter panel. No host types; only the shared Rect + contains().
#include "core/instrument/ui/param_slider.h" #include "core/instrument/ui/param_slider.h"
@@ -10,7 +9,7 @@
namespace reasampler::instrument::ui { 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, std::vector<ControlRow> layoutControls(const Rect& panel,
const std::vector<ControlDesc>& controls) { 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); return static_cast<double>(x - track.x) / static_cast<double>(span);
} }
// --- Radial knob (Wave A FA4) --------------------------------------------------------- // --- Radial knob -----------------------------------------------------------------------
namespace { namespace {
+68 -107
View File
@@ -1,180 +1,141 @@
// param_slider.h — PURE control-surface layout + hit-test + value<->pixel mapping for the // param_slider.h — 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 // editor parameter panel. Engine-free by design (no sampler_core/sample_map). Mirror of
// boundary, and — deliberately — NO sampler_core / sample_map engine types either. The // keyboard_strip/waveform_view/mode_switch; the shell draws each row and routes
// mirror of keyboard_strip / waveform_view / mode_switch: the fiddly slider-track and // clicks/drags into these functions, owning the control-id -> engine-param binding and
// toggle-segment arithmetic lives here, unit-tested outside the DAW, while the editor shell // the value domain mapping.
// 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.
// //
// WHY IT EXISTS (S12 + the S15/S16 control surfaces deferred here). The setup / Zones surface // Controls are one of three shapes — a two-segment Toggle, a horizontal Slider, or a
// grows a stack of parameter controls: the S15 play-mode toggle (Gate|Trigger), the AHDSR // radial Knob with a needle and vertical-drag mapping — laid out as a vertical stack of
// amp-envelope sliders (attack/hold/decay/sustain/release), the Trigger %-length + fade // fixed-height rows. This module maps a control's normalized value (0..1) to/from its
// controls, the S16 Varispeed|Preserve engine toggle, and the AD pitch-envelope // handle pixel / needle angle; the shell converts each control's engine value (frames,
// enable/attack/decay/depth. They are three shapes — a two-segment TOGGLE, a horizontal // seconds, a fraction, a signed semitone depth) to/from that 0..1.
// 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).
#pragma once #pragma once
#include <vector> #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 { namespace reasampler::instrument::ui {
// Fixed control-panel metrics, exposed so the shell and tests agree. // 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 kControlRowHeight = 22;
inline constexpr int kControlRowGap = 4; // vertical gap below each row inline constexpr int kControlRowGap = 4;
inline constexpr int kControlLabelWidth = 92; // the label column at the row's left inline constexpr int kControlLabelWidth = 92;
inline constexpr int kSliderHandleWidth = 8; // the draggable slider handle width (px) inline constexpr int kSliderHandleWidth = 8;
inline constexpr int kToggleSegments = 2; // a toggle is always two segments inline constexpr int kToggleSegments = 2;
// A control is one of three shapes. Toggle = a two-segment selector (the active segment // Toggle = two-segment selector (active segment highlights); Slider = horizontal track
// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value; // with a draggable handle over a 0..1 value; Knob = radial dial with a needle, dragged
// Knob = a radial dial with a needle indicator over a 0..1 value, dragged VERTICALLY // vertically (up = increase).
// (up = increase).
enum class ControlKind { Toggle, Slider, Knob }; enum class ControlKind { Toggle, Slider, Knob };
// One control the shell places in the panel, in stack order. `id` is the shell's own control // One control the shell places in the panel, in stack order. `id` is the shell's own
// identifier (an int the shell casts from its ControlId enum) returned by the hit-test so the // control identifier, returned by the hit-test so the shell routes to the right engine
// shell routes the interaction to the right engine param — this module never interprets it. // param — this module never interprets it.
struct ControlDesc { struct ControlDesc {
int id = 0; int id = 0;
ControlKind kind = ControlKind::Slider; ControlKind kind = ControlKind::Slider;
}; };
// The laid-out geometry of one control row: its full row rect plus the interactive sub-rect // Laid-out geometry of one control row: full row rect plus the interactive sub-rect (the
// (the track for a Slider, the whole control area for a Toggle — the shell splits a Toggle // 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. // into segments via toggleSegmentRect).
struct ControlRow { struct ControlRow {
int id = 0; int id = 0;
ControlKind kind = ControlKind::Slider; ControlKind kind = ControlKind::Slider;
Rect row; // the full row (label column + control column) Rect row;
Rect label; // the label column at the left Rect label;
Rect control; // the control column to the right of the label (track / toggle area) Rect control;
}; };
// Lay out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down. Each // Lays out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down.
// row is kControlRowHeight tall with kControlRowGap below it; the label column takes the left // Label column takes the left kControlLabelWidth (clamped to the panel), control column
// kControlLabelWidth (clamped so it never exceeds the panel), the control column the rest. A // the rest. A row past the panel bottom is still returned (the shell clips/suppresses it)
// row whose top falls past the panel bottom is still returned (the shell clips at paint / // so stack geometry is deterministic regardless of panel height.
// 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.
std::vector<ControlRow> layoutControls(const Rect& panel, std::vector<ControlRow> layoutControls(const Rect& panel,
const std::vector<ControlDesc>& controls); const std::vector<ControlDesc>& controls);
// The rect of segment `seg` (0..kToggleSegments-1) within a toggle control's `control` rect, // Rect of segment `seg` within a toggle's `control` rect, splitting it into
// splitting it into kToggleSegments equal segments left-to-right (the last absorbs any width // kToggleSegments equal segments left-to-right (last absorbs any width remainder).
// remainder, mirror of mode_switch's segment split). An out-of-range segment or a degenerate
// control rect yields an empty rect. Pure.
Rect toggleSegmentRect(const Rect& control, int seg); 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); 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 // Slider track sub-rect inside `control`: inset so the handle stays fully within the
// handle (kSliderHandleWidth) stays fully within the control at value 0 and 1 (a half-handle // control at value 0 and 1. The handle center ranges across [track.x, track.right()] as
// margin at each end). The handle CENTER ranges across [track.x, track.right()] as the value // the value ranges [0,1].
// ranges [0,1]. The shell draws the track fill + handle here. A degenerate control yields an
// empty rect. Pure.
Rect sliderTrackRect(const Rect& control); Rect sliderTrackRect(const Rect& control);
// The handle rect for a slider at normalized `value` (clamped to [0,1]) within `control`: a // Handle rect for a slider at normalized `value` (clamped to [0,1]).
// kSliderHandleWidth-wide bar centered at the value's position along sliderTrackRect. A
// degenerate control yields an empty rect. Pure — the inverse of valueAtPoint.
Rect sliderHandleRect(const Rect& control, double value); Rect sliderHandleRect(const Rect& control, double value);
// Map a point x to a normalized slider value [0,1] within `control` (the handle-center range). // Maps a point x to a normalized slider value [0,1]: at/left of track start -> 0, at/right
// x at/left of the track start -> 0; at/right of the end -> 1; linear between. A degenerate // of end -> 1, linear between. Inverse of sliderHandleRect's position map.
// 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).
double valueAtPoint(const Rect& control, int x); 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 // Angle convention: degrees clockwise from 12 o'clock (screen coords, y grows downward).
// coordinates (y grows downward): 0 = 12 o'clock (up), 90 = 3 o'clock (right), 180 = 6 // The value arc sweeps clockwise from startDeg (value 0) to endDeg (value 1); an endDeg
// o'clock (down), 270 = 9 o'clock (left). The value arc sweeps CLOCKWISE from startDeg // at-or-behind startDeg wraps +360.
// (value 0) to endDeg (value 1); an endDeg at-or-behind startDeg wraps +360, so equal
// angles mean a full 360° sweep.
// //
// The DEFAULT arc is the conventional 7→5 o'clock layout: min at 7 o'clock (210°) sweeping // Default arc: 7 o'clock (210°) sweeping clockwise 300° to 5 o'clock (150°), leaving a
// clockwise 300° around to max at 5 o'clock (150°), leaving a symmetric 60° dead arc at the // symmetric 60° dead arc at the bottom; the 50% value lands at 12 o'clock. Angles are
// bottom. The 50% (midpoint) value lands at 12 o'clock (0°/360°) — straight up. The angles // parameters, not hardcoded.
// are PARAMETERS, not hardcoded — the shell sets the final sweep when the parallel layout inline constexpr double kKnobArcStartDeg = 210.0;
// spec lands. inline constexpr double kKnobArcEndDeg = 150.0;
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 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; inline constexpr int kKnobDragRangePixels = 128;
// The configurable value arc of a knob. Defaults to the 7->5 o'clock reading above.
struct KnobArc { struct KnobArc {
double startDeg = kKnobArcStartDeg; double startDeg = kKnobArcStartDeg;
double endDeg = kKnobArcEndDeg; double endDeg = kKnobArcEndDeg;
}; };
// A knob's circle within its control cell: center + radius in pixel space (doubles so the // A knob's circle within its control cell: center + radius (doubles so the shell rounds
// shell rounds once, at draw time). radius == 0 marks a degenerate cell. // once, at draw time). radius == 0 marks a degenerate cell.
struct KnobGeometry { struct KnobGeometry {
double centerX = 0.0; double centerX = 0.0;
double centerY = 0.0; double centerY = 0.0;
double radius = 0.0; double radius = 0.0;
}; };
// A pixel-space point (the needle endpoint the shell draws to).
struct KnobPoint { struct KnobPoint {
double x = 0.0; double x = 0.0;
double y = 0.0; double y = 0.0;
}; };
// The knob circle inscribed in `cell`, centered, radius = half the smaller dimension. A // Knob circle inscribed in `cell`, centered, radius = half the smaller dimension. The
// degenerate cell yields radius 0. CONTRACT: the shell MUST pass `row.control` (the full // shell must pass `row.control` both when drawing and hit-testing — controlAtPoint always
// control column) both when drawing and when hit-testing — `controlAtPoint` always uses // uses `r.control` as the cell, so draw cell and hit cell must agree.
// `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.
KnobGeometry computeKnob(const Rect& cell); KnobGeometry computeKnob(const Rect& cell);
// True if (x, y) falls strictly inside the knob circle (boundary exclusive, matching the // True if (x, y) falls strictly inside the knob circle (boundary exclusive).
// module's half-open Rect convention). A degenerate knob (radius <= 0) hits nothing. Pure.
bool knobHitTest(const KnobGeometry& knob, int x, int y); bool knobHitTest(const KnobGeometry& knob, int x, int y);
// The clockwise sweep of `arc` in degrees, in (0, 360]: normalized end - start, wrapping // Clockwise sweep of `arc` in degrees, in (0, 360]: normalized end - start, wrapping +360
// +360 when the end is at-or-behind the start (default arc -> 300). Pure. // when the end is at-or-behind the start (default arc -> 300).
double knobSweepDeg(const KnobArc& arc); double knobSweepDeg(const KnobArc& arc);
// The needle angle for normalized `value` (clamped to [0,1]): startDeg at 0, endDeg at 1, // Needle angle for normalized `value` (clamped to [0,1]): startDeg at 0, endDeg at 1,
// linear between, returned normalized to [0, 360). Pure. // linear between, normalized to [0, 360).
double knobValueAngleDeg(const KnobArc& arc, double value); double knobValueAngleDeg(const KnobArc& arc, double value);
// The needle endpoint for normalized `value`: the point on the knob circle at the value's // 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 // angle, from the center.
// (or lerps toward the center for a shorter needle). Pure.
KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double value); 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), // Maps a vertical drag onto a knob value: `startValue` is the value at drag start,
// `dyPixels` the pointer's y displacement in screen coordinates (down = positive). Dragging // `dyPixels` the pointer's y displacement (down = positive). Up increases, down
// UP increases, DOWN decreases; `dragRangePixels` pixels of travel covers the full 0..1 // decreases; `dragRangePixels` pixels of travel covers the full 0..1 range.
// 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.
double knobDragValue(double startValue, int dyPixels, double knobDragValue(double startValue, int dyPixels,
int dragRangePixels = kKnobDragRangePixels); int dragRangePixels = kKnobDragRangePixels);
// The control a point lands on, given the laid-out `rows`. Returns the control id (ControlDesc // Control a point lands on, given laid-out `rows`. Returns the control id whose
// id) whose interactive area (a Slider's track, a Toggle's whole control area, a Knob's // interactive area contains the point, or -1 for a miss. First matching row wins (rows
// circle) contains the point, or -1 for a miss (a gap, the label column, or outside every // never overlap).
// 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.
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y); int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y);
} // namespace reasampler::instrument::ui } // 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); const int w = std::max(0, area.width);
if (frameCount <= 0 || w <= 0) return area.x; if (frameCount <= 0 || w <= 0) return area.x;
const std::int64_t f = clampFrame(frame, frameCount); const std::int64_t f = clampFrame(frame, frameCount);
// Linear map: x = left + round(f * w / frameCount). Rounding keeps the marker line // x = left + round(f * w / frameCount); multiply before divide to keep this exact.
// visually centered on its frame; the divide is exact rational (multiply first).
const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2; const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2;
return area.x + static_cast<int>(num / frameCount); 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.x) return 0;
if (x >= area.right()) return frameCount; if (x >= area.right()) return frameCount;
const std::int64_t dx = static_cast<std::int64_t>(x - area.x); 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 // Inverse of frameToX: frame = round(dx * frameCount / w).
// agree at bin granularity.
const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2; const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2;
return clampFrame(num / static_cast<std::int64_t>(w), frameCount); 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_view.h — waveform/marker geometry + zero-crossing snap. Mirror of keyboard_strip/
// waveform surface. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror // editor_geometry: frame<->pixel + marker hit-test + snap arithmetic lives here, unit-tested
// of keyboard_strip / editor_geometry: the fiddly frame<->pixel + marker hit-test + snap // outside the DAW; the shell draws and marshals mouse events into it.
// 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.
// //
// The surface maps a sample's full frame span [0, frameCount] linearly across a horizontal // 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, // waveform rect. Markers are a generic N-named-marker set (not hardcoded specials), so a
// loop end). The marker set is GENERIC — N named markers with drag + snap — deliberately // different mode (e.g. start + %-length end + fades) can repurpose the same machinery.
// 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.
#pragma once #pragma once
#include <cstdint> #include <cstdint>
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom #include "core/instrument/ui/editor_geometry.h" // Rect, contains
#include "core/audio/peaks.h" // AudioSample (float), the mono PCM the snap scans #include "core/audio/peaks.h" // AudioSample (float)
namespace reasampler::instrument::ui { namespace reasampler::instrument::ui {
using audio::AudioSample; using audio::AudioSample;
// The width (px) of a marker's grab region either side of its x line: a grab within this many // Pixel width of a marker's grab region either side of its x line. Mirrors keyboard_strip's
// pixels of a marker's drawn x is a grab OF that marker. Mirrors keyboard_strip's edge-grab // edge-grab idiom.
// idiom — wide enough to grab a 1px line comfortably, narrow enough that adjacent markers stay
// distinguishable.
inline constexpr int kMarkerGrabWidth = 5; inline constexpr int kMarkerGrabWidth = 5;
// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.x, // x pixel of `frame` under the linear map: frame 0 -> area.x, frame frameCount -> area.right().
// frame frameCount -> area.right(). A frame is clamped to [0, frameCount] before mapping, so an // Frame is clamped to [0, frameCount] before mapping. frameCount <= 0 or a zero-width area pins
// out-of-range frame pins to an edge rather than escaping the rect. frameCount <= 0 or a // every frame to area.x.
// zero-width area pins every frame to area.x (a degenerate, non-inverting result). Pure.
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame); 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 // Inverse of frameToX: the frame a point x maps to, clamped to [0, frameCount]. A point left of
// [0, frameCount]. A point left of area.x yields 0; right of area.right() yields frameCount. // area.x yields 0; right of area.right() yields frameCount.
// frameCount <= 0 or a zero-width area yields 0. Pure — the inverse of frameToX (round-trips
// to the same frame at bin granularity).
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x); 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 // Which marker (index into the caller's 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 // (x, y) lands on, or -1 for a miss. A marker is grabbed when x is within kMarkerGrabWidth of
// grabbed when x is within kMarkerGrabWidth of its drawn x AND y is inside `area`. First marker // its drawn x and y is inside `area`. First marker in draw order wins an overlapping tie.
// 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.
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames, int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
int count, int x, int y); int count, int x, int y);
// Resolve a drag to a new frame. Given the frame the grabbed marker held at grab time // Resolves a drag to a new frame: `startFrame` shifted by round(dxPixels * frameCount /
// (`startFrame`) and the horizontal pixel delta since grab (`dxPixels`), returns the frame the // areaWidth), clamped to [0, frameCount]. The shell applies between-marker clamps (e.g.
// marker should now hold: startFrame shifted by round(dxPixels * frameCount / areaWidth), // start <= loopEnd) after this per-marker resolve.
// 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.
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame, std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
int dxPixels); int dxPixels);
// The nearest zero-crossing frame to `target` in the mono PCM, for the loop/start snap (the // Nearest zero-crossing frame to `target` in the mono PCM, for loop/start snap. A crossing is a
// S2 zero-crossing-aware requirement). A zero crossing is a frame index i (1 <= i < frames) // frame i (1 <= i < frames) where pcm[i-1] and pcm[i] differ in sign (pcm[i] == 0 snaps to i).
// where the sign of pcm[i-1] and pcm[i] differ (a sample exactly 0 counts as its own crossing // Search fans out symmetrically from the clamped target; an equidistant tie resolves to the
// — pcm[i] == 0 snaps to i). The search fans out symmetrically from the clamped target and // lower frame. No sign change anywhere (or fewer than 2 frames) returns the clamped target
// returns the closest crossing frame; ties (equidistant crossings on both sides) resolve to // unchanged.
// 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.
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames, std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
std::int64_t target); 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 // core/json implementation — see json.h. Any behavioral change here changes
// quintuplicated) bank_model / view_mode_model lexical layer, verbatim; any // every persisted-blob parser that shares this lexical layer at once.
// behavioral change here changes five persisted-blob parsers at once.
#include "core/json/json.h" #include "core/json/json.h"
@@ -11,9 +10,7 @@
namespace reasampler::json { namespace reasampler::json {
// --------------------------------------------------------------------------- // -- emit helpers -------------------------------------------------------
// emit helpers
// ---------------------------------------------------------------------------
void writeEscaped(std::string& out, const std::string& s) { void writeEscaped(std::string& out, const std::string& s) {
out += '"'; out += '"';
@@ -75,9 +72,7 @@ void writeIntArray(std::string& out, const std::vector<int>& v) {
out += ']'; out += ']';
} }
// --------------------------------------------------------------------------- // -- Reader ---------------------------------------------------------------
// Reader
// ---------------------------------------------------------------------------
void Reader::skipWs() { void Reader::skipWs() {
while (!eof()) { while (!eof()) {
@@ -117,7 +112,6 @@ bool Reader::parseString(std::string& out) {
case 'r': out += '\r'; break; case 'r': out += '\r'; break;
case 't': out += '\t'; break; case 't': out += '\t'; break;
case 'u': { case 'u': {
// Decode a \uXXXX escape to its code point.
auto readHex4 = [&](unsigned int& cp) -> bool { auto readHex4 = [&](unsigned int& cp) -> bool {
if (pos_ + 4 > s_.size()) return false; if (pos_ + 4 > s_.size()) return false;
cp = 0; cp = 0;
@@ -149,7 +143,6 @@ bool Reader::parseString(std::string& out) {
return false; // unpaired low surrogate — malformed return false; // unpaired low surrogate — malformed
} }
// Encode codePoint as UTF-8.
if (codePoint <= 0x7F) { if (codePoint <= 0x7F) {
out += static_cast<char>(codePoint); out += static_cast<char>(codePoint);
} else if (codePoint <= 0x7FF) { } 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 // core/json — the ONE hand-rolled JSON lexical layer. Pure: standard library only
// "Parser ×4"). Pure: standard library only — NO REAPER, NO SWELL, NO VST3. // — NO REAPER, NO SWELL, NO VST3.
// //
// This module owns the lexical half of the house JSON dialect: the escape-aware // Owns the lexical half of the house JSON dialect: escape-aware string literals
// string literal (incl. \uXXXX + surrogate pairs re-encoded as UTF-8), the bare // (incl. \uXXXX + surrogate pairs re-encoded as UTF-8), bare scalar tokens, number
// scalar tokens, the number parses (strtod/strtoll with full-token + ERANGE // parsing (strtod/strtoll, full-token + ERANGE rejection), key+':' consumption,
// rejection), key+':' consumption, unknown-value skipping, and the emit side // unknown-value skipping, and the emit side (escaping, %.17g/%d/%lld rendering,
// (escaping, %.17g / %d / %lld number rendering, the scoped object writer). // the scoped object writer). Domain grammars — which keys exist, what shape each
// The DOMAIN grammars — which keys exist, what shape each value takes, what is // value takes — stay in the consumers (bank_model, bank_book, view_mode_model,
// rejected at the model boundary — stay in the consumers (bank_model, bank_book, // owned_manifest, tail_control).
// view_mode_model, owned_manifest, tail_control). One lexical definition means
// the five decoders can no longer drift on tolerance or escaping.
// //
// Byte-compatibility contract (load-bearing): the emit helpers reproduce the // Byte-compatibility contract (load-bearing): the emit helpers reproduce the prior
// prior per-module writers EXACTLY — writeEscaped's escape set, %.17g for // per-module writers EXACTLY — writeEscaped's escape set, %.17g for doubles
// doubles (shortest form that round-trips every IEEE-754 double bit-for-bit), // (shortest form that round-trips every IEEE-754 double bit-for-bit), plain
// plain decimal for ints — so a re-serialized blob is byte-identical to what // decimal for ints — so a re-serialized blob is byte-identical to what the
// the pre-extraction writers produced. This was a structural dedupe, not a // pre-extraction writers produced. Persisted .rpp ext-state must not shift by a byte.
// format change; persisted .rpp ext-state must not shift by a byte.
#pragma once #pragma once
@@ -26,9 +23,7 @@
namespace reasampler::json { namespace reasampler::json {
// --------------------------------------------------------------------------- // -- emit helpers (writer side) ----------------------------------------------
// emit helpers (writer side)
// ---------------------------------------------------------------------------
// Appends `s` as a quoted JSON string literal: the seven short escapes, \uXXXX // Appends `s` as a quoted JSON string literal: the seven short escapes, \uXXXX
// for remaining control chars, everything else verbatim (UTF-8 passes through). // for remaining control chars, everything else verbatim (UTF-8 passes through).
@@ -88,9 +83,7 @@ private:
bool first_ = true; 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. // 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 // 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 // bank_book implementation — the registry RULES half: construction, pool
// privileges, bank lifecycle, active bank, sample movement/removal, slot order, // privileges, bank lifecycle, active bank, sample movement/removal, slot order,
// and the reference queries. The JSON round-trip half (serialize / deserialize — // and the reference queries. The JSON round-trip half lives in bank_book_json.cpp,
// Q-W1's golden-literal-pinned byte format) lives in bank_book_json.cpp, compiled // compiled into the same target. The one symbol both halves share is the private
// into the same bank_book target (the slot_map extraction shape: same header, a // static BankBook::nameKey display-name folding rule (declared in bank_book.h).
// second TU). The one symbol both halves share is the private static
// BankBook::nameKey display-name folding rule (declared in bank_book.h).
namespace reasampler { namespace reasampler {
// SlotMap lives in core/model/slot_map.cpp (extracted Q-W1, T4-05). // -- construction + bank lookup ----------------------------------------------
// ---------------------------------------------------------------------------
// BankBook — construction + bank lookup
// ---------------------------------------------------------------------------
BankBook::BankBook() { BankBook::BankBook() {
Bank pool; Bank pool;
@@ -59,9 +53,7 @@ const Bank& BankBook::pool() const {
return *bank(kPoolBankId); return *bank(kPoolBankId);
} }
// --------------------------------------------------------------------------- // -- Ordinal normalization ----------------------------------------------------
// Ordinal normalization
// ---------------------------------------------------------------------------
void BankBook::normalizeOrdinals() { void BankBook::normalizeOrdinals() {
// Stable-sort by ordinal with the pool pinned first, then rewrite ordinals to a // 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); 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 // Folds a display name to its uniqueness key: strip leading/trailing ASCII
// whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share one // whitespace, lower-case ASCII letters — so "Drums"/"drums"/" Drums " share one
// key and cannot coexist. ASCII-only by design — the pure core carries no locale // key. ASCII-only by design — the pure core carries no locale facility; bank names
// facility and must not grow one; bank names are short user labels, not full Unicode // are short user labels, not Unicode case-folding candidates. Private static: the
// case-folding candidates. Private static member (Q-W5): the one folding rule shared // one folding rule shared with bank_book_json.cpp's parse-time coalesce.
// with bank_book_json.cpp's parse-time duplicate-display-name coalesce.
std::string BankBook::nameKey(const std::string& s) { std::string BankBook::nameKey(const std::string& s) {
std::size_t b = 0, e = s.size(); std::size_t b = 0, e = s.size();
auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }; 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; return false;
} }
// --------------------------------------------------------------------------- // -- Bank lifecycle ------------------------------------------------------------
// Bank lifecycle
// ---------------------------------------------------------------------------
bool BankBook::createBank(const std::string& id, const std::string& displayName) { bool BankBook::createBank(const std::string& id, const std::string& displayName) {
if (id.empty()) return false; // ids key the registry if (id.empty()) return false; // ids key the registry
@@ -208,9 +195,7 @@ bool BankBook::evacuate(const std::string& id) {
return true; return true;
} }
// --------------------------------------------------------------------------- // -- Active bank ----------------------------------------------------------------
// Active bank
// ---------------------------------------------------------------------------
bool BankBook::setActiveBank(const std::string& id) { bool BankBook::setActiveBank(const std::string& id) {
if (bank(id) == nullptr) return false; // unknown id never corrupts state if (bank(id) == nullptr) return false; // unknown id never corrupts state
@@ -227,9 +212,7 @@ const BankModel& BankBook::activeIndex() const {
return bank(activeBankId_)->index; return bank(activeBankId_)->index;
} }
// --------------------------------------------------------------------------- // -- Sample movement (index-only) -----------------------------------------------
// Sample movement (index-only)
// ---------------------------------------------------------------------------
namespace { namespace {
@@ -276,9 +259,7 @@ TransferResult BankBook::copySample(const std::string& sampleId,
return applyDestAdd(to->index, copy, TransferResult::Copied); 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, RemoveResult BankBook::removeSample(const std::string& sampleId,
const std::string& fromBankId, 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 return false; // no bank holds the id
} }
// --------------------------------------------------------------------------- // -- Sample display order — SlotMap driven, index membership untouched -----------
// Sample display order (L7) — SlotMap driven, index membership untouched
// ---------------------------------------------------------------------------
namespace { namespace {
@@ -323,9 +302,9 @@ std::vector<std::string> indexIds(const BankModel& idx) {
return ids; return ids;
} }
// Squares one bank's SlotMap with its index membership. A map with NO overlap with the // Squares one bank's SlotMap with its index membership. A map with NO overlap with
// index (the pre-L7 migration case, or a freshly-constructed bank) is seeded dense from // the index (a bank with no persisted slot data, or freshly constructed) is seeded
// insertion order; an existing map is reconciled (drop stale markers, append unmapped). // dense from insertion order; an existing map is reconciled (drop stale, append unmapped).
void reconcileBankSlots(Bank& b) { void reconcileBankSlots(Bank& b) {
const std::vector<std::string> live = indexIds(b.index); const std::vector<std::string> live = indexIds(b.index);
if (b.slots.empty()) { if (b.slots.empty()) {
+108 -208
View File
@@ -1,41 +1,13 @@
#pragma once #pragma once
// bank_book — the pure core of the multi-bank phase (Phase B), deliberately free // bank_book — pure multi-bank registry: wraps N BankModel instances (bank_model
// of any REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the // itself is untouched — additive, no bankId on Sample). Movement between banks is
// third instance of the same "pure registry + JSON round-trip, unit-tested outside // index-only; files never relocate, banks are logical groupings over one shared pool.
// the DAW" pattern as bank_model and view_mode_model.
// //
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // The pool is bank-zero (fixed id/name, ordinal 0), privileged and enforced HERE:
// vendor/ includes. Standard library only. // always exists, un-deletable, un-renamable, un-evacuable (evacuate's destination
// // only). createBank takes a caller-supplied id — REAPER GUID minting stays in the
// -- What it is -------------------------------------------------------------- // shell so this model stays pure and deterministic; the model still enforces
// // non-empty/unique/not-reserved.
// 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.
#include <optional> #include <optional>
#include <string> #include <string>
@@ -47,26 +19,22 @@
namespace reasampler { namespace reasampler {
// Q-W1 interim: this god module re-namespaces in its own split wave; until then the // Interim: this module re-namespaces later; the model types it wraps live in
// clean model types it wraps live in reasampler::model. // reasampler::model.
using namespace model; using namespace model;
// The pool's fixed identity. The id is reserved: createBank rejects it, and the // The pool's fixed identity: createBank rejects this id; renameBank rejects this name.
// pool is always bank-zero. The name is fixed: renameBank rejects the pool.
inline constexpr const char* kPoolBankId = "pool"; inline constexpr const char* kPoolBankId = "pool";
inline constexpr const char* kPoolBankName = "Pool"; inline constexpr const char* kPoolBankName = "Pool";
// SlotMap — extracted to its own TU/header pair (Q-W1, T4-05): core/model/slot_map.h. // One bank: id/display/ordinal/BankModel/SlotMap. The pool is the bank whose
// Included above because Bank carries one per bank. // id == kPoolBankId.
// 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.
struct Bank { struct Bank {
std::string id; // stable, persisted; the pool's is kPoolBankId std::string id; // stable, persisted; the pool's is kPoolBankId
std::string displayName; // mutable for named banks; fixed "Pool" for the pool std::string displayName; // mutable for named banks; fixed "Pool" for the pool
int ordinal = 0; // display order; pool is 0, named banks 1..N int ordinal = 0; // display order; pool is 0, named banks 1..N
BankModel index; // this bank's samples 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; } 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 // Outcome of a cross-bank move/copy — reports what happened rather than mutating
// reports what happened rather than silently mutating on a bad request. // silently on a bad request.
// - Moved / Copied: the sample was transferred to the destination as a new entry. // - Moved / Copied: transferred to the destination as a new entry.
// - Collapsed: the destination already held the hash; it collapsed onto the // - Collapsed: destination already held the hash, collapsed onto it (move
// existing entry (a no-op add on the destination side). For a // still removes the source; copy keeps it, as always).
// MOVE the source entry is STILL removed; for a COPY the source // - RejectedUnknownBank / RejectedSampleAbsent / RejectedSameBank: no-op guards.
// 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).
enum class TransferResult { enum class TransferResult {
Moved, Moved,
Copied, Copied,
@@ -95,21 +59,18 @@ enum class TransferResult {
RejectedSameBank, RejectedSameBank,
}; };
// Scope of a sample-remove (fork R-A, settled 2026-07-24). ThisBank is the default // Scope of a sample-remove. ThisBank is the only behavior surfaced in the UI;
// and the ONLY behavior surfaced in the UI/action layer; AllBanks is a latent seam // AllBanks is a tested latent seam, not wired to any affordance.
// live and tested at the model level, promotable later behind this parameter without // - ThisBank: drop the entry from the one named source bank only (no cross-bank
// a rewrite, but never wired to an affordance in B5. // cascade — dedup is per-bank).
// - ThisBank: drop the entry from the one named source bank only. A same-hash entry // - AllBanks: drop the sample's entry from every bank holding it ("purge from
// in another bank survives (no cross-bank cascade — dedup is per-bank). // the library").
// - AllBanks: drop the sample's entry from EVERY bank that holds the source id
// ("purge from the library"). Latent; unsurfaced.
enum class RemoveScope { enum class RemoveScope {
ThisBank, ThisBank,
AllBanks, AllBanks,
}; };
// Outcome of BankBook::removeSample. Mirrors TransferResult's honesty: the op reports // Outcome of BankBook::removeSample — same honesty as TransferResult.
// what happened rather than silently mutating on a bad request.
// - Removed: at least one index entry was dropped. // - Removed: at least one index entry was dropped.
// - RejectedUnknownBank: the source bank id named no bank (ThisBank scope only). // - RejectedUnknownBank: the source bank id named no bank (ThisBank scope only).
// - RejectedSampleAbsent: the sample id was in no bank in scope (nothing removed). // - 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 // 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 // indices, an active-bank pointer, and lossless JSON round-trip.
// multi-bank phase — mirror of bank_model / view_mode_model.
class BankBook { class BankBook {
public: public:
BankBook(); // seeds the pool (id kPoolBankId, name kPoolBankName, ordinal 0); BankBook(); // seeds the pool (id kPoolBankId, name kPoolBankName, ordinal 0);
@@ -129,34 +89,29 @@ public:
// -- Bank lifecycle ------------------------------------------------------ // -- Bank lifecycle ------------------------------------------------------
// Creates a named bank with the caller-supplied stable id and display name, // Creates a named bank with a caller-supplied id/display name (next ordinal
// assigning the next ordinal. Rejects (returns false, no mutation) an empty id, // assigned automatically). Rejects (false, no mutation) an empty/duplicate id,
// a duplicate id, the reserved pool id, or a display name that duplicates an // the reserved pool id, or a duplicate display name (trimmed + case-insensitive,
// existing bank's name (including the pool's "Pool"). Display-name uniqueness is // ASCII — "Drums"/"drums"/" Drums " collide, including against the pool's "Pool").
// trimmed + case-insensitive (ASCII): "Drums", "drums", and " Drums " collide.
bool createBank(const std::string& id, const std::string& displayName); 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 // Renames a named bank. Rejects (false, no mutation) an unknown id, the pool, or
// target name already used by a DIFFERENT bank (trimmed + case-insensitive, as // a name already used by another bank. Renaming to its own current name is a
// createBank). Renaming a bank to its own current name is a no-op success. // no-op success.
bool renameBank(const std::string& id, const std::string& displayName); bool renameBank(const std::string& id, const std::string& displayName);
// Deletes a NAMED bank, removing it (and its member index entries) from the // Deletes a named bank and its member entries (files untouched — a shell/prune
// registry. Files are a shell/prune concern and are NOT touched here. Rejects // concern). Rejects (false, no mutation) an unknown id or the pool. Remaining
// (false, no mutation) an unknown id or the pool. Remaining banks' ordinals are // ordinals compact after; if the deleted bank was active, falls back to the pool.
// 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.
bool deleteBank(const std::string& id); bool deleteBank(const std::string& id);
// Reorders a NAMED bank to `newOrdinal` (clamped into the named-bank range), // Reorders a named bank to newOrdinal (clamped into range, others shift to stay
// shifting the others to keep ordinals contiguous. The pool is pinned at 0 and // contiguous). The pool is pinned at 0. Rejects an unknown id or the pool.
// cannot be reordered. Rejects (false, no mutation) an unknown id or the pool.
bool reorderBank(const std::string& id, int newOrdinal); bool reorderBank(const std::string& id, int newOrdinal);
// Moves EVERY member of a named bank into the pool (index-only, observing the // Moves every member of a named bank into the pool (index-only, same
// same destination-collapse-by-hash as a move), leaving the bank empty. Rejects // destination-collapse-by-hash as a move). Rejects an unknown id or the pool
// (false, no mutation) an unknown id or the pool (the pool is the destination, // (the pool is only ever a destination). Returns true even if already empty.
// never a source). Returns true on success even if the bank was already empty.
bool evacuate(const std::string& id); bool evacuate(const std::string& id);
// -- Active bank --------------------------------------------------------- // -- Active bank ---------------------------------------------------------
@@ -164,123 +119,83 @@ public:
// The active bank's id (the capture target). Defaults to the pool. // The active bank's id (the capture target). Defaults to the pool.
const std::string& activeBankId() const { return activeBankId_; } const std::string& activeBankId() const { return activeBankId_; }
// Sets the active bank. Rejects (returns false, no change) an id that names no // Sets the active bank. Rejects (false, no change) an id that names no bank.
// bank — an invalid set never corrupts state.
bool setActiveBank(const std::string& id); bool setActiveBank(const std::string& id);
// The active bank's BankModel — the index the capture layer adds to. Always // The active bank's BankModel — always valid (falls back to the pool).
// valid (the active id always names a live bank; it falls back to the pool).
BankModel& activeIndex(); BankModel& activeIndex();
const BankModel& activeIndex() const; const BankModel& activeIndex() const;
// -- Sample movement (index-only; files never relocate) ------------------ // -- Sample movement (index-only; files never relocate) ------------------
// Moves a sample by id from `fromBankId` to `toBankId`: removes it from the // Moves a sample by id between banks (destination collapse-by-hash observed).
// source index and adds it to the destination (observing destination // See TransferResult for the full outcome set.
// collapse-by-hash). See TransferResult for the full outcome set.
TransferResult moveSample(const std::string& sampleId, TransferResult moveSample(const std::string& sampleId,
const std::string& fromBankId, const std::string& fromBankId,
const std::string& toBankId); const std::string& toBankId);
// Copies a sample by id from `fromBankId` to `toBankId`: the source entry is // Copies a sample by id between banks, source retained (destination
// retained, the destination gains it (observing destination collapse-by-hash). // collapse-by-hash observed). Cross-bank dedup is NOT enforced — the same hash
// Same hash may then live in both banks — cross-bank dedup is NOT enforced. // may then live in both banks.
TransferResult copySample(const std::string& sampleId, TransferResult copySample(const std::string& sampleId,
const std::string& fromBankId, const std::string& fromBankId,
const std::string& toBankId); const std::string& toBankId);
// -- Sample removal (index-only; the file is NEVER touched — orphaned until prune) -- // -- 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). // Drops a sample's index entry — non-destructive to the file (a last-reference
// Index-only and non-destructive to the file: a last-reference remove leaves the // remove leaves it on disk, orphaned until prune reclaims it). See RemoveScope/
// file on disk, orphaned until Phase R prune — remove NEVER deletes bytes. // RemoveResult for scope and outcome. No mutation on any Rejected outcome.
//
// 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).
RemoveResult removeSample(const std::string& sampleId, RemoveResult removeSample(const std::string& sampleId,
const std::string& fromBankId, const std::string& fromBankId,
RemoveScope scope = RemoveScope::ThisBank); 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 // The bank's sample ids in display (slot) order, reconciled against live index
// iterates, sourced from the bank's SlotMap. Reconciles the map against live index // membership first (drops stale markers, appends unmapped samples densely). An
// membership first (drops stale markers, appends unmapped samples densely), so a // unknown bank id yields an empty vector.
// 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.
std::vector<std::string> orderedSampleIds(const std::string& bankId); std::vector<std::string> orderedSampleIds(const std::string& bankId);
// Ensures every bank's SlotMap is consistent with its index membership: seeds a // 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 // dense order, or reconciles a partial map). Idempotent — call after deserialize
// default — dense, no gaps), and reconciles a partially-populated map (drop stale, // or any out-of-band membership change.
// append unmapped). Idempotent. Called after deserialize and after any capture/
// transfer that added samples out-of-band of the L7 reorder path.
void reconcileSlots(); void reconcileSlots();
// Reorders sample `id` within `bankId` to `targetSlot` (gap-preserving; see // Reorders sample `id` within `bankId` to targetSlot (gap-preserving; index/file/
// SlotMap::reorder). INDEX-ONLY of positions — the sample's membership, file, and // metadata untouched). Returns false (no mutation) on an unknown bank or id.
// 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.
bool reorderSample(const std::string& id, const std::string& bankId, int targetSlot); 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`) // Alt-replace: the dragged `newId` (already a member of bankId) takes the slot of
// takes the slot of the occupant `oldId`, and `oldId` is REMOVED from `bankId`'s // `oldId`, and `oldId` is removed from the index (same semantics as removeSample
// index (index-only, same semantics as removeSample ThisBank — the file stays on // ThisBank). Position is preserved; only the occupant changes.
// disk; owned-manifest/prune govern bytes; hashReferencedElsewhere handles the
// last-reference case). Position of the slot is preserved; only its occupant changes.
// //
// POOL GUARD (settled): the index-removal of `oldId` passes the SAME guard the // Applies the same pool guard as removeSample — per-sample removal from the pool
// remove verb applies — removeSample(oldId, bankId, ThisBank) must return Removed. // is allowed (the pool's guards are un-delete/rename/evacuate, never per-sample
// For the pool this is permitted whenever the occupant exists (per-sample removal // remove). Rejects (false, no mutation of either index or slots) an unknown bank,
// is not a pool privilege violation — the pool's guards are un-delete/rename/evacuate, // a newId/oldId the bank doesn't hold, or newId == oldId.
// 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.
bool replaceSample(const std::string& newId, const std::string& oldId, bool replaceSample(const std::string& newId, const std::string& oldId,
const std::string& bankId); const std::string& bankId);
// Refreshes a sample IN PLACE wherever it lives in the book (M10 re-capture): // Refreshes a sample in place wherever it lives (re-capture): finds the bank
// finds the bank holding `sampleId` and replaces its entry with `updated` // holding sampleId and replaces its entry with `updated` (order-preserving, no
// (order-preserving, no dedup — see BankModel::updateInPlace). Scans banks in // dedup). Updates the FIRST holder in ordinal order if the id lives in multiple
// ordinal order and updates the FIRST holder (a sample id is unique within a // banks via copy. Returns false (no mutation) if no bank holds the id or the
// bank; the same id living in two banks via copy would update the earliest, which // replacement's path is absolute.
// 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.
bool updateSampleInPlace(const std::string& sampleId, const Sample& updated); bool updateSampleInPlace(const std::string& sampleId, const Sample& updated);
// Reference-count query backing the confirm-on-last-reference guardrail: does any // Does any bank other than exceptBankId still hold an entry whose contentHash
// bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`? // == 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."
// Identity is the CONTENT HASH, not the file path: hash is the canonical dedup key // An empty hash never matches (mirrors findByHash) — reads as
// the whole model already reasons in (findByHash / collapse-by-hash), and two // referenced-nowhere-else, the safe confirm-eliciting default.
// 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).
bool hashReferencedElsewhere(const std::string& hash, bool hashReferencedElsewhere(const std::string& hash,
const std::string& exceptBankId) const; const std::string& exceptBankId) const;
// Every project-relative file path referenced by ANY bank in the book, pool // Every project-relative path referenced by any bank (pool included) — the union
// included — the union across the whole book (Phase R, prune). This is the // prune subtracts against. Paths are verbatim (no normalization), first-seen
// safety-critical referenced-set the prune core subtracts: a file referenced by // order across banks in ordinal then insertion order, de-duplicated. An empty
// any bank (INCLUDING via a copy into a second bank) appears here, so prune never // relativePath is skipped.
// 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.
std::vector<std::string> referencedPaths() const; std::vector<std::string> referencedPaths() const;
// -- Query --------------------------------------------------------------- // -- Query ---------------------------------------------------------------
@@ -308,33 +223,25 @@ public:
// -- Persistence --------------------------------------------------------- // -- Persistence ---------------------------------------------------------
// Serializes the whole book to a JSON string (lossless round-trip): the pool // Serializes the whole book to JSON (lossless): pool as bank-zero + named banks
// folded in as bank-zero + named banks + per-bank indices + ordinals + active // + per-bank indices + ordinals + active id. deserialize(serialize(x)) == x.
// id. deserialize(serialize(x)) == x.
std::string serialize() const; std::string serialize() const;
// Parses a book JSON produced by serialize(). std::nullopt on malformed input. // 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 // A bare legacy bank_index JSON (pre-multi-bank shape: a "samples" array, no
// object with a "samples" array and no "banks" key) is promoted into the pool's // "banks" key) is promoted into the pool's index — one-way, lossless — yielding
// index, yielding a book of { pool } with zero named banks — one-way, lossless. // a book of { pool } with zero named banks.
// After migration the book blob is authoritative (the caller persists the book
// shape going forward; the legacy key is retired by the B2 shell).
static std::optional<BankBook> deserialize(const std::string& json); static std::optional<BankBook> deserialize(const std::string& json);
// Resolve a BankBook from the two persisted ext-state values a project may carry: // Resolves a BankBook from the two persisted ext-state values a project may
// the authoritative `banks` blob and the retired-but-possibly-present legacy // carry: the authoritative `banksJson` and the retired legacy `bank_index` blob.
// `bank_index` blob. The persist shell (B2) hands both raw strings straight here so // 1. non-empty banksJson -> deserialize it. If malformed, do NOT fall back to
// the load-source decision stays REAPER-free and unit-tested. Precedence: // legacy — a corrupt banks blob is an error, not an absence; returns an
// 1. non-empty `banksJson` present -> deserialize it (authoritative). If it is // empty book so a stale legacy key can never resurrect superseded state.
// MALFORMED, do NOT silently fall back to the legacy blob — a corrupt `banks` // 2. else non-empty legacyJson -> deserialize it (pool migration).
// blob is an error, not an absence; return an empty book so a stale legacy key // 3. else -> a fresh empty book (pool only).
// can never resurrect a superseded single-bank state over a broken book. // Never returns nullopt — an unloadable input degrades to the empty 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.
static BankBook loadFromPersisted(const std::string& banksJson, static BankBook loadFromPersisted(const std::string& banksJson,
const std::string& legacyJson); const std::string& legacyJson);
@@ -343,41 +250,34 @@ private:
std::string activeBankId_; // always names a live bank; defaults to pool std::string activeBankId_; // always names a live bank; defaults to pool
// Folds a display name to its uniqueness key: strip leading/trailing ASCII // Folds a display name to its uniqueness key: strip leading/trailing ASCII
// whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share // whitespace, lower-case ASCII letters — so "Drums"/"drums"/" Drums " share one
// one key and cannot coexist. ASCII-only by design — the pure core carries no // key. ASCII-only by design — the pure core carries no locale facility. Private
// locale facility and must not grow one. A private STATIC member (Q-W5, settled) // static because both halves of the split implementation (rules + JSON) need the
// because BOTH halves of the split implementation need the ONE folding rule: the // one folding rule; a drifted second copy would let a parsed book violate the
// rules TU (bank_book.cpp, displayNameTaken) and the JSON TU (bank_book_json.cpp, // create/rename uniqueness invariant.
// deserialize's duplicate-display-name coalesce) — a drifted second copy would let
// a parsed book violate the create/rename uniqueness invariant.
static std::string nameKey(const std::string& s); static std::string nameKey(const std::string& s);
// True if a bank OTHER than `exceptId` already carries `name`'s uniqueness key // True if a bank other than exceptId already carries name's uniqueness key.
// (trimmed + case-insensitive, ASCII). Backs the create/rename uniqueness check; // Backs the create/rename uniqueness check; pass exceptId=id to let a bank keep
// pass exceptId=id to let a bank keep (or re-case/-space) its own name. // (or re-case/-space) its own name.
bool displayNameTaken(const std::string& name, const std::string& exceptId) const; bool displayNameTaken(const std::string& name, const std::string& exceptId) const;
// Re-sorts banks_ by ordinal (pool pinned first) and rewrites ordinals to a // 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 // contiguous 0..N-1. Called after any structural change (create/delete/reorder).
// structural change (create / delete / reorder).
void normalizeOrdinals(); void normalizeOrdinals();
// Replaces the book's banks with a parsed set, normalizes ordinals, and resolves // 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 // 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); void adoptBanks(std::vector<Bank>&& banks, const std::string& activeBank);
}; };
// The next bank id to activate when cycling the active bank forward, in ordinal // 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 // order (pool -> named -> ... -> pool, wraps). Free function (not a member) so it's
// after the last returns the first (pool → named → … → pool). This is the pure // unit-testable against a bare id vector without a full book.
// 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.
// * empty list -> "" (nothing to cycle to) // * empty list -> "" (nothing to cycle to)
// * single id (pool-only) -> that id (a one-bank book stays put) // * single id (pool-only) -> that id (a one-bank book stays put)
// * currentBankId not present -> the first id (a sane home to jump to) // * 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, std::string nextBankId(const std::vector<std::string>& orderedBankIds,
const std::string& currentBankId); const std::string& currentBankId);
+27 -43
View File
@@ -6,33 +6,25 @@
#include "core/json/json.h" #include "core/json/json.h"
// bank_book JSON round-trip (Q-W5 extraction out of bank_book.cpp — same header, // bank_book JSON round-trip — a sibling TU to bank_book.cpp, sharing its header
// compiled into the same bank_book target; the slot_map second-TU shape). The // and target. The registry RULES half stays in bank_book.cpp; the one shared
// registry RULES half stays in bank_book.cpp; the ONE shared symbol is the private // symbol is the private static BankBook::nameKey folding rule — the parse-time
// static BankBook::nameKey folding rule (declared in bank_book.h) — the parse-time // duplicate-display-name coalesce below must fold names EXACTLY as create/rename
// duplicate-display-name coalesce below must fold names EXACTLY as the create/rename // uniqueness does, or a parsed book could violate the in-model invariant.
// uniqueness check does, or a parsed book could violate the in-model invariant.
// //
// JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model // The book blob nests one bank object per bank, each carrying that bank's
// and view_mode_model. The book blob nests one bank object per bank, each carrying that // BankModel serialized by bank_model's OWN writer, so per-bank sample
// bank's BankModel serialized by bank_model's OWN writer (BankModel::serialize), // serialization stays owned by bank_model and is not duplicated here. The book
// so per-bank sample serialization stays owned by bank_model and is not duplicated // writer emits the bank envelope (id / displayName / ordinal) plus a raw "index"
// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a // member whose value is the BankModel blob verbatim; the parser splits the book
// raw "index" member whose value is the BankModel blob verbatim; the parser splits // envelope, then hands each nested index blob straight to BankModel::deserialize.
// the book envelope, then hands each nested index blob straight to
// BankModel::deserialize. Ints use %d; strings are escaped by writeEscaped.
// BYTE-IDENTICAL to the pre-extraction writer — the Q-W1 golden-literal test pins it.
namespace reasampler { namespace reasampler {
// =========================================================================== // -- JSON — writer ----------------------------------------------------------
// JSON — writer
// ===========================================================================
namespace { 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); } std::string intToStr(int v) { return json::numToStr(v); }
using ObjWriter = json::Writer; 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 // The nested index is bank_model's own JSON, emitted verbatim so the
// per-sample shape stays owned by BankModel::serialize (not duplicated). // per-sample shape stays owned by BankModel::serialize (not duplicated).
b.keyRaw("index", banks_[i].index.serialize()); b.keyRaw("index", banks_[i].index.serialize());
// L7 display positions (gap-preserving). Absent on a pre-L7 blob; the // Display positions (gap-preserving). Absent on a pre-existing blob;
// parser defaults such a bank's slots from insertion order on load. // the parser defaults such a bank's slots from insertion order on load.
b.keyRaw("slots", banks_[i].slots.serialize()); b.keyRaw("slots", banks_[i].slots.serialize());
} }
out += ']'; out += ']';
@@ -67,13 +59,10 @@ std::string BankBook::serialize() const {
return out; return out;
} }
// =========================================================================== // -- JSON — parser (recursive descent; std::nullopt on malformed input, never UB) --
// JSON — parser (recursive descent; std::nullopt on any malformed input, never UB)
// ===========================================================================
namespace { 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}, // parseBank parses one bank object; parseSlots the "slots" array ([{id, slot},
// ...]) into (id, slot) pairs (empty array valid; the pair-level defensive // ...]) into (id, slot) pairs (empty array valid; the pair-level defensive
// repair — dupes/conflicts — lives in SlotMap::fromEntries); parseBook the root // 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); b.index = std::move(*idx);
haveIndex = true; haveIndex = true;
} else if (key == "slots") { } else if (key == "slots") {
// L7 display positions. Absent on a pre-L7 blob (the else-branch skips // Display positions. Absent on a pre-existing blob; when present it
// nothing because the key never appears); when present it drives the // drives the bank's SlotMap. reconcileSlots() (post-adopt) squares it
// bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership. // with membership.
std::vector<std::pair<std::string, int>> pairs; std::vector<std::pair<std::string, int>> pairs;
if (!parseSlots(r, pairs)) return false; if (!parseSlots(r, pairs)) return false;
b.slots = SlotMap::fromEntries(pairs); 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") { } else if (key == "activeBank") {
if (!r.parseString(activeBank)) return false; if (!r.parseString(activeBank)) return false;
} else if (key == "samples") { } else if (key == "samples") {
// Legacy marker. The legacy index is re-parsed from the whole input below // Legacy marker; the legacy index is re-parsed from the whole input below.
// (BankModel::deserialize owns that shape); here we only skip the value to
// keep the scan well-formed and note that we saw it.
sawSamples = true; sawSamples = true;
if (!r.skipValue()) return false; if (!r.skipValue()) return false;
} else { } else {
@@ -253,23 +240,20 @@ std::optional<BankBook> BankBook::deserialize(const std::string& blob) {
json::Reader r(blob); json::Reader r(blob);
if (!parseBook(r, blob, banks, activeBank)) return std::nullopt; 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, // 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 // 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 // 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- // would degrade the user's entire library to empty, so instead we AUTO-
// DISAMBIGUATE the later duplicate deterministically: scan in parse order, and // DISAMBIGUATE the later duplicate: scan in parse order, and the first time a
// the first time a folded key repeats, suffix that bank's display name (" 2", // folded key repeats, suffix that bank's display name (" 2", " 3", …) until its
// " 3", …) until its folded key is unique among all names seen so far. The FIRST // folded key is unique among names seen so far — the first bank to carry a key
// bank to carry a key keeps its name verbatim; only subsequent collisions are // keeps its name verbatim. No bank or sample is lost, ids are untouched, and the
// renamed. No bank or sample is lost, and ids are untouched. The pool is included // pool's reserved "Pool" key is seeded first so a named bank folding to "pool"
// in the seen-set (its "Pool" key is reserved) so a named bank folding to "pool"
// is disambiguated away from it, never the reverse. // is disambiguated away from it, never the reverse.
// //
// Hosted HERE (a static member, Q-W5) rather than in the free parseBook because // Hosted here (not in the free parseBook) because it folds through the PRIVATE
// it folds through the PRIVATE BankBook::nameKey the same rule the // BankBook::nameKey the create/rename uniqueness check also uses.
// create/rename uniqueness check applies. Runs after parseBook on BOTH shapes;
// the legacy path yields { pool } alone, where the scan is a trivial no-op.
{ {
std::vector<std::string> seenKeys; std::vector<std::string> seenKeys;
seenKeys.reserve(banks.size()); seenKeys.reserve(banks.size());
+21 -40
View File
@@ -4,21 +4,14 @@
#include "core/json/json.h" #include "core/json/json.h"
// bank_model implementation. // 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
// JSON rides on the shared core/json lexical layer (Q-W1: one reader/writer, // significant digits (%.17g), the shortest form that round-trips every
// no per-module Parser copy). The field set is a flat struct of primitives, // IEEE-754 double exactly, so deserialize(serialize(x)) == x holds bit-for-bit.
// 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.
namespace reasampler::model { namespace reasampler::model {
// --------------------------------------------------------------------------- // -- equality -----------------------------------------------------------
// equality
// ---------------------------------------------------------------------------
bool SourceRange::operator==(const SourceRange& o) const { bool SourceRange::operator==(const SourceRange& o) const {
return startSeconds == o.startSeconds && endSeconds == o.endSeconds && return startSeconds == o.startSeconds && endSeconds == o.endSeconds &&
@@ -51,20 +44,15 @@ bool Sample::operator==(const Sample& o) const {
provenance == o.provenance && createdTimestamp == o.createdTimestamp; provenance == o.provenance && createdTimestamp == o.createdTimestamp;
} }
// --------------------------------------------------------------------------- // -- path invariant -------------------------------------------------------
// path invariant
// ---------------------------------------------------------------------------
// DECISION: reject absolute paths rather than normalize them. The pure model has // Rejects absolute paths rather than normalizing them: the pure model has no
// no knowledge of the project root, so it cannot correctly relativize an absolute // knowledge of the project root, so any "normalization" would be a guess that
// path — any "normalization" would be a guess that could point at the wrong file. // could point at the wrong file. Covers POSIX ("/x"), Windows drive ("C:\x",
// Rejecting at the boundary is honest and deterministic; the capture backend (M3) // "C:/x", "C:foo" drive-relative), and UNC ("\\host\share") forms. Any leading
// is responsible for handing us an already-relative path. Covers POSIX ("/x"), // <alpha>: is rejected regardless of what follows — drive-relative paths
// Windows drive ("C:\x", "C:/x", "C:foo" drive-relative), and UNC ("\\host\share") // ("C:foo.wav") resolve against the drive's current directory, not the project
// forms. Any leading <alpha>: is rejected regardless of the character that follows — // root, so they violate relative-paths-only just as much as "C:\foo.wav" does.
// 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.
static bool isAbsolutePath(const std::string& p) { static bool isAbsolutePath(const std::string& p) {
if (p.empty()) return false; if (p.empty()) return false;
if (p[0] == '/' || p[0] == '\\') return true; // POSIX root or UNC if (p[0] == '/' || p[0] == '\\') return true; // POSIX root or UNC
@@ -73,9 +61,7 @@ static bool isAbsolutePath(const std::string& p) {
return false; return false;
} }
// --------------------------------------------------------------------------- // -- BankModel ------------------------------------------------------------
// BankModel
// ---------------------------------------------------------------------------
AddResult BankModel::add(const Sample& sample) { AddResult BankModel::add(const Sample& sample) {
if (sample.id.empty()) return AddResult::RejectedEmptyId; if (sample.id.empty()) return AddResult::RejectedEmptyId;
@@ -139,9 +125,7 @@ std::vector<Sample> BankModel::byTier(Tier tier) const {
return out; return out;
} }
// --------------------------------------------------------------------------- // -- JSON writer ------------------------------------------------------------
// JSON writer
// ---------------------------------------------------------------------------
namespace { namespace {
@@ -182,9 +166,9 @@ void writeSample(std::string& out, const Sample& s) {
w.keyBegin("key"); w.keyBegin("key");
if (s.key) writeEscaped(out, *s.key); else out += "null"; 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` // Emitted as null when absent (same shape as `key`/`provenance`) so JSON that
// and `provenance`) so pre-Phase-S JSON — which lacks these keys entirely — // lacks these keys entirely parses to empty optionals and re-serializes
// parses to empty optionals and re-serializes without invention. // without invention.
w.keyBegin("rootNote"); w.keyBegin("rootNote");
if (s.rootNote) out += numToStr(*s.rootNote); else out += "null"; if (s.rootNote) out += numToStr(*s.rootNote); else out += "null";
@@ -240,12 +224,9 @@ std::string BankModel::serialize() const {
return out; return out;
} }
// --------------------------------------------------------------------------- // -- JSON parser (recursive descent over the shared json::Reader) -----------
// JSON parser (recursive descent over the shared json::Reader). Returns false // Returns false on any malformed input; never reads out of bounds. Only
// on any malformed input; never reads out of bounds. Only supports the subset // supports the subset our writer emits.
// our writer emits. The lexical layer (strings, numbers, skip) lives in
// core/json; only the Sample/index DOMAIN grammar lives here.
// ---------------------------------------------------------------------------
namespace { namespace {
+35 -44
View File
@@ -1,11 +1,7 @@
#pragma once #pragma once
// bank_model — the HEART of ReaSampler, deliberately free of any REAPER type so // bank_model — the HEART of ReaSampler: the per-project sample bank. `Sample`
// it compiles and unit-tests OUTSIDE the DAW. It owns the per-project sample // metadata struct + `BankModel` (add/remove/query/tier moves/dedup-by-hash + JSON
// bank: the `Sample` metadata struct and the `BankModel` (add / remove / query / // round-trip to/from std::string).
// 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.
#include <cstdint> #include <cstdint>
#include <optional> #include <optional>
@@ -14,8 +10,8 @@
namespace reasampler::model { namespace reasampler::model {
// How the source audio was obtained. Kept in the pure core (no REAPER coupling); // How the source audio was obtained; the capture backends map their own notion
// the capture backends (M3/M8) map their own notion onto these. // onto these.
enum class SourceMode { enum class SourceMode {
MasterMix, // offline render of the master output MasterMix, // offline render of the master output
SelectedTracks, // offline render of selected tracks SelectedTracks, // offline render of selected tracks
@@ -31,9 +27,8 @@ enum class Tier {
Archive, Archive,
}; };
// Sample-accurate source bounds, in both project seconds and PPQ (ticks). Both // Sample-accurate source bounds, in both project seconds and PPQ (ticks) — both
// are stored because capture needs seconds and musical placement needs PPQ; we // stored so capture doesn't re-derive one from the other and risk rounding.
// refuse to re-derive one from the other and risk rounding (precision invariant).
struct SourceRange { struct SourceRange {
double startSeconds = 0.0; double startSeconds = 0.0;
double endSeconds = 0.0; double endSeconds = 0.0;
@@ -44,9 +39,9 @@ struct SourceRange {
}; };
// Present only when a sample was resampled FROM another sample. Carries the // 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 // parent's id and an FX-chain snapshot (a thin drift fingerprint, NOT a restorable
// restorable chunk) captured at resample time; the re-capture-from-source action // chunk) — re-capture-from-source uses it to detect chain drift and replay the
// (M10) uses it to detect chain drift and replay the original capture request. // original capture request.
struct Provenance { struct Provenance {
std::string parentSampleId; std::string parentSampleId;
std::string fxChainSnapshot; std::string fxChainSnapshot;
@@ -63,15 +58,13 @@ struct Levels {
bool operator==(const Levels& o) const; bool operator==(const Levels& o) const;
}; };
// Sample-accurate sustain-loop bounds, as frame indices into the captured file // Sample-accurate sustain-loop bounds, as frame indices into the captured file — a
// (Phase S seam field, D-B). A bank intrinsic — a fact about the file, like // bank intrinsic (like sampleRate or length) the MIDI-playback instrument uses to
// sampleRate or length — consumed by the future MIDI-playback instrument to hold // hold notes past the recorded length. One optional struct (not two loose
// notes past the recorded length. Modeled as one optional struct (not two loose // optionals) so "both points or neither" is structural, not a rule to re-check at
// optionals) so "both points or neither" is a structural invariant, not a rule to // every boundary. Frame indices, not seconds — the instrument relates them to time
// re-check at every boundary. Frame indices, not seconds, because the loop is a // via the file's sample rate. Invariant (enforced at deserialize): 0 <= start <=
// per-sample-frame contract; the instrument reads the file's sample rate to relate // end; start == end is a valid zero-length loop marker.
// them to time. Invariant (enforced at the deserialize boundary): 0 <= start <= end.
// start == end is a valid zero-length loop marker.
struct LoopPoints { struct LoopPoints {
std::int64_t start = 0; std::int64_t start = 0;
std::int64_t end = 0; std::int64_t end = 0;
@@ -102,23 +95,23 @@ struct Sample {
double lengthBeats = 0.0; double lengthBeats = 0.0;
double captureTempo = 0.0; // project tempo (BPM) at capture time double captureTempo = 0.0; // project tempo (BPM) at capture time
// Time signature at capture time (L7 F1 — stamped alongside captureTempo so the // Time signature at capture time, stamped alongside captureTempo so the
// bars.beats.subdivisions read-out is stable under later project meter changes). // 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); // 0/0 means UNSTAMPED (pre-existing sample, or a capture that could not read the
// the metadata formatter renders a blank musical read-out for 0/0 and keeps s.ms. // 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 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 int captureTimeSigDenom = 0; // meter denominator (e.g. 4 in 4/4); 0 = unstamped
std::optional<std::string> key; // musical key, when known std::optional<std::string> key; // musical key, when known
// Phase S seam fields (D-B) — bank intrinsics for the MIDI-playback instrument, // Bank intrinsics for the MIDI-playback instrument, additive like `provenance`.
// additive like `provenance` (M1). Both default cleanly empty: pre-Phase-S // Both default cleanly empty: pre-existing samples deserialize without them and
// samples deserialize without them and re-serialize without inventing values. // re-serialize without inventing values.
// - rootNote: MIDI note (0..127) the sample was recorded at, so the instrument // - 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: // 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. // `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) // Populated only where derivable — never guessed when the source isn't a
// when the source is not a single played note. // single played note.
// - loop: sustain-loop bounds, populated only where explicitly set. // - loop: sustain-loop bounds, populated only where explicitly set.
std::optional<int> rootNote; std::optional<int> rootNote;
std::optional<LoopPoints> loop; std::optional<LoopPoints> loop;
@@ -156,7 +149,7 @@ enum class AddResult {
// An ordered, id-keyed collection of Samples with content-hash dedup, tier // An ordered, id-keyed collection of Samples with content-hash dedup, tier
// moves/filtering, and lossless JSON round-trip. Insertion order is preserved // 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 { class BankModel {
public: public:
// Adds a sample. Enforces the relative-paths-only invariant and dedups by // Adds a sample. Enforces the relative-paths-only invariant and dedups by
@@ -168,16 +161,14 @@ public:
bool remove(const std::string& id); bool remove(const std::string& id);
// Replaces the sample carrying `id` IN PLACE (preserving its position in // Replaces the sample carrying `id` IN PLACE (preserving its position in
// insertion order), with `updated`. Used by M10 re-capture-from-source: a // insertion order) with `updated`. Used by re-capture-from-source: a
// provenanced sample's file is regenerated and its metadata (relativePath, // provenanced sample's file is regenerated and its metadata refreshed while
// contentHash, levels, timestamp, ...) refreshed while its identity (id) and // its identity (id) and slot are kept, so the panel shows the same tile
// slot are kept, so the bank panel shows the same tile updated rather than a // updated rather than a reordered new entry. `updated.id` should equal `id`;
// reordered new entry. `updated.id` should equal `id` (the caller keeps the id // a differing id is written through as given. Does NOT dedup — an in-place
// stable); a differing id is written through as given (the caller's contract). // refresh is not a new insert, so collapse-by-hash (which guards inserts)
// Does NOT dedup — an in-place refresh of one entry is not a new insert, so the // does not apply. Returns false (no mutation) if `id` is absent or
// collapse-by-hash rule (which guards NEW inserts) does not apply. Returns false // `updated.relativePath` is absolute (relative-paths-only still holds here).
// (no mutation) if `id` is absent or `updated.relativePath` is absolute
// (the relative-paths-only invariant still holds for the replacement).
bool updateInPlace(const std::string& id, const Sample& updated); bool updateInPlace(const std::string& id, const Sample& updated);
// Returns the sample with `id`, or nullptr if absent. The pointer is // 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" #include "core/json/json.h"
// owned_manifest implementation. // owned_manifest implementation. JSON shape is a single object with one string
// // array:
// JSON rides on the shared core/json lexical layer (Q-W1, mirror of bank_model /
// bank_book / tail_control). The shape is a single object with one string array:
// //
// {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]} // {"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 { namespace reasampler::model {
// --------------------------------------------------------------------------- // -- path invariant (mirror of bank_model's isAbsolutePath) ----------------
// path invariant (mirror of bank_model's isAbsolutePath)
// ---------------------------------------------------------------------------
namespace { namespace {
// Any leading '/' or '\' (POSIX root / UNC), or a leading <alpha>: (Windows drive, // 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 // 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 // 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). // must match exactly.
bool isAbsolutePath(const std::string& p) { bool isAbsolutePath(const std::string& p) {
if (p.empty()) return false; if (p.empty()) return false;
if (p[0] == '/' || p[0] == '\\') return true; if (p[0] == '/' || p[0] == '\\') return true;
@@ -35,9 +29,7 @@ bool isAbsolutePath(const std::string& p) {
} // namespace } // namespace
// --------------------------------------------------------------------------- // -- mutation / query -------------------------------------------------------
// mutation / query
// ---------------------------------------------------------------------------
ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) { ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) {
if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath; if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath;
@@ -53,9 +45,7 @@ bool OwnedFileManifest::contains(const std::string& relativePath) const {
return false; return false;
} }
// --------------------------------------------------------------------------- // -- JSON writer --------------------------------------------------------
// JSON writer (shared core/json escape — byte-identical to the prior local one)
// ---------------------------------------------------------------------------
std::string OwnedFileManifest::serialize() const { std::string OwnedFileManifest::serialize() const {
std::string out = "{\"owned\":["; std::string out = "{\"owned\":[";
@@ -67,11 +57,8 @@ std::string OwnedFileManifest::serialize() const {
return out; return out;
} }
// --------------------------------------------------------------------------- // JSON parser: string-array-only grammar. Tolerates unknown keys and requires
// JSON parser (string-array-only DOMAIN grammar over the shared core/json // the "owned" value to be an array of strings.
// lexical layer). Tolerates unknown keys (forward-compat) and requires the
// "owned" value to be an array of strings.
// ---------------------------------------------------------------------------
namespace { namespace {
+14 -29
View File
@@ -1,31 +1,16 @@
#pragma once #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 // NOT a mirror of the bank index: removing/moving an index entry does NOT remove
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the same // the file's manifest record (the manifest tracks files *created*; prune reconciles
// "small pure type + JSON round-trip" pattern as wav_codec / tab_strip. // manifest-vs-index later). Only the capture add-path adds to it — no remove verb.
// //
// -- What it is -------------------------------------------------------------- // Paths are ALWAYS project-relative (same invariant as Sample.relativePath). add()
// // rejects an absolute path rather than guess a relativization — the pure model has
// The set of files the bank system ITSELF created — every file the capture path // no project root, so "normalizing" could point at the wrong file.
// 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).
#include <optional> #include <optional>
#include <string> #include <string>
@@ -58,12 +43,12 @@ public:
// capture of an identical request does not double-record. // capture of an identical request does not double-record.
ManifestAddResult add(const std::string& relativePath); ManifestAddResult add(const std::string& relativePath);
// True iff the exact path string is recorded. Phase R uses this to attribute a // 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) // present file to the bank system. Exact string match — path normalization (if
// is the caller's concern, consistent across add and query. // any) is the caller's concern, consistent across add and query.
bool contains(const std::string& relativePath) const; 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. // set; here it is the round-trip + query surface.
const std::vector<std::string>& paths() const { return paths_; } const std::vector<std::string>& paths() const { return paths_; }
+15 -28
View File
@@ -4,26 +4,16 @@
#include "core/wire/wire.h" #include "core/wire/wire.h"
// provenance implementation — pure, self-contained (no third-party lib, mirror of // provenance implementation — pure, self-contained encoding.
// bank_model's hand-rolled encoding discipline).
// //
// ENCODING (the fingerprint string): a length-prefixed, field-ordered format so it // Fingerprint grammar: magic "rsprov1" + fixed-order length-prefixed fields
// is unambiguous and forge-proof (a value containing the separator cannot shift // (<len>':'<bytes>), so a value containing the separator can never shift the
// the parse). Grammar: // 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
// "rsprov1" -- magic + version tag // track-GUID count + that many GUIDs, then the folded fxChainIdentity — itself
// then, in fixed order, each field as <len>':'<bytes> // length-prefixed per entry field, so it nests safely as one more field. Any
// // deviation (bad magic, short read, bad number) -> parseFingerprint returns
// Every field — including numbers — is emitted as its decimal / %.17g text then // nullopt.
// 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.
namespace reasampler::model { namespace reasampler::model {
@@ -39,10 +29,9 @@ namespace {
constexpr const char* kMagic = "rsprov1"; constexpr const char* kMagic = "rsprov1";
// The shared core/wire codec (Q-W1, T2-01b) carries the field grammar + the full // The shared core/wire codec carries the field grammar + range-checked fieldInt.
// hardening (incl. the fixed fieldInt range check that closes the old strtol // Only the %.17g double rendering stays local — this writer's convention, shared
// silent-narrowing TODO). Only the %.17g double rendering stays local — it is // with the bank model's JSON doubles.
// this writer's convention, shared with the bank model's JSON doubles.
using wire::putField; using wire::putField;
using Cursor = wire::Cursor; using Cursor = wire::Cursor;
@@ -112,10 +101,9 @@ std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint) {
std::size_t guidCount = 0; std::size_t guidCount = 0;
if (!c.fieldSizeT(guidCount)) return std::nullopt; if (!c.fieldSizeT(guidCount)) return std::nullopt;
// Q-W0 T2-01a (the sample_usage count-sanity pattern): each GUID field costs at least // Each GUID field costs at least 2 wire bytes ("0:"), so a count past size/2 is
// 2 wire bytes ("0:"), so a count past size/2 is provably bogus — reject BEFORE the // provably bogus — reject BEFORE the reserve, so a corrupt/crafted persisted
// reserve, so a corrupt/crafted persisted fingerprint can never drive reserve(huge) // fingerprint can never drive reserve(huge) into std::length_error / bad_alloc.
// into std::length_error / bad_alloc through the shell.
if (guidCount > fingerprint.size() / 2u + 1u) return std::nullopt; if (guidCount > fingerprint.size() / 2u + 1u) return std::nullopt;
r.trackGuids.reserve(guidCount); r.trackGuids.reserve(guidCount);
for (std::size_t i = 0; i < guidCount; ++i) { 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 std::optional<std::string> parent; // the single bank sample all sources point at
for (const std::string& src : sourceItemFiles) { for (const std::string& src : sourceItemFiles) {
// Resolve this source file against the bank by exact normalized path.
const std::string* matchedId = nullptr; const std::string* matchedId = nullptr;
for (const BankFileRef& ref : bankFiles) { for (const BankFileRef& ref : bankFiles) {
if (!ref.absolutePath.empty() && ref.absolutePath == src) { if (!ref.absolutePath.empty() && ref.absolutePath == src) {
+23 -47
View File
@@ -1,35 +1,17 @@
#pragma once #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 // A THIN reproducibility fingerprint (drift-detect + re-run) — NOT a serialized FX
// vendor/ includes. Standard library only. The shell (main.cpp / the action families) // chunk to restore; nothing here stores a restorable chain.
// 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.
#include <optional> #include <optional>
#include <string> #include <string>
@@ -111,7 +93,7 @@ std::string buildFingerprint(const CaptureRecipe& recipe);
// mis-driving a re-capture. // mis-driving a re-capture.
std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint); 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, // 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 // 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) std::string absolutePath; // normalized (forward-slash, no trailing slash)
}; };
// Decides whether a capture derives from a bank sample. // 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
// RULE (stated for the handoff, honest — no false parentage): a capture derives // be resolved points at the SAME bank sample's file (exact normalized absolute
// from a bank sample iff EVERY source item whose media file could be resolved // path). Files not in the bank, or matching MORE THAN ONE distinct bank sample
// points at the SAME bank sample's file (by exact normalized absolute path). If // (ambiguous), yield no parent. An empty source-file set yields no parent.
// 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.
// //
// sourceItemFiles : normalized absolute paths of the capture's source items' // sourceItemFiles : normalized absolute paths of the capture's source items'
// take media files (the shell gathers + normalizes them). A // take media files, gathered by the shell. A file that could
// file that could not be resolved is simply omitted by the // not be resolved is simply omitted — never an empty string.
// shell — it never becomes an empty string here.
// bankFiles : the active book's samples as BankFileRefs (path -> id). // bankFiles : the active book's samples as BankFileRefs (path -> id).
// //
// Returns the parent sample id, or nullopt when the capture is not a genuine // Returns the parent sample id, or nullopt otherwise. Both sides are normalized
// resample-from-sample. Comparison is exact path identity; the caller normalizes // identically via normalizeSlashes (lowercased on Windows) so a slash/case
// both sides identically via normalizeSlashes (which lowercases on Windows) so a // difference never spuriously matches or misses.
// 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.
std::optional<std::string> detectParent( std::optional<std::string> detectParent(
const std::vector<std::string>& sourceItemFiles, const std::vector<std::string>& sourceItemFiles,
const std::vector<BankFileRef>& bankFiles); const std::vector<BankFileRef>& bankFiles);
+2 -6
View File
@@ -4,12 +4,10 @@
#include "core/json/json.h" #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 // 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 // slot per id. Every mutator restores it; queries assume it.
// shared core/json emit helpers — the emitted fragment is byte-identical to the
// pre-extraction bank_book writer.
namespace reasampler::model { namespace reasampler::model {
@@ -128,8 +126,6 @@ SlotMap SlotMap::fromEntries(const std::vector<std::pair<std::string, int>>& pai
std::string SlotMap::serialize() const { std::string SlotMap::serialize() const {
// Array of {id, slot} objects in ascending slot order (entries_ is kept sorted). // 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; std::string out;
out += '['; out += '[';
for (std::size_t i = 0; i < entries_.size(); ++i) { for (std::size_t i = 0; i < entries_.size(); ++i) {
+12 -20
View File
@@ -1,21 +1,13 @@
#pragma once #pragma once
// slot_map — the L7 gap-preserving display-position carrier for ONE bank (F2 settled: // slot_map — the gap-preserving display-position carrier for ONE bank: plain
// plain interchangeable slots, NOT M9 fixed/addressable slots). A slot is just a // interchangeable slots, not fixed/addressable ones. A slot is a display position a
// display position a sample id occupies; the map is sample id -> slot (>= 0). Gaps // sample id occupies; the map is sample id -> slot (>= 0). Gaps are first-class (a
// are first-class: a bank may have a sample at slot 1 with slot 0 empty (an empty // bank may have slot 1 occupied with slot 0 empty). At most one id per slot, at
// first row above an occupied second row). At most one id per slot (a slot is never // most one slot per id.
// double-occupied) and at most one slot per id (an id sits in exactly one place).
// //
// Position lives HERE, not on Sample (CLAUDE.md wrapping discipline): a copy of one // Position lives HERE, not on Sample: a copy of one sample into two banks may sit
// sample into two banks may sit at different slots, so position is a per-bank display // at different slots, so position is a per-bank display concern owned by the
// concern owned by the bank's membership. bank_model / Sample stay untouched. // bank's membership. bank_model / Sample stay untouched.
//
// Extracted from bank_book (Q-W1, T4-05): a self-contained ordered-slot container
// with its own serialize, distinct from the multi-bank registry that carries it.
// Behavior covered by bank_book_tests (the round-trip + reorder/reconcile suites);
// a dedicated slot_map_tests target is a welcome follow-up, not a Q-W1 requirement.
//
// PURE: standard library + core/json (serialize) only.
#include <cstddef> #include <cstddef>
#include <string> #include <string>
@@ -50,7 +42,7 @@ public:
// keeps its position. Returns true if the id was mapped. // keeps its position. Returns true if the id was mapped.
bool remove(const std::string& id); 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 EMPTY -> `id` moves there; its old slot is left empty.
// * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and // * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and
// every occupant at slot >= targetSlot (except `id` itself) shifts up by one, // 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); bool reorder(const std::string& id, int targetSlot);
// Rebuilds the map densely from `ids` in the given order (slot i = ids[i]), // 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 // dropping any prior state. The migration path: a bank with no persisted slot
// slot data is seeded from its BankModel insertion order, densely packed (no gaps), // 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. // so it is visually identical on first load. Empty/duplicate ids skipped.
void resetDense(const std::vector<std::string>& ids); void resetDense(const std::vector<std::string>& ids);
// Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left // 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