Compare commits
18 Commits
1.0.0
...
be37192fe9
| Author | SHA1 | Date | |
|---|---|---|---|
| be37192fe9 | |||
| 949dc3af7c | |||
| 47ccadf726 | |||
| 9d037fb233 | |||
| 58c6d49261 | |||
| 8dac5b4a54 | |||
| 80df142605 | |||
| 12ffe377e5 | |||
| 65ca1e1f9d | |||
| 3d3415f943 | |||
| 54f5f24506 | |||
| cc36dd59c7 | |||
| d4d29146c7 | |||
| ccd9968be1 | |||
| 354192ae27 | |||
| 3599d97836 | |||
| 85cc340e58 | |||
| 1f24c4b095 |
-3258
File diff suppressed because it is too large
Load Diff
-1230
File diff suppressed because it is too large
Load Diff
-2779
File diff suppressed because it is too large
Load Diff
@@ -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.**
|
||||
> S1–S18 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 S1–S6 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 0–1 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/W2v–W5 god-module splits → W6 OCP finish).
|
||||
>
|
||||
> **PHASE STATUS (2026-07-29): all seven waves (Q-W0..Q-W6 incl. Q-W2v) are structurally
|
||||
> COMPLETE.** Remaining before the phase closes and merges to `dev`: (1) Daniel's in-DAW
|
||||
> verification batch — the full deferred list across all waves (panel parity, editor/processor
|
||||
> parity, stereo Preserve listening, null test, bit-identical repeats, capture flows, action
|
||||
> families, one-op-one-Ctrl-Z, prune fail-safes, save/load/relocation) — now unblocked since the
|
||||
> tree is stable; (2) the phase-close CLAUDE.md architecture refresh (module map still describes
|
||||
> some pre-Q homes); (3) the phase-q → dev merge on Daniel's sign-off. See `COMPLETED.md` for
|
||||
> each wave's full landed narrative.
|
||||
|
||||
## Q-W0 — pre-restructure functional + DSP quality audit (runs FIRST; gates Q-W1)
|
||||
**STATUS (2026-07-29): audit COMPLETE, triage COMPLETE, sign-off COMPLETE, fix-now
|
||||
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 ~322–369 per file;
|
||||
> no honest seam remains; bisection was rejected.
|
||||
>
|
||||
> **Review note for the Q-W4 planning record:** `panel_bank_ops`'s verbs still embed
|
||||
> prompts/panel-state nudges — Q-W4's dedupe needs promptless inner verbs (`renameBank(id,name)`
|
||||
> etc.), not a call-site swap; `promptText`/`mintBankId` are byte-identical twins with
|
||||
> `actions.cpp` and are the cheapest first dedupe.
|
||||
>
|
||||
> **In-DAW verification (panel parity) is PENDING on `phase-q`** — deferred by design, not yet
|
||||
> performed.
|
||||
|
||||
## Q-W2v — split the VST god-modules (NEW wave — Q-W0 T4 §1.5; runs parallel with Q-W2)
|
||||
|
||||
> **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/L4–L7 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.
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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."*
|
||||
+133
@@ -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 1–5 are all settled; one panel-polish detail remains open. Fork 4 already settled that the active-bank indicator must be "visually unmistakable" — only its placement is undecided.
|
||||
|
||||
**The wart.** No placement chosen yet among three candidates: per-region headers, a single header readout, or a lit-tab treatment.
|
||||
|
||||
**Intended fix.** Not stated in the source beyond the three candidate placements above — the choice among them is the open item. This is explicitly a panel-polish detail.
|
||||
|
||||
**The constraint the fix MUST handle.** Not stated in the source.
|
||||
|
||||
**Priority / risk.** Not stated as a priority level; the source characterizes this as a "panel-polish detail."
|
||||
|
||||
**Done looks like.** Not stated in the source beyond choosing one of the three placement options.
|
||||
@@ -10,7 +10,8 @@ flatness, naming families) are **not restated**; where a finding below touches t
|
||||
is because the functional mechanism is new.
|
||||
|
||||
Every claim below was verified by grep/read of the actual tree. Line numbers are as of this
|
||||
audit's snapshot. Wave assignments reference PLAN.md §Q-W1..Q-W6.
|
||||
audit's snapshot. Wave assignments reference the Q-W1..Q-W6 waves (landed history in
|
||||
`docs/ARCHIVE.md`; sequencing in `docs/product/code-organization.md` §5).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Static analysis, 2026-07-28, branch `pq-w0-audit`. Scope: any value stored in an
|
||||
environment-coupled domain — frames, sample rate, DPI, pixels, tick cadence — that should be
|
||||
stored **rate-free / device-free and resolved at the point of use** (PLAN.md §Q-W0 env-coupled
|
||||
bullet; `docs/product/code-organization.md` §2c.3; the load-bearing `sample_map` seconds
|
||||
invariant). Findings are domain-modeling calls, not "rescale by rate" patches. The judgment bar
|
||||
stored **rate-free / device-free and resolved at the point of use** (`docs/product/code-organization.md`
|
||||
§2c.3 env-coupled bullet; the load-bearing `sample_map` seconds invariant, documented in
|
||||
`src/core/instrument/CLAUDE.md`). Findings are domain-modeling calls, not "rescale by rate" patches. The judgment bar
|
||||
applied: a finding requires (a) an env-coupled *stored* domain AND (b) an environment that can
|
||||
actually change under it. Frame counts computed transiently from seconds at the use site are
|
||||
correct and are not reported.
|
||||
@@ -63,7 +63,7 @@ fix-now findings in those files must be remediated in Q-W0 itself.
|
||||
normalize the Trigger fade-in/out knobs.
|
||||
- **Stored vs. correct domain:** the fade **storage** domain (int64 SOURCE frames, persisted in
|
||||
the zones payload) is settled and correct — a source-timeline fact, invariant under project-
|
||||
rate change (PLAN.md §S15). The *UI ceiling*, however, encodes a wall-clock intent ("2-second
|
||||
rate change (`docs/ARCHIVE.md` §S15). The *UI ceiling*, however, encodes a wall-clock intent ("2-second
|
||||
max fade throw") as a frame count at an assumed 44.1 kHz source. `88200` is a rate-derived
|
||||
literal in `src/`, brushing the no-hardcoded-rate ruling even though it never touches disk.
|
||||
- **What breaks when the environment shifts:** the environment here is the **source file's
|
||||
@@ -165,7 +165,7 @@ fix-now findings in those files must be remediated in Q-W0 itself.
|
||||
`SampleRefs` (paths + root/loop/channels intrinsics), `instanceGuid` — all rate-free or
|
||||
file-fact domains. Clean.
|
||||
- **Trigger `fadeInFrames`/`fadeOutFrames`/`startPoint`/`SampleLoop.start/end` persisted as
|
||||
int64 SOURCE frames:** deliberate, settled source-timeline facts (PLAN.md §S15;
|
||||
int64 SOURCE frames:** deliberate, settled source-timeline facts (`docs/ARCHIVE.md` §S15;
|
||||
`bank_model.h:66-72` documents the loop rationale) — frames *of the file* are invariant under
|
||||
project-rate change; the file's own rate is stored alongside and resolved at decode. Correct
|
||||
domain, not a finding.
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
# Capture tail — spec
|
||||
|
||||
Authoritative spec for the **capture-tail** feature: preserving reverb/release
|
||||
tails that decay past the end of a capture range. The tickable milestone lives in
|
||||
`PLAN.md` (Milestone T); this doc holds the full technical detail **and** the
|
||||
product framing.
|
||||
tails that decay past the end of a capture range. The tickable milestone's landed
|
||||
history is in `docs/ARCHIVE.md` (Milestone T); this doc holds the full technical
|
||||
detail **and** the product framing.
|
||||
|
||||
> **Why this doc carries the technical spec (not `CONTEXT.md`).** Every other
|
||||
> pillar (capture M0–M11, Design View, Multi-bank) keeps its authoritative
|
||||
> technical spec as a `CONTEXT.md §` section and its *why* in a `docs/product/`
|
||||
> note. Capture-tail is a rider on the already-shipped offline-render path
|
||||
> (M3/M7), not a standalone pillar, and it is being specced without reopening
|
||||
> `CONTEXT.md`. So the authoritative detail lands **here**, house-styled to match
|
||||
> the CONTEXT specs; when the tail work lands, doc-keeper may fold the invariant
|
||||
> deltas into `CONTEXT.md §Precision invariants` as landed history. Same standing
|
||||
> discipline applies: **verify every REAPER API name/flag against
|
||||
> **Why this doc carries the technical spec (not the architecture docs).** Every
|
||||
> other pillar (capture M0–M11, Design View, Multi-bank) keeps its authoritative
|
||||
> technical spec as a per-directory `src/**/CLAUDE.md` section and its *why* in a
|
||||
> `docs/product/` note. Capture-tail is a rider on the already-shipped
|
||||
> offline-render path (M3/M7), not a standalone pillar, and it was specced without
|
||||
> reopening the architecture spec. So the authoritative detail lands **here**,
|
||||
> house-styled to match those specs; the landed invariant deltas are folded into
|
||||
> root `CLAUDE.md` §Precision invariants and `src/core/capture/CLAUDE.md` /
|
||||
> `src/shell/capture/CLAUDE.md` as landed history. Same standing discipline
|
||||
> applies: **verify every REAPER API name/flag against
|
||||
> `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use** — the flag values
|
||||
> below are transcribed from that header (line numbers cited) and are not guesses.
|
||||
|
||||
@@ -252,7 +253,7 @@ held to bit-identical repeats.
|
||||
- **Opt-in beyond the region.** The tail only ever adds audio past the range end
|
||||
when a tail is explicitly requested (Auto or Manual). This is exactly the existing
|
||||
invariant: *"no added silence unless a tail is explicitly requested"*
|
||||
(`CONTEXT.md §Precision invariants`, `CLAUDE.md §Exact bounds`). Auto-trim
|
||||
(`CLAUDE.md §Precision invariants §Exact bounds`). Auto-trim
|
||||
strengthens it — the tail added is decay, not silence, and the silence past the
|
||||
decay is trimmed off.
|
||||
- **The null test uses NO tail.** The null-test / verify capture (M10) and any
|
||||
@@ -274,8 +275,8 @@ held to bit-identical repeats.
|
||||
the tail is **the track's own reverb/delay decay**, not the parent bus's. A track
|
||||
with a reverb plugin captures that reverb's tail; a track feeding a folder reverb
|
||||
does **not** capture the folder reverb's tail (that send is out of scope — and
|
||||
note the pre-existing send-isolation caveat in `PLAN.md §Open questions`, which
|
||||
the tail inherits unchanged, does not worsen). Correct and consistent.
|
||||
note the pre-existing send-isolation caveat, which the tail inherits
|
||||
unchanged, does not worsen). Correct and consistent.
|
||||
- **Item scope:** item/take FX only, self-track + ancestors + master bypassed →
|
||||
the tail is the **item/take FX decay only**. An item with a take reverb captures
|
||||
its tail; the track's reverb does not ring into it. Correct.
|
||||
|
||||
@@ -9,8 +9,10 @@ subdirectories) **without sacrificing runtime performance**, against a stated qu
|
||||
>
|
||||
> Bring the codebase "**into the realm of something I can stand to look at.**"
|
||||
|
||||
Its build roadmap lives in **PLAN.md §Phase Q** and its authoritative spec in
|
||||
**CONTEXT.md §Phase Q**. This doc holds the *why* — the quality bar, the evidence base
|
||||
Its build roadmap's landed history lives in **`docs/ARCHIVE.md`** ("Phase Q — Quality:
|
||||
the decision record") and its authoritative spec now lives in root **`CLAUDE.md`**
|
||||
(§Directory and namespace layout, §Performance guardrails, §Structural heuristics)
|
||||
plus the per-directory `src/**/CLAUDE.md` files. This doc holds the *why* — the quality bar, the evidence base
|
||||
(a grep-verified SOLID audit), the target directory/namespace shape grounded in the Vital
|
||||
reference, and the numbered fork decisions.
|
||||
|
||||
@@ -321,10 +323,10 @@ re-namespaced, and split, not carried forward untouched into a tidier tree. "Som
|
||||
look at" is partly shape (§2/§2b) and partly *functional soundness* (§2c).
|
||||
|
||||
Q-W0 is **not executed by product-designer** — this doc *defines the wave and its scope*; the audit
|
||||
itself is staff-engineer/DSP work. The authoritative wave definition lives in **PLAN.md §Q-W0** and
|
||||
its spec framing in **CONTEXT.md §"The pre-restructure audit wave (Q-W0)"**; this section is the
|
||||
evidence-doc framing (the *why* and the smell taxonomy), matching how §2/§2b frame the structural
|
||||
audits.
|
||||
itself is staff-engineer/DSP work. The wave's landed history lives in **`docs/ARCHIVE.md`**
|
||||
("Q-W0 fix-now remediations" and "Phase Q — Quality: the decision record"), and its findings live in
|
||||
**`docs/product/code-quality-audit.md`**; this section is the evidence-doc framing (the *why* and
|
||||
the smell taxonomy), matching how §2/§2b frame the structural audits.
|
||||
|
||||
### 2c.1 Audit surfaces (the named targets)
|
||||
|
||||
@@ -451,7 +453,7 @@ namespace of every header, splitting the four largest TUs). Meanwhile:
|
||||
- **Phase L** has **L3** (VST editor + embed-strip restyle, gated on Phase S landing on dev)
|
||||
still to land — it touches the Phase S draw shells (`reasampler_editor` / `reasampler_embed`),
|
||||
which arrive on dev with Phase S. (L1/L2/L4/L5/L6/L7 have **already landed** — see
|
||||
`COMPLETED.md`; the once-listed "L2 pending" is stale and has been corrected here.)
|
||||
`docs/ARCHIVE.md`; the once-listed "L2 pending" is stale and has been corrected here.)
|
||||
- **D2** is **functionally complete** (D2-W1..W3-B landed; the only open item — a per-track
|
||||
lane-split panel indicator — is *explicitly deferred*, not a blocking residual). **M9** (slots)
|
||||
is **abandoned** (Daniel, 2026-07-27) — will not be built. D2 is named in the gate only so a
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
Date: 2026-07-28 · Branch: `pq-w0-audit` · Static analysis only; no code changed by the audit.
|
||||
|
||||
This is the committed Q-W0 findings report (Q-10 SETTLED: a committed doc beside the SOLID/naming
|
||||
audit — `docs/product/code-organization.md` §2c.3; deliverable contract in PLAN.md §Q-W0). It
|
||||
audit — `docs/product/code-organization.md` §2c.3; deliverable contract landed per `docs/ARCHIVE.md`
|
||||
§Phase Q). It
|
||||
synthesizes four parallel audit tracks; the full track notes remain in the tree as appendices and
|
||||
are the evidence base for every claim here — this report cites finding IDs and does not restate
|
||||
mechanisms in full:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Design View — product notes
|
||||
|
||||
Framing, rationale, and design-direction calls behind the **Design View** phase.
|
||||
The tickable spec lives in `PLAN.md` (Phase D) and the authoritative technical
|
||||
detail in `CONTEXT.md` (§Design View). This doc holds the *why* — the workflow
|
||||
The tickable spec's landed history lives in `docs/ARCHIVE.md` (Phase D) and the
|
||||
architecture detail in `src/core/view/CLAUDE.md` + `src/shell/view/CLAUDE.md`
|
||||
(§Design View). This doc holds the *why* — the workflow
|
||||
narrative, the N-mode reasoning, the screenset differentiation, and the
|
||||
design-direction recommendations — so those don't clutter the build docs.
|
||||
|
||||
@@ -339,8 +340,7 @@ tooltip on the switch.
|
||||
`docs/design/`), say so and it moves. Flagging because establishing the
|
||||
directory is a project convention, not mine to assume silently.
|
||||
2. **Phase namespace.** Proposed **Phase D** (a lettered namespace) rather than
|
||||
M12 — see PLAN.md rationale. Confirm you're happy with letters for parallel,
|
||||
non-capture phases.
|
||||
M12. Confirm you're happy with letters for parallel, non-capture phases.
|
||||
3. **SDK name correction.** The brief cited `B_SHOWINMCP`; the verified SDK flag
|
||||
for mixer-panel visibility is **`B_SHOWINMIXER`** (SDK header line 2235). The
|
||||
spec uses the verified name. Just flagging the discrepancy so it isn't a
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
Framing for a **MIDI-triggered audio sampler** that plays back ReaSampler's captured
|
||||
banks. This began as a discussion-shaping doc; with all forks now settled it has become
|
||||
the **product framing behind a scoped phase**. Its build roadmap lives in **PLAN.md
|
||||
§Phase S** and its authoritative spec in **CONTEXT.md §Phase S** — this doc holds the
|
||||
*why* (the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, the settled
|
||||
decision record).
|
||||
the **product framing behind a scoped phase**. Its build roadmap lives in
|
||||
**`docs/ARCHIVE.md` §Phase S** and its authoritative spec in
|
||||
**`src/core/instrument/CLAUDE.md`** and **`src/shell/instrument/CLAUDE.md`** — this
|
||||
doc holds the *why* (the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, the
|
||||
settled decision record).
|
||||
|
||||
Status: framed by product-designer (2026-07-26), **revised 2026-07-27 (r11)**. r11 records the
|
||||
**Sample-face recomposition** (Daniel's post-landing DAW pass, 2026-07-27): all linear sliders →
|
||||
@@ -42,10 +43,11 @@ demoted to an opt-in Zones panel — see the r6 Addendum in §4. r6 also settles
|
||||
S1–S6 instrument: the product name **ReaSampler 9000** and the **"better than RS5K" UX
|
||||
overhaul** (Phase S points S10–S13) — see the r5 Addendum in §4. r4 (below) settled the four
|
||||
residual forks D-A..D-D. The
|
||||
"no PLAN.md footprint" era is **over** — with D-A through D-D settled (below), the
|
||||
"no landed-roadmap footprint" era is **over** — with D-A through D-D settled (below), the
|
||||
instrument was scoped into **Phase S** (codename Daniel's: "S" for Sampler, because "D"
|
||||
collides with the existing Design View phase). **PLAN.md §Phase S is now the
|
||||
authoritative roadmap; CONTEXT.md §Phase S is the authoritative spec.** This doc is the
|
||||
collides with the existing Design View phase). **`docs/ARCHIVE.md` §Phase S now records
|
||||
the landed roadmap; `src/core/instrument/CLAUDE.md` and `src/shell/instrument/CLAUDE.md`
|
||||
are the authoritative spec.** This doc is the
|
||||
framing/decision record they point back to. Prior revisions (a) established that a REAPER
|
||||
*extension* cannot be a MIDI instrument, (b) corrected a material omission — REAPER's
|
||||
**VST-host bridge**, which lets a VST3 plugin *hosted inside REAPER* call back into
|
||||
@@ -410,8 +412,8 @@ it doesn't carry" — is unchanged. What the bridge settles is *where that mappi
|
||||
between extension and instrument as **live shared `"reasampler"` state**, not a file one
|
||||
writes and the other re-parses.
|
||||
|
||||
**What the current index carries** (from `bank_model`'s `Sample`, per CONTEXT.md §Data
|
||||
model): id, display name, relative path, source range, channel count, sample rate,
|
||||
**What the current index carries** (from `bank_model`'s `Sample`): id, display name,
|
||||
relative path, source range, channel count, sample rate,
|
||||
length, capture tempo, an **optional key**, peak/RMS/LUFS, content hash, tier,
|
||||
provenance, timestamp. Notably it *already* has an optional key field and capture
|
||||
tempo — the seeds of pitch-mapping are there.
|
||||
@@ -426,8 +428,8 @@ tempo — the seeds of pitch-mapping are there.
|
||||
to velocity zones).
|
||||
- **Round-robin groups** (cycle through N samples on repeated same-note hits).
|
||||
- **Loop points** (sustain loop start/end for held notes; sample-accurate,
|
||||
zero-crossing-aware — CONTEXT already flags loop/zero-crossing handling as
|
||||
day-one-relevant for wavetable material).
|
||||
zero-crossing-aware — loop/zero-crossing handling is day-one-relevant for
|
||||
wavetable material).
|
||||
- **Amplitude envelope** (ADSR) and optionally filter/pitch envelopes.
|
||||
- **Tuning/gain trim** per sample.
|
||||
|
||||
@@ -559,7 +561,7 @@ ReaSampler-native way to build it and it's assumed, not debated, going forward.
|
||||
|
||||
All four residual decisions are now called. Each is marked **SETTLED** with Daniel's
|
||||
choice and the reasoning kept as the record of *why* — do not re-litigate. They are
|
||||
scoped into **PLAN.md §Phase S** / **CONTEXT.md §Phase S**.
|
||||
scoped into **`docs/ARCHIVE.md` §Phase S** / **`src/core/instrument/CLAUDE.md`**.
|
||||
|
||||
**D-A — SETTLED: bare Steinberg VST3 SDK + LICE editor (no JUCE).** *(The central fork.
|
||||
§1a is the assessment that fed it. The sub-question — who draws the editor? — was the
|
||||
@@ -649,7 +651,7 @@ After Phase S was scoped (D-A..D-D), Daniel set two further directions. These ar
|
||||
**settled directions**, not open forks — specced as new Phase S points (S7–S9), not
|
||||
re-litigated. Recorded here per the doc's settled-decisions convention.
|
||||
|
||||
**D-E — Channel mode: mono | stereo, per-instance, bus-negotiated (→ PLAN.md S7).**
|
||||
**D-E — Channel mode: mono | stereo, per-instance, bus-negotiated (→ `docs/ARCHIVE.md` §S7).**
|
||||
Captures are often stereo; the current mono downmix is a Tier-0 simplification. The
|
||||
engine gets a **per-instance channel-mode toggle (1 mono / 2 stereo)** that "works with
|
||||
the REAPER audio bus automatically" — the VST3 declares/negotiates its output bus
|
||||
@@ -662,8 +664,8 @@ Cross-mode policy: mono-source-in-stereo → dual-mono; stereo-source-in-mono
|
||||
never a bank fact). Sequenced **first after the editor/embed work** because it touches the
|
||||
engine Daniel smoke-tests.
|
||||
|
||||
**Ingest routes through the bank — "option 1"; the extension owns ingest (→ PLAN.md
|
||||
S8 + S9).** Loading a sample into the sampler is **one gesture**: capture/import-into-bank
|
||||
**Ingest routes through the bank — "option 1"; the extension owns ingest (→
|
||||
`docs/ARCHIVE.md` §S8 + §S9).** Loading a sample into the sampler is **one gesture**: capture/import-into-bank
|
||||
+ auto-assign to the active instance. The **extension owns ingest** (it has arrange
|
||||
access, Media-Explorer access, and the drop-target surface on its own panels); the
|
||||
**instrument stays a read-only bank consumer** — it never captures or imports. Sub-parts,
|
||||
@@ -690,7 +692,7 @@ with the honest SDK reality verified against the vendored headers:
|
||||
|
||||
*The genuine spikes flagged (not decisions Daniel owes, just build-time unknowns):* the
|
||||
ME merely-selected-file read (b), and the drop-onto-editor cross-artifact relay (c). Both
|
||||
are honestly-flagged as spikes in PLAN.md S8, not promised.
|
||||
are honestly-flagged as spikes in `docs/ARCHIVE.md` §S8, not promised.
|
||||
|
||||
### Addendum — product name + UX overhaul (Daniel, 2026-07-26, post-S1–S6 DAW test)
|
||||
|
||||
@@ -700,7 +702,7 @@ open forks (the two flagged forks below are the only calls left to Daniel).
|
||||
|
||||
**The instrument's product name is `ReaSampler 9000`.** The extension stays **ReaSampler**
|
||||
(capture + organization); the instrument is **ReaSampler 9000** (playback). Propagation is
|
||||
a checklist item (PLAN.md §Phase S — product name; CONTEXT.md §Product name): the VST3
|
||||
recorded in `docs/ARCHIVE.md` §Phase S — product name: the VST3
|
||||
class **display name** string, the `IPlugView` editor title band (today "ReaSampler
|
||||
Instrument"), the S6 embed-strip label, and the docs. **Compat guard (load-bearing):** the
|
||||
**VST3 class UID must NOT change** — instances in saved projects key off it; a UID change
|
||||
@@ -747,8 +749,9 @@ names the editor as the wound.
|
||||
|
||||
After the r5 UX-overhaul directive was specced (keymap-first S10), Daniel reframed the
|
||||
workflow before S10 was implemented. This **revises S10** and settles S-NAME-1. Settled
|
||||
directions, not open forks — recorded here per the doc's settled-decisions convention; PLAN.md
|
||||
§S10 and CONTEXT.md §Phase S (workflow hierarchy) carry the spec.
|
||||
directions, not open forks — recorded here per the doc's settled-decisions convention;
|
||||
`docs/ARCHIVE.md` §S10 records what landed and `src/core/instrument/CLAUDE.md` (the
|
||||
editor `ui/` modules) documents the current architecture.
|
||||
|
||||
**The reframe, verbatim (Daniel, 2026-07-26):** *"We need to think hard about the workflow
|
||||
with this plugin. Have a giant list of 'item' blocks is visually useless. When the plugin is
|
||||
@@ -806,8 +809,8 @@ partly on filename, fall back to keeping the filename and record that as shipped
|
||||
|
||||
Daniel directed a set of engine features for the sampler, specced as **new Phase S points
|
||||
S15 (Trigger vs Gate) and S16 (pitch envelope)**. **The feature set is settled** — recorded
|
||||
here per the doc's settled-decisions convention; PLAN.md §S15/S16 and CONTEXT.md §Sampling
|
||||
modes carry the spec. Two forks are flagged with leans (S15-F1 choke, S15-F2 param
|
||||
here per the doc's settled-decisions convention; `docs/ARCHIVE.md` §S15 / §S16 records what
|
||||
landed and `src/core/instrument/CLAUDE.md` §Sampling modes documents the current spec. Two forks are flagged with leans (S15-F1 choke, S15-F2 param
|
||||
granularity); the WDL question was resolved by inspection.
|
||||
|
||||
**Directive, verbatim (Daniel, 2026-07-26):** *"let's have product spec out some features
|
||||
@@ -927,9 +930,10 @@ This reshapes S16 and **flips the r7 WDL verdict** on `WDL_SimplePitchShifter`.
|
||||
`process` allocation; measure per-voice CPU + onset latency against the polyphony cap. Treat
|
||||
S16's Preserve-engine point as the phase's next real DSP spike, not a thin envelope add-on.
|
||||
|
||||
**Where the spec lives:** PLAN.md §S16 (reshaped to "pitch engine modes + pitch envelope",
|
||||
with forks S16-F1/F2 and the corrected WDL finding) and the S15 × S16 interaction note;
|
||||
CONTEXT.md §Pitch engine modes — Varispeed vs Preserve + the corrected WDL surface finding.
|
||||
**Where the spec lives:** `docs/ARCHIVE.md` §S16 (reshaped to "pitch engine modes + pitch
|
||||
envelope", with forks S16-F1/F2 and the corrected WDL finding) and the S15 × S16
|
||||
interaction note; `src/core/instrument/CLAUDE.md` §Sampling modes — Varispeed vs Preserve
|
||||
+ the WDL surface finding.
|
||||
|
||||
### Addendum — VST channel isolation (Daniel, 2026-07-26)
|
||||
|
||||
@@ -972,9 +976,9 @@ with or right after the in-flight waves (S9 ext_keys, S15/S16 processor/editor)
|
||||
channel's banks; stable-project + beta-VST = clean empty (not error); the S-NAME-1
|
||||
rename/rebind test extends to the beta UID.
|
||||
|
||||
**Where the spec lives:** PLAN.md §S18; CONTEXT.md §VST3 channel identity — the UID pair + the
|
||||
pairing surface. The pairing surface's data half is already load-bearing V4 machinery; S18
|
||||
adds only the identity fork on top.
|
||||
**Where the spec lives:** `docs/ARCHIVE.md` §S18; `src/shell/instrument/CLAUDE.md` §VST3
|
||||
channel identity — the UID pair + the pairing surface. The pairing surface's data half is
|
||||
already load-bearing V4 machinery; S18 adds only the identity fork on top.
|
||||
|
||||
---
|
||||
|
||||
@@ -986,8 +990,9 @@ good — but the two-view editor (today's "Browser" + "Zones" toggle) misallocat
|
||||
the default window is undersized for a 1080p world, and the drop-a-capture-onto-FX gesture is
|
||||
broken in practice. The directive: **make the one job — pick a capture, tune it, play it —
|
||||
fast, easy, and fun. Style is a critical ingredient. No spreadsheet aesthetics.** These are the
|
||||
`r9` calls. Authoritative spec: **CONTEXT.md §Phase S — editor view-model redesign (S-VIEW)**;
|
||||
build roadmap: **PLAN.md §Phase S — editor view-model redesign**.
|
||||
`r9` calls. Current architecture: **`src/core/instrument/CLAUDE.md`** and
|
||||
**`src/shell/instrument/CLAUDE.md`**; landed record: **`docs/ARCHIVE.md` §Phase S — editor
|
||||
view-model redesign**.
|
||||
|
||||
**The reference devices (the north star for control density).** Daniel named Ableton **Simpler**
|
||||
and a Kilohearts/Phase-Plant **sampler group** as the composition targets. Both share one
|
||||
@@ -1021,7 +1026,7 @@ grammar, and it is the grammar the redesign adopts:
|
||||
loading a new one is a distinct act), not a three-way radio. *Why the reframe matters:* it
|
||||
makes "I just want to play this capture" the zero-click default, and "I want a different one"
|
||||
a single deliberate gesture, instead of making the user re-choose their whole stance every
|
||||
time. See CONTEXT.md §S-VIEW for the precise navigation model.
|
||||
time. See `docs/ARCHIVE.md` §S-VIEW-1 for the precise navigation model as landed.
|
||||
|
||||
2. **The Sample view earns the hero treatment; Browse gets ruthlessly cut.** Browse today
|
||||
carries a waveform preview, root-note piano-roll, loop-point labels, a track-root message, and
|
||||
@@ -1037,10 +1042,10 @@ grammar, and it is the grammar the redesign adopts:
|
||||
3. **Two engineering prerequisites, framed but routed to implementation.** The **drop-to-FX bug**
|
||||
(dropping a capture onto a track's FX chain does not instantiate + init ReaSampler 9000) and
|
||||
the **undersized default window** are not design decisions — they are a bug and a one-line
|
||||
default. Both are framed in CONTEXT.md §S-VIEW with the SDK reality swept (drop-to-FX: the S17
|
||||
machinery is SDK-correct, so this is a *diagnosis* task, not a redesign; window size: the
|
||||
`getSize`/`checkSizeConstraint` mechanism is verified), and both are flagged for
|
||||
staff-engineer, not for a product fork.
|
||||
default. Both are recorded landed in `docs/ARCHIVE.md` §S-VIEW-BUG-1 (drop-to-FX: the S17
|
||||
machinery is SDK-correct, so this is a *diagnosis* task, not a redesign) and §S-VIEW-SIZE-1
|
||||
(window size: the `getSize`/`checkSizeConstraint` mechanism is verified), and both were
|
||||
flagged for staff-engineer, not for a product fork.
|
||||
|
||||
**New parameters this introduces (both instrument performance state, D-B — never bank facts):**
|
||||
|
||||
@@ -1064,7 +1069,7 @@ grammar, and it is the grammar the redesign adopts:
|
||||
new top-level `previewVelocity` field), **not** the extension's `persist` project ext-state —
|
||||
that module is REAPER-project-scoped and extension-owned, so it would make the level
|
||||
project-global instead of per-instance and route an instrument concern through a bank-read-only
|
||||
seam. See CONTEXT.md §S-VIEW for the round-trip and back-compat lift. This is what makes the
|
||||
seam. See `docs/ARCHIVE.md` §S-VIEW-4 for the round-trip and back-compat lift as landed. This is what makes the
|
||||
preview button *fun*: tap it hard or soft without reaching for a controller — and it remembers.
|
||||
|
||||
**Two visual components the redesign commits to:**
|
||||
@@ -1101,8 +1106,10 @@ persisted fields is not a compat event; saved instances rebind and restore. And
|
||||
(extended additively — `keyTrack` per-zone, `previewVelocity` per-instance via an envelope bump to
|
||||
v6, both with back-compat defaults on read) are the same load-bearing core.
|
||||
|
||||
**Where the spec lives:** CONTEXT.md §Phase S — editor view-model redesign (S-VIEW); PLAN.md
|
||||
§Phase S — editor view-model redesign. This Addendum is the *why*; those are the *what/how*.
|
||||
**Where the spec lives:** `src/core/instrument/CLAUDE.md` (envelope overlay, key-tracking,
|
||||
preview-velocity ownership) documents the current architecture; `docs/ARCHIVE.md` §Phase S
|
||||
— editor view-model redesign records what landed (S-VIEW-1 through S-VIEW-10). This
|
||||
Addendum is the *why*; those are the *what/how*.
|
||||
|
||||
---
|
||||
|
||||
@@ -1217,8 +1224,9 @@ for preview velocity — a different struct on a different version axis). Concre
|
||||
the L1 kit, routing mouse through `velocity_curve`), gated on the foundation track and composing
|
||||
with the S-VIEW-2 Sample face + S-VIEW-3 envelope-overlay work.
|
||||
|
||||
**Where the spec lives:** CONTEXT.md §Phase S — editor view-model redesign (S-VIEW), velocity-curve
|
||||
sub-section; PLAN.md §Phase S — editor view-model redesign (S-VIEW-9/S-VIEW-10 + fork R10-F1). This
|
||||
**Where the spec lives:** `src/core/instrument/CLAUDE.md` (the `velocity_curve` module, its
|
||||
engine application point, and its ownership rules) documents the current architecture;
|
||||
`docs/ARCHIVE.md` §S-VIEW-9 / §S-VIEW-10 records what landed (fork R10-F1 resolved). This
|
||||
Addendum is the *why*; those are the *what/how*.
|
||||
|
||||
---
|
||||
@@ -1273,16 +1281,20 @@ small labelled knobs in functional clusters** — and drives the **Wave B editor
|
||||
same knob deck + button/popup — Sample and Zone already edit one storage site (S15-F2); the lean
|
||||
is that they should speak one control grammar too. Daniel's call (it grows Wave B scope).
|
||||
|
||||
**What does NOT change (guardrails).** Zero engine/state/persistence change — this is a view
|
||||
recomposition of *existing* controls; no new params, no component-state bump, VST3 class UID
|
||||
unchanged. All drawing through the L1 kit by palette role; all layout/hit-test in new pure modules
|
||||
**What does NOT change (guardrails).** Zero engine-behavior change — this is a view
|
||||
recomposition of *existing* controls, with one additive exception: the new post-mixer master
|
||||
gain persists as `masterGainLinear`, bumping `ComponentState` v7→v8 (back-compat: pre-v8 blobs
|
||||
lift to unity gain); VST3 class UID unchanged. All drawing through the L1 kit by palette role;
|
||||
all layout/hit-test in new pure modules
|
||||
(`knob_deck`, `curve_popup` — mirrors of `action_bar`/`overflow_menu`); the knobs and the hero's
|
||||
envelope nodes remain two surfaces on one param model (S-VIEW-F2's structural sync, untouched).
|
||||
The full inventory contract (every landed element → its r11 home) is in the CONTEXT.md spec.
|
||||
The full inventory of what landed (every element → its r11 home) is recorded in
|
||||
`docs/ARCHIVE.md` §FB1 and §FB2.
|
||||
|
||||
**Where the spec lives:** CONTEXT.md §Phase S — editor view-model redesign (S-VIEW) → "The
|
||||
Sample-face recomposition (r11)"; PLAN.md §Phase S — editor Wave B (S-VIEW-11/12/13 + forks
|
||||
R11-F1/R11-F2). This Addendum is the *why*; those are the *what/how*.
|
||||
**Where the spec lives:** `src/core/instrument/CLAUDE.md` (the `knob_deck`/`curve_popup`/
|
||||
`master_gain` modules) documents the current architecture; `docs/ARCHIVE.md` §FB1 and §FB2
|
||||
record what landed (S-VIEW-11/12/13 + forks R11-F1/R11-F2 resolved). This Addendum is the
|
||||
*why*; those are the *what/how*.
|
||||
|
||||
---
|
||||
|
||||
@@ -1329,7 +1341,8 @@ Post-DAW-test directives (2026-07-26; see the "product name + UX overhaul" Adden
|
||||
frameworks), DS-2 (Direction B "Neon Console" + Direction C's spectral keyboard strip), and
|
||||
DS-3 (thorough panel layout) are all **SETTLED (2026-07-26)**. Framing + palette + the three
|
||||
visual directions + forks: `docs/product/visual-design-language.md` (on `dev`); roadmap +
|
||||
spec: **PLAN.md §Phase L + CONTEXT.md §Phase L** (on `dev`). **S10–S13 build with the
|
||||
spec: **`docs/ARCHIVE.md` §Phase L** (landed record) and **`src/core/ui/CLAUDE.md`**
|
||||
(current architecture) (on `dev`). **S10–S13 build with the
|
||||
current drawing and adopt the L1 kit when it lands — not gated on Phase L.** Answers
|
||||
Daniel's "the VST is dogshit / temple os / does Cockos have a toolkit" (2026-07-26,
|
||||
post-S1–S6 DAW test).
|
||||
@@ -1350,15 +1363,16 @@ Post-DAW-test directives (2026-07-26; see the "product name + UX overhaul" Adden
|
||||
S15-F1 (choke, held) / S15-F2 (param granularity, lean per-zone). Feature set settled;
|
||||
the engine default is Daniel's fork.
|
||||
|
||||
**Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1–S6 the original
|
||||
dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded
|
||||
UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10–S13** the ReaSampler
|
||||
9000 UX overhaul, **S15/S16** the Trigger-vs-Gate + pitch-engine-modes engine features);
|
||||
**CONTEXT.md §Phase S** is the spec (seam-field semantics, scope contracts, the channel-mode
|
||||
/ ingest / bank-generation / sampling-mode / pitch-engine contracts, the UX-overhaul spec,
|
||||
the product-name convention, the pure/shell split, the WDL finding, the must-verify
|
||||
SDK/bridge surfaces). This doc is the framing/decision record they point back to. The "no
|
||||
PLAN.md footprint" era is over.
|
||||
**Authoritative from here:** **`docs/ARCHIVE.md` §Phase S** is the landed roadmap (S1–S6
|
||||
the original dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier
|
||||
1 → embedded UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10–S13** the
|
||||
ReaSampler 9000 UX overhaul, **S15/S16** the Trigger-vs-Gate + pitch-engine-modes engine
|
||||
features); **`src/core/instrument/CLAUDE.md`**, **`src/shell/instrument/CLAUDE.md`**, and
|
||||
**`src/core/wire/CLAUDE.md`** are the current spec (seam-field semantics, scope contracts,
|
||||
the channel-mode / ingest / bank-generation / sampling-mode / pitch-engine contracts, the
|
||||
UX-overhaul spec, the product-name convention, the pure/shell split, the WDL finding, the
|
||||
must-verify SDK/bridge surfaces). This doc is the framing/decision record they point back
|
||||
to. The "no landed-roadmap footprint" era is over.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Multi-bank — product notes
|
||||
|
||||
Framing, rationale, and design-direction calls behind the **Multi-bank** phase.
|
||||
The tickable spec lives in `PLAN.md` (Phase B) and the authoritative technical
|
||||
detail in `CONTEXT.md` (§Multi-bank). This doc holds the *why* — the workflow
|
||||
The tickable spec's landed history lives in `docs/ARCHIVE.md` (Phase B) and the
|
||||
architecture detail in `src/core/model/CLAUDE.md` + `src/shell/bank_ops/CLAUDE.md`
|
||||
(§Multi-bank). This doc holds the *why* — the workflow
|
||||
narrative, the pool-privilege reasoning, the movement semantics, and the
|
||||
design-direction recommendations — so those don't clutter the build docs.
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Provenance — product notes
|
||||
|
||||
Framing, rationale, and the dual-canvas reconciliation behind the reshaped
|
||||
**Milestone 10 (provenance)**. The tickable spec lives in `PLAN.md` (M10); the
|
||||
authoritative technical detail is `CONTEXT.md` (§Data model, §capture) plus this
|
||||
note for the reconciliation calls. This doc holds the *why* and the open forks so
|
||||
they don't clutter the build docs.
|
||||
**Milestone 10 (provenance)**. The tickable spec's landed history is in
|
||||
`docs/ARCHIVE.md` (M10); the architecture detail is in `src/core/model/CLAUDE.md`
|
||||
and `src/shell/capture/CLAUDE.md` plus this note for the reconciliation calls.
|
||||
This doc holds the *why* and the open forks so they don't clutter the build docs.
|
||||
|
||||
Status: **IMPLEMENTED (2026-07-26).** Settled 2026-07-23; landed 2026-07-26.
|
||||
Reshaped from the old "provenance + null-test verify" M10. Two decisions were fixed
|
||||
|
||||
@@ -2,24 +2,26 @@
|
||||
|
||||
Framing, rationale, and open forks behind the two missing removal capabilities:
|
||||
**sample-remove** (a sample-level index verb) and **prune** (the file-lifecycle
|
||||
path CONTEXT.md keeps forward-referencing but never scoped). The tickable spec
|
||||
lives in `PLAN.md` (Phase B point B5 for remove; **Phase R** for prune) and the
|
||||
authoritative technical detail in `CONTEXT.md` (§Sample removal, §Prune — file
|
||||
lifecycle). This doc holds the *why* — the workflow, the guardrails, the
|
||||
index-vs-file boundary, and the forks that need a Daniel decision.
|
||||
path the spec kept forward-referencing but never scoped). The tickable spec's
|
||||
landed history is in `docs/ARCHIVE.md` (Phase B point B5 for remove; **Phase R**
|
||||
for prune) and the architecture detail lives in `src/core/model/CLAUDE.md` +
|
||||
`src/shell/bank_ops/CLAUDE.md` (§Sample removal) and `src/core/reclaim/CLAUDE.md`
|
||||
+ `src/shell/persist/CLAUDE.md` (§Prune — file lifecycle). This doc holds the
|
||||
*why* — the workflow, the guardrails, the index-vs-file boundary, and the forks
|
||||
that need a Daniel decision.
|
||||
|
||||
Status: framed by product-designer (2026-07-23); **all five forks settled by Daniel
|
||||
(2026-07-24)** — R-A this-bank-primary, R-B batched REAPER undo points
|
||||
(Phase-B-wide), R-C trash-preferred-with-unlink-fallback, R-D owned-file manifest
|
||||
(seam lands early in Phase B / capture), R-E manual action + panel button. The
|
||||
decisions are folded into the fork sections below and into the B5 / Phase R spec
|
||||
prose in CONTEXT.md and the tickable points in PLAN.md.
|
||||
decisions are folded into the fork sections below and into the B5 / Phase R
|
||||
history in `docs/ARCHIVE.md` and the architecture docs above.
|
||||
|
||||
---
|
||||
|
||||
## The one boundary that governs everything: index vs. file
|
||||
|
||||
ReaSampler already draws a hard line, stated repeatedly in CONTEXT.md: **a bank
|
||||
ReaSampler already draws a hard line: **a bank
|
||||
operation touches the *index*, never the *file*.** Move, copy, evacuate, and
|
||||
delete-bank are all index-only; files persist on disk "until prune." Every
|
||||
removal capability below sits on exactly one side of that line, and keeping the
|
||||
@@ -120,7 +122,7 @@ line. It is the verb Phase B forgot, not a new pillar.
|
||||
|
||||
---
|
||||
|
||||
## Prune — the file-lifecycle path CONTEXT.md kept promising
|
||||
## Prune — the file-lifecycle path the spec kept promising
|
||||
|
||||
### What the user is doing
|
||||
|
||||
@@ -131,7 +133,7 @@ purpose. Over a long project the bank folder accumulates dead `.wav` files that
|
||||
cost disk and clutter. **Prune is the reclaim pass**: "sweep the bank folder,
|
||||
delete the files nothing references, tell me what you reclaimed."
|
||||
|
||||
This is the path CONTEXT.md forward-references in at least four places ("files
|
||||
This is the path the spec forward-references in at least four places ("files
|
||||
persist on disk until prune," "the capture/prune path reclaims it") but never
|
||||
scopes. It is a real, promised capability with **no phase, no module, no point**
|
||||
— a dangling reference the plan has to make good on.
|
||||
@@ -141,8 +143,8 @@ scopes. It is a real, promised capability with **no phase, no module, no point**
|
||||
ReaSampler already shipped this exact shape once. Design View's `view_mode_model`
|
||||
has **`ViewModeModel::reconcile(liveGuids)`** — a pure function fed the live set
|
||||
(the tracks that still exist), returning the residual membership entries to drop
|
||||
(CONTEXT.md §Design View: "prunes orphaned snapshots on every toggle/load;
|
||||
tolerates unknown/stale GUIDs (prune on reconcile)"). Prune is the **file-pool
|
||||
(`src/core/view/CLAUDE.md`: "tolerates unknown/stale GUIDs (pruned on reconcile
|
||||
via `ViewModeModel::reconcile(liveGuids)`)"). Prune is the **file-pool
|
||||
mirror of that pure pattern**:
|
||||
|
||||
> `reconcile(liveGuids)` reconciles *membership entries* against *live tracks*.
|
||||
@@ -224,7 +226,7 @@ Prune is **not** a Phase B point. Three reasons it earns its own lettered phase
|
||||
|
||||
1. **It is a different pillar.** Phase B is the *bank container* pillar
|
||||
(index-only, non-destructive, above the file). Prune is the *file lifecycle*
|
||||
pillar (the one path that deletes files). CONTEXT.md already names it as a
|
||||
pillar (the one path that deletes files). The spec already named it as a
|
||||
separate concern every time it says "the capture/**prune** path" — file
|
||||
lifecycle is spoken of as its own thing, owned by neither the capture nor the
|
||||
bank layer. Giving it its own phase matches how the spec already talks about it.
|
||||
@@ -337,7 +339,8 @@ capture).** The book tracks the set of files it has created; prune reclaims
|
||||
ext-state**, and Phase R's R1/R2 *consume* that manifest. The exact persistence
|
||||
shape — a sibling ext-state key vs. folded into the `banks` blob — is a small
|
||||
residual to settle at build; the **manifest-now decision is firm**. (Settled
|
||||
2026-07-24; the up-front point is added to Phase B / the capture path in PLAN.md.)
|
||||
2026-07-24; the up-front point is recorded in `docs/ARCHIVE.md` under Phase B /
|
||||
the capture path.)
|
||||
|
||||
**Fork R-E — prune trigger: manual-only vs. offer-on-orphaning vs. periodic.
|
||||
SETTLED: MANUAL ACTION + PANEL BUTTON.** Prune runs via a bindable manual action
|
||||
|
||||
@@ -8,8 +8,8 @@ concrete and decidable:
|
||||
2. **A beta side-channel** — so development can continue and a beta build run
|
||||
*alongside* the stable one without the beta clobbering the release.
|
||||
|
||||
This doc holds the *why*, the forks, and a recommendation. When Daniel picks, the
|
||||
tickable points land in `PLAN.md` and the deploy/build wiring hands off to dev-ops.
|
||||
This doc holds the *why*, the forks, and a recommendation. The tickable points'
|
||||
landed history is in `docs/ARCHIVE.md`; the deploy/build wiring hands off to dev-ops.
|
||||
This is a framing note; it changes no source or CMake.
|
||||
|
||||
Status: framed by product-designer (2026-07-26); **all four forks settled by Daniel
|
||||
@@ -54,7 +54,7 @@ Two sharp edges follow directly and recur throughout this note:
|
||||
Both binaries reading/writing the same namespace on the same open project means a
|
||||
**beta can read — and rewrite — a stable project's saved bank/view state.** Given
|
||||
the forward-only migrations already in the design (legacy `bank_index` retired
|
||||
after promotion; `banks` authoritative thereafter — CONTEXT.md §Multi-bank), a
|
||||
after promotion; `banks` authoritative thereafter — `docs/ARCHIVE.md` §Phase B), a
|
||||
beta that writes a newer schema into a project a user then reopens in stable is a
|
||||
real corruption path, not a theoretical one.
|
||||
|
||||
|
||||
@@ -16,11 +16,12 @@ written after Daniel DAW-tested the S1–S6 instrument:
|
||||
revised 2026-07-26 (Daniel)** from the single electric-cyan accent to a **three-accent
|
||||
pastel system** (primary/secondary/tertiary = pastel lime green / pastel teal / pastel
|
||||
purple) — see §6. (A stylish-font upgrade was considered and **declined** by Daniel — no
|
||||
font bundling/redistribution; the kit keeps its current cached-font face.) The build points it feeds live in
|
||||
**PLAN.md §Phase L** (**L1** the shared LICE drawing kit, **L2** the dock-panel layout
|
||||
redesign, **L3** the VST editor + embed-strip restyle) and **CONTEXT.md §Phase L** (the
|
||||
design-system spec). Toolkit facts below are **verified against the vendored `vendor/WDL`
|
||||
tree**, not lore.
|
||||
font bundling/redistribution; the kit keeps its current cached-font face.) The build points it
|
||||
fed have landed; their history is in **`docs/ARCHIVE.md`** ("Phase L — Look-and-feel", **L1** the
|
||||
shared LICE drawing kit, **L2** the dock-panel layout redesign, **L3** the VST editor +
|
||||
embed-strip restyle) and the architecture now lives in **`src/core/ui/CLAUDE.md`** /
|
||||
**`src/shell/panel/CLAUDE.md`** (the design-system implementation). Toolkit facts below are
|
||||
**verified against the vendored `vendor/WDL` tree**, not lore.
|
||||
|
||||
> **Phase-boundary note (2026-07-26).** This look-and-feel effort was originally drafted
|
||||
> as Phase S points **S0-DS** (the shared kit) and **S14** (the panel refresh). It was
|
||||
@@ -497,7 +498,7 @@ within the pastel intent — is one file.
|
||||
|
||||
---
|
||||
|
||||
## 5. The build shape (feeds PLAN.md / CONTEXT.md §Phase L)
|
||||
## 5. The build shape (landed history in `docs/ARCHIVE.md` §Phase L)
|
||||
|
||||
Three points and their sequencing.
|
||||
|
||||
@@ -547,7 +548,7 @@ draw it.
|
||||
affordances, per-selection **move / copy / remove** sample menu.
|
||||
- **Prune** button (R-E) — the byte-deleting action, `warn`-colored.
|
||||
|
||||
*M11 adds (dev PLAN.md M11 — merging now):*
|
||||
*M11 adds (landed — `docs/ARCHIVE.md` Milestone 11):*
|
||||
- **Action trigger buttons** — clickable buttons that fire the capture + provenance
|
||||
action family directly (capture item / capture track scopes, re-capture from source,
|
||||
resample-and-mute-source, batch capture, conform-on-insert, insert-at-cursor, drag-out,
|
||||
|
||||
@@ -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
@@ -1,25 +1,16 @@
|
||||
// main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers.
|
||||
//
|
||||
// This file is the entire contract between REAPER and the extension:
|
||||
// * At startup REAPER scans UserPlugins/ for reaper_*.dll|dylib|so and
|
||||
// dlopen()s each one, then looks up ONE exported symbol: ReaperPluginEntry
|
||||
// (that name is produced by the REAPER_PLUGIN_ENTRYPOINT macro).
|
||||
// * REAPER calls it, handing over `rec` — a small dispatch struct.
|
||||
// - rec->GetFunc(name) resolves any REAPER API function to a pointer
|
||||
// - rec->Register(what,ptr) plugs OUR callbacks into REAPER
|
||||
// * REAPERAPI_LoadAPI(rec->GetFunc) walks reaper_plugin_functions.h and
|
||||
// fills in every global function pointer (ShowConsoleMsg, InsertMedia...).
|
||||
// REAPER dlopen()s reaper_*.dll|dylib|so from UserPlugins/ and calls the exported
|
||||
// ReaperPluginEntry, handing over `rec` (rec->GetFunc resolves API pointers,
|
||||
// rec->Register plugs our callbacks in). Exactly ONE .cpp defines
|
||||
// REAPERAPI_IMPLEMENT (this one) — that allocates storage for the global API
|
||||
// pointers every other TU gets `extern`. Never let a second TU define it.
|
||||
//
|
||||
// Exactly ONE .cpp defines REAPERAPI_IMPLEMENT (this one) — that allocates
|
||||
// storage for those global pointers. Every other .cpp includes
|
||||
// reaper_plugin_functions.h WITHOUT the define and gets `extern` declarations.
|
||||
//
|
||||
// Since Q-W3 this TU is ONLY pointers + entry + dispatch; since Q-W6 its own
|
||||
// action family registers through the DATA-DRIVEN TABLE below (kMainActionRows +
|
||||
// action_registry's registerActionTable/actionTableHandleCommand/
|
||||
// unregisterActionTable) — adding a bindable action here means adding ONE row and
|
||||
// its handler function, nothing else (OCP). The design_view / bank / ingest
|
||||
// families keep their own register/handle/unregister triples, called from entry.
|
||||
// This TU is ONLY pointers + entry + dispatch. Its own action family registers
|
||||
// through the data-driven table below (buildMainActionTable + action_registry) —
|
||||
// adding a bindable action means adding ONE row and its handler function (OCP). The
|
||||
// design_view / bank / ingest families keep their own register/handle/unregister
|
||||
// triples, called from entry.
|
||||
|
||||
#define REAPERAPI_IMPLEMENT
|
||||
#include "reaper_plugin.h"
|
||||
@@ -32,9 +23,9 @@
|
||||
#include "core/capture/render_settings.h" // captureActionTable
|
||||
#include "core/version/app_version.h" // appVersion
|
||||
#include "ingest.h"
|
||||
#include "shell/actions/action_registry.h" // the Q-W6 registration table
|
||||
#include "shell/actions/bank_actions.h" // multi-bank action family (B3; Q-W4 home)
|
||||
#include "shell/actions/design_view_actions.h" // Design View action family (D4; Q-W4 home)
|
||||
#include "shell/actions/action_registry.h" // the registration table
|
||||
#include "shell/actions/bank_actions.h" // multi-bank action family
|
||||
#include "shell/actions/design_view_actions.h" // Design View action family
|
||||
#include "shell/capture/capture_batch.h" // batch + recapture action bodies
|
||||
#include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies
|
||||
#include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver
|
||||
@@ -46,20 +37,13 @@
|
||||
namespace capture = reasampler::capture;
|
||||
|
||||
// Globals other files reference via `extern`.
|
||||
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle
|
||||
reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct
|
||||
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr;
|
||||
reaper_plugin_info_t* g_rec = nullptr;
|
||||
|
||||
// Retired command-id SUFFIXES. Kept ONLY to mirror-unregister them on unload so a
|
||||
// user's stale keybindings are cleaned up. Never re-register these. Composed through
|
||||
// the channel prefix at unload (channelIdFor) so a beta unload clears beta-qualified
|
||||
// retired ids and a stable unload clears stable's — each channel cleans up only its
|
||||
// own family.
|
||||
// * The M7 four-mode ids (tracks/items/razor WET).
|
||||
// * CAPTURE_MASTER and CAPTURE_MASTER_REALTIME — the master offline scope and the
|
||||
// master realtime action are REMOVED (capture is now item + track only; realtime
|
||||
// taps the selected track). Their shipped ids are retired so old keybindings clear.
|
||||
// * CAPTURE_ITEM_TAIL and CAPTURE_TRACK_TAIL — the former per-action tail variants
|
||||
// are REMOVED; tail is now a panel-setting toggle, not a paired action.
|
||||
// Retired command-id SUFFIXES: kept ONLY to mirror-unregister on unload so a user's
|
||||
// stale keybindings are cleaned up. Never re-register these. The four-mode WET ids,
|
||||
// the removed master scope/realtime actions, and the removed per-action tail variants
|
||||
// (tail is now a panel toggle, not a paired action).
|
||||
static const char* const kRetiredCaptureCmdSuffixes[] = {
|
||||
"CAPTURE_TRACKS_WET",
|
||||
"CAPTURE_ITEMS_WET",
|
||||
@@ -70,35 +54,30 @@ static const char* const kRetiredCaptureCmdSuffixes[] = {
|
||||
"CAPTURE_TRACK_TAIL",
|
||||
};
|
||||
|
||||
// The persistence session (M4): owns the in-memory BankModel and bridges it to
|
||||
// project ext state. A timer tick drives g_session.poll() to detect project
|
||||
// load / Save-As; capture adds Samples to g_session.bank() — which (B2) resolves to
|
||||
// the ACTIVE bank's index inside the session's BankBook; after a capture we serialize
|
||||
// the book back into the active project's ext state (the `banks` key) so it travels
|
||||
// with the .rpp. Replaces the M3 session-only g_bank.
|
||||
// Owns the in-memory BankModel and bridges it to project ext state. A timer tick
|
||||
// drives g_session.poll() to detect project load / Save-As; capture adds Samples to
|
||||
// g_session.bank() (resolves to the active bank's index), and we serialize the book
|
||||
// back into the active project's ext state (the `banks` key) so it travels with the .rpp.
|
||||
static reasampler::ReaSamplerSession g_session;
|
||||
|
||||
// Command id of the TOGGLE_BANK_PANEL row, resolved from the table once at load so
|
||||
// OnToggleAction's checked-state poll is a single int compare (no per-poll lookup).
|
||||
static int g_cmdToggleBankPanel = 0;
|
||||
|
||||
// --- Action handlers (the table's function pointers) --------------------------
|
||||
//
|
||||
// Each is a thin stateless routing shim: (session, per-row arg) -> the action body
|
||||
// hoisted in Q-W3/Q-W4 (shell/capture/, shell/panel/). The bodies own all behavior;
|
||||
// these exist only so the table rows can be plain data with flat function pointers.
|
||||
// Each handler is a thin stateless routing shim: (session, per-row arg) -> the
|
||||
// action body in shell/capture/ or shell/panel/, existing only so table rows can be
|
||||
// plain data with flat function pointers.
|
||||
|
||||
// Capture scope family: `arg` is the captureActionTable() row index — the table rows
|
||||
// below are built by iterating that pure taxonomy, so the routing stays 1:1 by
|
||||
// construction (never a hand-kept parallel list).
|
||||
// `arg` is the captureActionTable() row index — the table rows below are built by
|
||||
// iterating that pure taxonomy, so the routing stays 1:1 by construction.
|
||||
static void RunCaptureScopeRow(int arg) {
|
||||
capture::RunCapture(g_session,
|
||||
capture::captureActionTable()[static_cast<std::size_t>(arg)]);
|
||||
}
|
||||
static void RunToggleBankPanel(int) { reasampler::bankPanelToggle(); }
|
||||
static void RunCaptureItemAssign(int) { capture::RunCaptureItemAssign(g_session); }
|
||||
// Insert: `arg` != 0 is the EXPLICIT conform-to-project-tempo opt-in (CONTEXT.md
|
||||
// §insert: conform is opt-in, never silent); 0 inserts at native length.
|
||||
// `arg` != 0 is the EXPLICIT conform-to-project-tempo opt-in (never silent); 0
|
||||
// inserts at native length.
|
||||
static void RunInsertSelected(int arg) {
|
||||
capture::RunInsertSelected(g_session, arg != 0);
|
||||
}
|
||||
@@ -108,24 +87,15 @@ static void RunCaptureRealtime(int) { capture::RunCaptureRealtimeTrack(g_sessi
|
||||
static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); }
|
||||
static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); }
|
||||
static void RunShowVersion(int) {
|
||||
// On-demand version readout — the ONLY version output on any path (Phase V: no
|
||||
// unconditional startup print; routine console chatter pops the console window).
|
||||
// On-demand only — no unconditional startup print (routine console chatter pops
|
||||
// the console window).
|
||||
ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str());
|
||||
}
|
||||
|
||||
// --- The registration table (Q-W6) --------------------------------------------
|
||||
//
|
||||
// ONE row per bindable action this TU owns: FOREVER-STABLE id suffix (channel prefix
|
||||
// composed at register — stable rebuilds the exact shipped id, e.g.
|
||||
// "CEREBELLUM_REASAMPLER_CAPTURE_TRACK"; beta its isolated forever-family), the
|
||||
// Actions-list phrase (after the "ReaSampler[ beta]: " lead), the handler, and its
|
||||
// per-row arg. Registration, hookcommand dispatch, and the unload mirror-unregister
|
||||
// all iterate this data — adding an action = adding a row + a handler above.
|
||||
//
|
||||
// The capture scope rows (CAPTURE_ITEM / CAPTURE_TRACK) come first, sourced from the
|
||||
// pure captureActionTable() taxonomy (render_settings) — suffix/phrase live in that
|
||||
// one testable list, and `arg` carries the row index back to RunCapture. The
|
||||
// remaining rows are this TU's singles, in the pre-table registration order.
|
||||
// ONE row per bindable action this TU owns: FOREVER-STABLE id suffix, Actions-list
|
||||
// phrase, handler, per-row arg. Registration, hookcommand dispatch, and the unload
|
||||
// mirror-unregister all iterate this data. The capture scope rows come first,
|
||||
// sourced from the pure captureActionTable() taxonomy; the rest are this TU's singles.
|
||||
static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
|
||||
using reasampler::ActionTableRow;
|
||||
std::vector<ActionTableRow> rows;
|
||||
@@ -135,40 +105,32 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
|
||||
rows.push_back(ActionTableRow{cap[i].commandSuffix, cap[i].descriptionPhrase,
|
||||
&RunCaptureScopeRow, static_cast<int>(i)});
|
||||
|
||||
// M5: show/hide the docked bank panel (display-only; never captures/inserts).
|
||||
// Show/hide the docked bank panel (display-only; never captures/inserts).
|
||||
rows.push_back({"TOGGLE_BANK_PANEL", "toggle bank panel", &RunToggleBankPanel});
|
||||
// S8: Item-scope capture + assignment-request write (capture family because it
|
||||
// leans on the capture render machinery; the other ingest surfaces live in the
|
||||
// ingest family and the panel drop callback).
|
||||
rows.push_back({"CAPTURE_ITEM_ASSIGN",
|
||||
"capture selected item into bank + assign to active instance",
|
||||
&RunCaptureItemAssign});
|
||||
// M6: place the panel's selected sample at the edit cursor. Two variants that
|
||||
// differ ONLY in InsertOptions — native length vs the explicit conform opt-in.
|
||||
// Two variants differing ONLY in InsertOptions — native length vs conform opt-in.
|
||||
rows.push_back({"INSERT_SELECTED", "insert selected sample at edit cursor",
|
||||
&RunInsertSelected, 0});
|
||||
rows.push_back({"INSERT_SELECTED_CONFORM",
|
||||
"insert selected sample at edit cursor (conform to tempo)",
|
||||
&RunInsertSelected, 1});
|
||||
// M11: one action fires N captures (per selected item / per razor area); the
|
||||
// original selection is restored on every exit path. Bank-only, never places.
|
||||
// One action fires N captures (per selected item / per razor area); the original
|
||||
// selection is restored on every exit path. Bank-only, never places.
|
||||
rows.push_back({"CAPTURE_BATCH_ITEMS",
|
||||
"batch capture selected items (one per item)",
|
||||
&RunBatchCaptureItems});
|
||||
rows.push_back({"CAPTURE_BATCH_RAZOR", "batch capture razor areas (one per area)",
|
||||
&RunBatchCaptureRazor});
|
||||
// M8: realtime sibling of the offline CAPTURE_TRACK scope — records the selected
|
||||
// track's own output into a hidden temp track, dialog-free — plus its
|
||||
// cancel-in-flight companion (stop + restore, non-destructive).
|
||||
// Realtime sibling of the offline CAPTURE_TRACK scope, plus its cancel-in-flight
|
||||
// companion (stop + restore, non-destructive).
|
||||
rows.push_back({"CAPTURE_TRACK_REALTIME", "capture selected track (realtime)",
|
||||
&RunCaptureRealtime});
|
||||
rows.push_back({"CANCEL_REALTIME_CAPTURE", "cancel realtime capture",
|
||||
&RunCancelRealtime});
|
||||
// M10: regenerate the selected PROVENANCED sample from its recorded source's
|
||||
// current state, in place. Bank-only, never places on the timeline.
|
||||
rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source",
|
||||
&RunRecaptureFromSource});
|
||||
// Phase V: on-demand version readout for bug reports.
|
||||
rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion});
|
||||
|
||||
return rows;
|
||||
@@ -180,74 +142,52 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
|
||||
static void OnTimer()
|
||||
{
|
||||
// Advance any in-flight realtime capture FIRST, so a project switch is caught and
|
||||
// the capture torn down/restored before session.poll() reacts to that switch.
|
||||
// LOAD-BEARING (CONTEXT.md §Phase Q): the idle fast-path is a SINGLE POINTER
|
||||
// TEST — the cross-TU drive call is made only when a capture is in flight.
|
||||
// torn down/restored before session.poll() reacts to that switch. LOAD-BEARING:
|
||||
// the idle fast-path is a SINGLE POINTER TEST — drive only when a capture is live.
|
||||
if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session);
|
||||
|
||||
g_session.poll();
|
||||
|
||||
// D4 reapply-on-open glue. persist stays MODEL-ONLY (it loads the saved view
|
||||
// model but deliberately does NOT apply visibility — that would couple persist
|
||||
// to the view shell). Instead poll() raises a one-shot load signal; here — the
|
||||
// integration layer that already drives both persist and the view shell — we
|
||||
// drain it and reapply the SAVED active mode's visibility/processing so opening a
|
||||
// project saved in Design mode parks the Arrange tracks automatically, no manual
|
||||
// toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks
|
||||
// skip it. proj = nullptr -> REAPER's active project (the one poll just loaded).
|
||||
//
|
||||
// The SAME signal re-arms the bank panel's new-content detector: a load must
|
||||
// re-baseline the detector against the just-loaded project's content so its
|
||||
// pre-existing tracks are never mis-detected as "new" and mass-tagged into the
|
||||
// active mode (the reload-mis-tag bug). Notify BEFORE the reapply so the detector's
|
||||
// re-arm and the model restore ride the one authoritative load event.
|
||||
// persist stays MODEL-ONLY (loads the saved view model but does not apply
|
||||
// visibility, to avoid coupling persist to the view shell); poll() raises a
|
||||
// one-shot load signal that we drain here to reapply the SAVED active mode so a
|
||||
// project saved in Design mode parks Arrange tracks automatically. The same
|
||||
// signal re-arms the bank panel's new-content detector — notified BEFORE the
|
||||
// reapply so re-arm and model restore ride the one load event (otherwise
|
||||
// pre-existing tracks can be mis-detected as "new" and mass-tagged).
|
||||
if (g_session.consumeLoadSignal()) {
|
||||
reasampler::bankPanelNotifyProjectLoaded();
|
||||
// Reconcile the restored lane-ownership index against the live project's lanes
|
||||
// FIRST (via REAPER's durable P_LANENAME — the cross-session source of truth),
|
||||
// so a saved lane-split project's managed/manual classification is correct
|
||||
// before the active mode's lane visibility is reapplied. Never re-mints, never
|
||||
// mass-tags — it only records managed ownership recovered from lane names.
|
||||
// Reconcile lane ownership against the live project's lanes (P_LANENAME,
|
||||
// the cross-session source of truth) BEFORE reapplying visibility. Never
|
||||
// re-mints, never mass-tags.
|
||||
reasampler::reconcileManagedLanes(g_session.view(), nullptr);
|
||||
reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
|
||||
}
|
||||
|
||||
// Reflect a live bank change (capture / project load) in the docked grid.
|
||||
// Cheap when the bank is unchanged (a fingerprint compare); repaints only on
|
||||
// an actual change. No-op when the panel is closed.
|
||||
reasampler::bankPanelRefresh();
|
||||
reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed
|
||||
}
|
||||
|
||||
// --- projectconfig hook: reload the session on undo/redo (R-B) ---------------
|
||||
// A Ctrl-Z / Ctrl-Shift-Z rolls back / forward the "reasampler" project ext state on
|
||||
// disk but keeps the SAME project identity (ReaProject*/GUID/.rpp path), so the timer's
|
||||
// identity poll reads it as NoOp and never re-reads ext state — the in-memory book/view
|
||||
// would stay stale until close+reopen. REAPER's projectconfig extension fires
|
||||
// BeginLoadProjectState on every project-state (re)load, INCLUDING an undo/redo restore
|
||||
// (isUndo == true for both). We hook it to drive a session reload.
|
||||
// A Ctrl-Z/Ctrl-Shift-Z rolls back/forward the "reasampler" project ext state on disk
|
||||
// but keeps the SAME project identity, so the timer's identity poll never re-reads
|
||||
// ext state on undo/redo — the in-memory book/view would stay stale until
|
||||
// close+reopen. REAPER's projectconfig fires BeginLoadProjectState on every
|
||||
// project-state (re)load INCLUDING undo/redo (isUndo == true for both); we hook it.
|
||||
//
|
||||
// TIMING (the crux): BeginLoadProjectState is documented (reaper_plugin.h ~1203) as
|
||||
// firing BEFORE any state restore. Reading GetProjExtState synchronously here would
|
||||
// return the PRE-undo value. So we do NOT read here — we raise a one-shot reload request
|
||||
// (g_session.requestReload()) that OnTimer's poll() drains on the NEXT tick, by which
|
||||
// point REAPER has finished restoring the <EXTSTATE> block and GetProjExtState returns
|
||||
// the POST-undo value. Deterministic, event-driven — NOT ext-state content polling.
|
||||
//
|
||||
// GATED ON isUndo: a normal project open also fires BeginLoadProjectState (isUndo=false);
|
||||
// we ignore that here so a normal open flows solely through the timer's identity-transition
|
||||
// Load path (no double load). Only undo/redo (isUndo=true) requests the reload.
|
||||
// TIMING: BeginLoadProjectState fires BEFORE any state restore, so reading
|
||||
// GetProjExtState here would return the PRE-undo value. Instead we raise a one-shot
|
||||
// reload request that OnTimer's poll() drains on the NEXT tick, once REAPER has
|
||||
// finished restoring the <EXTSTATE> block. A normal project open also fires this
|
||||
// (isUndo=false); ignored here so a normal open flows solely through the timer's
|
||||
// identity-transition Load path (no double load).
|
||||
static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/)
|
||||
{
|
||||
if (isUndo)
|
||||
g_session.requestReload();
|
||||
}
|
||||
|
||||
// ProcessExtensionLine / SaveExtensionConfig are intentional no-ops: ReaSampler stores
|
||||
// its state via project EXT STATE (SetProjExtState/GetProjExtState under "reasampler"),
|
||||
// which REAPER persists in its own <EXTSTATE> RPP block — NOT via this extension's own
|
||||
// project lines. We register the struct ONLY for the BeginLoadProjectState undo/redo
|
||||
// notification. Returning false from ProcessExtensionLine means "not our line" so REAPER
|
||||
// keeps dispatching (we claim none). SaveExtensionConfig writes nothing.
|
||||
// Intentional no-ops: ReaSampler stores state via project EXT STATE, not this
|
||||
// extension's own project lines. The struct is registered ONLY for the
|
||||
// BeginLoadProjectState undo/redo notification.
|
||||
static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/,
|
||||
bool /*isUndo*/, project_config_extension_t* /*reg*/)
|
||||
{
|
||||
@@ -257,7 +197,6 @@ static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*
|
||||
static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/,
|
||||
project_config_extension_t* /*reg*/)
|
||||
{
|
||||
// Nothing to write: our data rides in ext state, not project lines.
|
||||
}
|
||||
|
||||
// Storage must outlive registration — REAPER holds this pointer until we unregister it.
|
||||
@@ -268,19 +207,15 @@ static project_config_extension_t g_projectConfig{
|
||||
nullptr, // userData
|
||||
};
|
||||
|
||||
// REAPER calls this for EVERY action fired anywhere; claim only our own id,
|
||||
// return false otherwise so REAPER keeps looking. This TU's own family dispatches
|
||||
// through the registration table; the Q-W4 families claim their own ids after it.
|
||||
// REAPER calls this for EVERY action fired anywhere; claim only our own id, return
|
||||
// false otherwise so REAPER keeps looking. This TU's own family dispatches through
|
||||
// the registration table; the other families claim their own ids after it.
|
||||
static bool OnHookCommand(int command, int /*flag*/)
|
||||
{
|
||||
if (command == 0) return false;
|
||||
if (reasampler::actionTableHandleCommand(command)) return true;
|
||||
// Design View action family (D4). Claims only its own ids; returns false for the
|
||||
// rest so this hook keeps looking (per the contract).
|
||||
if (reasampler::designViewHandleCommand(command)) return true;
|
||||
// Multi-bank action family (B3). Same contract: claims only its own ids.
|
||||
if (reasampler::bankHandleCommand(command)) return true;
|
||||
// S8 ingest action family (Media-Explorer import). Same contract.
|
||||
if (reasampler::ingestHandleCommand(command)) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -299,39 +234,30 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
{
|
||||
if (!rec)
|
||||
{
|
||||
// rec == nullptr => REAPER is UNLOADING us. Mirror-unregister every
|
||||
// callback with the same strings prefixed '-' (per the contract).
|
||||
// rec == nullptr => REAPER is UNLOADING us.
|
||||
if (g_rec)
|
||||
{
|
||||
// Abort any in-flight realtime capture FIRST, while the API pointers are
|
||||
// still live — finalize-or-abort + restore so we never leave a temp track,
|
||||
// an armed track, or an altered transport/cursor in the user's project on
|
||||
// unload. Commit whatever was captured (best effort) before tearing down.
|
||||
// still live, so we never leave a temp track, an armed track, or an
|
||||
// altered transport/cursor in the user's project on unload.
|
||||
capture::AbortRealtimeCaptureForUnload(g_session);
|
||||
|
||||
g_rec->Register("-timer", (void*)&OnTimer);
|
||||
g_rec->Register("-projectconfig", (void*)&g_projectConfig);
|
||||
g_rec->Register("-toggleaction", (void*)&OnToggleAction);
|
||||
g_rec->Register("-hookcommand", (void*)&OnHookCommand);
|
||||
// Tear down the Design View action family (D4) — mirror-unregisters each
|
||||
// gaccel + command_id with '-'-prefixed strings. After the hook is gone.
|
||||
reasampler::designViewUnregisterActions(g_rec);
|
||||
// Tear down the multi-bank action family (B3) — same mirror-unregister.
|
||||
reasampler::bankUnregisterActions(g_rec);
|
||||
// Tear down the S8 ingest action family — same mirror-unregister.
|
||||
reasampler::ingestUnregisterActions(g_rec);
|
||||
// Tear down this TU's own family from the registration table (reverse
|
||||
// table order; each '-command_id' re-presents the SAME interned,
|
||||
// channel-qualified pointer used at register).
|
||||
// This TU's own family, reverse table order; each '-command_id'
|
||||
// re-presents the SAME interned pointer used at register.
|
||||
reasampler::unregisterActionTable(g_rec);
|
||||
// Retire the REMOVED command ids (command_id only — we never held a gaccel
|
||||
// for them this session). Clears stale user keybindings on unload. Composed
|
||||
// per channel so a beta clears beta-qualified retired ids, stable its own.
|
||||
// for them this session).
|
||||
for (const char* suffix : kRetiredCaptureCmdSuffixes)
|
||||
g_rec->Register("-command_id", (void*)reasampler::channelIdFor(suffix));
|
||||
}
|
||||
// Destroy the docked window and release cached thumbnails before we drop
|
||||
// the API pointers (DockWindowRemove/DestroyWindow need them live).
|
||||
// Before dropping the API pointers: DockWindowRemove/DestroyWindow need them live.
|
||||
reasampler::bankPanelShutdown();
|
||||
g_rec = nullptr;
|
||||
return 0;
|
||||
@@ -349,13 +275,10 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
g_hInst = hInstance;
|
||||
g_rec = rec;
|
||||
|
||||
// Point the bank panel at the live session BEFORE registering its action, so
|
||||
// a toggle firing immediately has a session to read (M5). Does not open the
|
||||
// window — only stores the session pointer.
|
||||
// Point the bank panel at the live session BEFORE registering its action, so a
|
||||
// toggle firing immediately has a session to read. Does not open the window.
|
||||
reasampler::bankPanelInit(&g_session);
|
||||
|
||||
// Register this TU's whole action family from the table: command_id -> gaccel
|
||||
// per row, all channel-qualified, all FOREVER-STABLE per channel.
|
||||
{
|
||||
const std::vector<reasampler::ActionTableRow> rows = buildMainActionTable();
|
||||
reasampler::registerActionTable(rec, rows.data(), rows.size());
|
||||
@@ -367,37 +290,23 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
if (g_cmdToggleBankPanel)
|
||||
rec->Register("toggleaction", (void*)&OnToggleAction);
|
||||
|
||||
// Register the Design View action family (D4): toggle/activate mode, tag/untag/
|
||||
// show-both selected tracks. Each mints its own command_id + gaccel; the single
|
||||
// hookcommand below routes them via designViewHandleCommand. Registered before
|
||||
// the hook so every id is minted first.
|
||||
// Each family mints its own command_id + gaccel, shares g_session, and is routed
|
||||
// by the same hookcommand below. Registered before the hook so every id is
|
||||
// minted first.
|
||||
reasampler::designViewRegisterActions(rec, &g_session);
|
||||
|
||||
// Register the multi-bank action family (B3): create/rename/delete/evacuate bank,
|
||||
// activate (cycle + pool), move/copy selected samples to a bank, and the two
|
||||
// full-height layout toggles. Shares g_session with the Design View family; routed
|
||||
// by the same hookcommand via bankHandleCommand. Registered before the hook.
|
||||
reasampler::bankRegisterActions(rec, &g_session);
|
||||
|
||||
// Register the S8 ingest action family: the Media-Explorer import-into-bank+assign
|
||||
// action. Shares g_session with the other families; routed by the same hookcommand via
|
||||
// ingestHandleCommand. (The arrange capture+assign action is a table row above; the
|
||||
// drop path is a bank_panel callback, not a bindable action.)
|
||||
reasampler::ingestRegisterActions(rec, &g_session);
|
||||
|
||||
// One hookcommand routes every ReaSampler action (table + the three families).
|
||||
// Registered once, after all command ids are minted.
|
||||
rec->Register("hookcommand", (void*)&OnHookCommand);
|
||||
|
||||
// Drive project-load / Save-As detection (M4 persist). The timer polls the
|
||||
// active project each tick; on a project load it reloads the bank from ext
|
||||
// state, on a Save-As it relocates the bank folder under the new .rpp.
|
||||
// Drives project-load / Save-As detection: the timer polls the active project
|
||||
// each tick; on a project load it reloads the bank from ext state, on a Save-As
|
||||
// it relocates the bank folder under the new .rpp.
|
||||
rec->Register("timer", (void*)&OnTimer);
|
||||
|
||||
// Register the projectconfig hook so an UNDO/REDO state restore reloads the
|
||||
// session's book + view from the restored ext state (R-B). The timer's identity
|
||||
// poll cannot see an undo (same project identity), so this hook owns undo/redo; it
|
||||
// requests a deferred reload that the next timer tick drains (see the hook comment).
|
||||
// An UNDO/REDO state restore reloads the session's book + view from the restored
|
||||
// ext state. The timer's identity poll cannot see an undo (same project
|
||||
// identity), so this hook owns it (see OnBeginLoadProjectState).
|
||||
rec->Register("projectconfig", (void*)&g_projectConfig);
|
||||
|
||||
return 1; // success — REAPER keeps us loaded
|
||||
|
||||
@@ -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
@@ -5,14 +5,11 @@
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
// peaks implementation.
|
||||
// peaks — pure implementation. See peaks.h.
|
||||
//
|
||||
// One linear pass per channel. The frame->bin partition is computed with integer
|
||||
// arithmetic so it is exact for any frameCount / binCount pairing: bin b owns the
|
||||
// half-open frame span [b*frameCount/binCount, (b+1)*frameCount/binCount). That
|
||||
// span formula distributes the remainder deterministically (earlier bins get the
|
||||
// extra frames) with no rounding drift and no dropped tail — the last bin's end is
|
||||
// always exactly frameCount.
|
||||
// One linear pass per channel. Frame->bin partition uses integer arithmetic so it's exact for
|
||||
// any frameCount/binCount pairing: bin b owns [b*frameCount/binCount, (b+1)*frameCount/binCount)
|
||||
// — earlier bins absorb the remainder, no rounding drift, no dropped tail.
|
||||
|
||||
namespace reasampler::audio {
|
||||
|
||||
@@ -22,11 +19,10 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
||||
std::size_t binCount) {
|
||||
Envelope envelope(channelCount);
|
||||
if (channelCount == 0) {
|
||||
return envelope; // no channels -> no envelopes
|
||||
return envelope;
|
||||
}
|
||||
|
||||
// Never read past what the buffer actually holds, even if the caller's
|
||||
// frameCount overstates the buffer (defensive: no OOB on a short buffer).
|
||||
// Never read past what the buffer actually holds, even if frameCount overstates it.
|
||||
const std::size_t availableFrames = interleaved.size() / channelCount;
|
||||
const std::size_t frames = std::min(frameCount, availableFrames);
|
||||
|
||||
@@ -35,14 +31,10 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
||||
bins.assign(binCount, MinMax{}); // empty/degenerate bins default to {0,0}
|
||||
|
||||
for (std::size_t b = 0; b < binCount; ++b) {
|
||||
// Half-open frame span for this bin: [b*frames/binCount, (b+1)*frames/binCount).
|
||||
// Guard against size_t overflow in b*frames and (b+1)*frames: binCount is
|
||||
// caller-controlled and unbounded, so when b >= SIZE_MAX/frames either
|
||||
// multiplication could wrap. Any such bin is unreachable in practice
|
||||
// (allocating that many MinMax entries would OOM first), but we guard
|
||||
// explicitly to eliminate UB.
|
||||
// Guard b*frames / (b+1)*frames overflow: binCount is caller-controlled and
|
||||
// unbounded. Unreachable in practice (would OOM first) but guarded to avoid UB.
|
||||
if (frames > 0 && b >= SIZE_MAX / frames) {
|
||||
continue; // b*frames or (b+1)*frames would overflow; span is empty
|
||||
continue;
|
||||
}
|
||||
const std::size_t begin = (b * frames) / binCount;
|
||||
const std::size_t end = ((b + 1) * frames) / binCount;
|
||||
@@ -69,21 +61,17 @@ MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col) {
|
||||
const int nbins = static_cast<int>(bins.size());
|
||||
if (columnCount <= 0 || nbins == 0) return MinMax{};
|
||||
|
||||
// Clamp col to [0, columnCount-1].
|
||||
if (col < 0) col = 0;
|
||||
if (col >= columnCount) col = columnCount - 1;
|
||||
|
||||
// Half-open bin range for this column, mirroring computeEnvelope's exact partition.
|
||||
// 64-bit products: col*nbins can exceed int range for a large oversampled envelope
|
||||
// (same overflow discipline as computeEnvelope's frame-span arithmetic above).
|
||||
// Half-open bin range for this column, mirroring computeEnvelope's partition. 64-bit
|
||||
// products: col*nbins can exceed int range for a large oversampled envelope.
|
||||
const std::int64_t begin64 = (static_cast<std::int64_t>(col) * nbins) / columnCount;
|
||||
const std::int64_t end64 =
|
||||
(static_cast<std::int64_t>(col) + 1) * nbins / columnCount;
|
||||
// col <= columnCount-1 guarantees begin64 <= (columnCount-1)*nbins/columnCount < nbins.
|
||||
const int colBinBegin = static_cast<int>(begin64);
|
||||
|
||||
// When the column spans no full bin (more columns than bins), use the enclosing bin
|
||||
// so no column is left empty.
|
||||
// When the column spans no full bin (more columns than bins), use the enclosing bin.
|
||||
const int scanEnd = (end64 > begin64) ? static_cast<int>(end64) : colBinBegin + 1;
|
||||
const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins;
|
||||
|
||||
@@ -102,14 +90,11 @@ std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
|
||||
AudioSample linearThreshold) {
|
||||
if (channelCount == 0) return kNoFrameAboveThreshold;
|
||||
|
||||
// Clamp to what the buffer actually holds — a caller frameCount that overstates
|
||||
// the buffer must never read past the end (mirror of computeEnvelope's guard).
|
||||
const std::size_t availableFrames = interleaved.size() / channelCount;
|
||||
const std::size_t frames = std::min(frameCount, availableFrames);
|
||||
if (frames == 0) return kNoFrameAboveThreshold;
|
||||
|
||||
// Scan backward: the first frame (from the end) whose loudest channel exceeds the
|
||||
// threshold is the last audible frame. `f` runs frames..1 so `f-1` never wraps.
|
||||
// Scan backward; `f` runs frames..1 so `f-1` never wraps.
|
||||
for (std::size_t f = frames; f > 0; --f) {
|
||||
const std::size_t frame = f - 1;
|
||||
const std::size_t base = frame * channelCount;
|
||||
|
||||
+42
-76
@@ -1,32 +1,20 @@
|
||||
#pragma once
|
||||
// peaks — waveform min/max envelope (thumbnail) computation from raw interleaved
|
||||
// PCM. We compute our own thumbnails from the captured file rather than depending
|
||||
// on REAPER's peak API: we own the file format, so this is simpler, testable, and
|
||||
// dependency-free. A future bank panel (M5) calls this at whatever bin resolution
|
||||
// the panel width dictates and draws one min/max envelope per channel.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. Builds and unit-tests without REAPER.
|
||||
// peaks — waveform min/max envelope (thumbnail) computation from raw interleaved PCM. We compute
|
||||
// our own thumbnails rather than depending on REAPER's peak API: we own the file format, so this
|
||||
// is simpler, testable, and dependency-free.
|
||||
|
||||
#include <cstddef>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::audio {
|
||||
|
||||
// Canonical in-memory audio-sample type. `float` is REAPER's native audio buffer
|
||||
// format (its render/PCM_source callbacks hand back interleaved 32-bit float), so
|
||||
// peaks consumes that directly with no lossy conversion. If a capture ever lands
|
||||
// as a different depth, the caller converts to float at the boundary — the
|
||||
// thumbnail core stays single-typed.
|
||||
//
|
||||
// NAMED AudioSample, not `Sample`: `reasampler::Sample` is already bank_model's
|
||||
// metadata struct. A `using Sample = float` here would collide at namespace scope
|
||||
// wherever both headers are visible (the bank_panel module includes both). The
|
||||
// audio-domain name also reads more precisely — this is one PCM sample value.
|
||||
// REAPER's native audio buffer format (interleaved 32-bit float), consumed directly with no
|
||||
// lossy conversion. Named AudioSample rather than Sample to avoid colliding with bank_model's
|
||||
// metadata struct of the same short name.
|
||||
using AudioSample = float;
|
||||
|
||||
// One bin of a channel's envelope: the extremes of every sample that fell in it.
|
||||
// min <= max always. For an empty bin (more bins than frames), both are 0.
|
||||
// One bin's extremes across the samples that fell in it. min <= max always; an empty bin
|
||||
// (more bins than frames) is {0, 0}.
|
||||
struct MinMax {
|
||||
AudioSample min = 0.0f;
|
||||
AudioSample max = 0.0f;
|
||||
@@ -37,83 +25,61 @@ struct MinMax {
|
||||
// One channel's envelope: exactly `binCount` bins, in time order.
|
||||
using ChannelEnvelope = std::vector<MinMax>;
|
||||
|
||||
// Per-channel envelopes: outer index is channel (channelCount entries, order
|
||||
// preserved — never mixed or folded), inner is that channel's bins.
|
||||
// Per-channel envelopes: outer index is channel (channelCount entries, order preserved — never
|
||||
// mixed or folded), inner is that channel's bins.
|
||||
using Envelope = std::vector<ChannelEnvelope>;
|
||||
|
||||
// Computes a per-channel min/max envelope from interleaved PCM.
|
||||
//
|
||||
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...].
|
||||
// Size must be >= frameCount * channelCount; extra is ignored.
|
||||
// channelCount channels per frame (the stride). Each channel is enveloped
|
||||
// INDEPENDENTLY — no averaging, no stereo fold (precision
|
||||
// invariant: channel count preserved).
|
||||
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. Size must be
|
||||
// >= frameCount * channelCount; extra is ignored.
|
||||
// channelCount channels per frame (the stride). Each channel is enveloped INDEPENDENTLY — no
|
||||
// averaging, no stereo fold (channel count is preserved end to end).
|
||||
// frameCount frames (samples-per-channel) to consider.
|
||||
// binCount requested bins per channel. Honored exactly for any frameCount.
|
||||
//
|
||||
// Frame->bin partition: frames are split into `binCount` contiguous spans as
|
||||
// evenly as possible; when frameCount does not divide evenly, the remainder is
|
||||
// spread one-frame-per-bin across the earliest bins (ceil/floor split), so the
|
||||
// tail is never dropped and no bin reads out of bounds. When binCount > frameCount
|
||||
// the trailing empty bins are {0, 0}.
|
||||
// Frame->bin partition: frames split into `binCount` contiguous spans as evenly as possible;
|
||||
// when frameCount doesn't divide evenly, the remainder spreads one-frame-per-bin across the
|
||||
// earliest bins, so the tail is never dropped and no bin reads out of bounds.
|
||||
//
|
||||
// Defined behavior for degenerate input (no UB, no throw):
|
||||
// binCount == 0 -> per channel: an empty bin vector.
|
||||
// channelCount == 0 -> an empty envelope (no channels).
|
||||
// frameCount == 0 -> per channel: binCount bins, all {0, 0}.
|
||||
// Degenerate input (no UB, no throw): binCount == 0 -> empty bin vector per channel;
|
||||
// channelCount == 0 -> empty envelope; frameCount == 0 -> binCount bins, all {0, 0}.
|
||||
Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
||||
std::size_t channelCount,
|
||||
std::size_t frameCount,
|
||||
std::size_t binCount);
|
||||
|
||||
// The merged min/max for display column `col` (0-based, of `columnCount` total columns)
|
||||
// of a pre-computed per-bin ChannelEnvelope: the true extremes of every bin that projects
|
||||
// to that column. This is the display-side collapse of an envelope computed at HIGHER
|
||||
// resolution than the drawn width (oversampled bins -> per-pixel-column min/max), so a
|
||||
// steep transient whose adjacent bins hold disjoint spans (e.g. {0.9,1.0} then
|
||||
// {-1.0,-0.9}) renders as one gap-free vertical span instead of two separated dots.
|
||||
// Merged min/max for display column `col` (0-based, of `columnCount` total) of a pre-computed
|
||||
// ChannelEnvelope — the true extremes of every bin projecting to that column. This is the
|
||||
// display-side collapse when the envelope was computed at a higher resolution than the drawn
|
||||
// width, so a steep transient split across adjacent bins (e.g. {0.9,1.0} then {-1.0,-0.9})
|
||||
// renders as one gap-free span instead of two separated dots.
|
||||
//
|
||||
// Bin->column mapping mirrors computeEnvelope's half-open partition:
|
||||
// column col owns bins [col*nbins/columnCount, (col+1)*nbins/columnCount).
|
||||
// When that range is empty (more columns than bins), the enclosing bin
|
||||
// (col*nbins/columnCount) fills the column — so no column is left empty and no bin is
|
||||
// ever dropped. columnCount <= 0 or bins.empty() returns {0, 0}; `col` is clamped to
|
||||
// [0, columnCount-1]. Pure.
|
||||
// Bin->column mapping mirrors computeEnvelope's half-open partition: column col owns bins
|
||||
// [col*nbins/columnCount, (col+1)*nbins/columnCount). When that range is empty (more columns
|
||||
// than bins), the enclosing bin fills the column instead. columnCount <= 0 or bins.empty()
|
||||
// returns {0, 0}; col is clamped to [0, columnCount-1].
|
||||
MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col);
|
||||
|
||||
// Sentinel returned by lastFrameAboveThreshold when NO frame in the scanned range
|
||||
// peaks above the threshold (pure silence at that level). SIZE_MAX is unambiguous:
|
||||
// no valid frame index can equal it (a real index is < frameCount <= SIZE_MAX for
|
||||
// any allocatable buffer), so the caller tests `== kNoFrameAboveThreshold` cleanly.
|
||||
// Sentinel for "no frame in the scanned range peaked above threshold". SIZE_MAX is unambiguous
|
||||
// since no real frame index can reach it.
|
||||
inline constexpr std::size_t kNoFrameAboveThreshold =
|
||||
static_cast<std::size_t>(-1);
|
||||
|
||||
// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (the max
|
||||
// absolute value across all channels of that frame — NO stereo fold, just the
|
||||
// loudest channel that frame) exceeds `linearThreshold`, returning that frame index.
|
||||
// Returns kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input).
|
||||
// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (max |sample| across
|
||||
// all channels of that frame — no stereo fold) exceeds `linearThreshold`. Returns
|
||||
// kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input).
|
||||
//
|
||||
// This is the boundary primitive behind the realtime tail's decay-scan trim
|
||||
// (docs/product/capture-tail.md §The realtime path): the recorded tail window is
|
||||
// scanned back from the end for the last frame still above -72 dB, and the file is
|
||||
// truncated one frame past it. Deliberately a separate primitive from
|
||||
// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail),
|
||||
// this answers "the last frame above a level" (a boundary). Bending the bin-oriented
|
||||
// envelope to a frame-exact boundary question is a worse fit (spec §option a).
|
||||
// This is the boundary primitive behind the realtime tail's decay-scan trim (see
|
||||
// docs/product/capture-tail.md): the recorded tail is scanned back from the end for the last
|
||||
// frame still above -72 dB, and the file truncated one frame past it. Deliberately separate from
|
||||
// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail), this answers
|
||||
// "the last frame above a level" (a boundary); bending a bin-oriented envelope to a frame-exact
|
||||
// question is a worse fit.
|
||||
//
|
||||
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...].
|
||||
// Must hold >= frameCount * channelCount; extra is ignored, and a
|
||||
// short buffer is clamped to what it actually holds (no OOB read).
|
||||
// channelCount channels per frame (the stride). The per-frame test is the max
|
||||
// |sample| over these channels — the frame is "above" if its
|
||||
// loudest channel is above the threshold.
|
||||
// frameCount frames to consider (the scan starts at the last of these).
|
||||
// linearThreshold the comparison level as a LINEAR amplitude ratio (e.g. the
|
||||
// -72 dB ratio from render_settings::autoTrimEndRatio), NOT dB.
|
||||
// A frame counts as above when its peak is STRICTLY > this.
|
||||
//
|
||||
// Pure, stdlib-only, unit-tested (a synthetic decaying ramp, silence, all-above,
|
||||
// and degenerate inputs) so the trim boundary math is locked outside the DAW.
|
||||
// linearThreshold a LINEAR amplitude ratio (e.g. the -72 dB ratio from
|
||||
// render_settings::autoTrimEndRatio), NOT dB. A frame counts as above when
|
||||
// its peak is STRICTLY greater than this.
|
||||
std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
|
||||
std::size_t channelCount,
|
||||
std::size_t frameCount,
|
||||
|
||||
@@ -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.
|
||||
@@ -1,5 +1,5 @@
|
||||
// batch_capture.cpp — pure logic for M11 batch capture. See header.
|
||||
// NO REAPER types; unit-tested by tests/test_batch_capture.cpp.
|
||||
// batch_capture.cpp — pure logic for batch capture. See header.
|
||||
// Unit-tested by tests/test_batch_capture.cpp.
|
||||
|
||||
#include "core/capture/batch_capture.h"
|
||||
|
||||
@@ -12,9 +12,7 @@ std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges)
|
||||
units.reserve(ranges.size());
|
||||
int ordinal = 0;
|
||||
for (const BatchRange& r : ranges) {
|
||||
// Drop empty/inverted ranges — the offline backend refuses end<=start too, so
|
||||
// planning one would only manufacture a guaranteed per-unit failure. Ordinals
|
||||
// count kept units so the reported numbering is contiguous.
|
||||
// Drop empty/inverted ranges — the offline backend refuses end<=start too.
|
||||
if (!(r.endSeconds > r.startSeconds)) continue;
|
||||
++ordinal;
|
||||
units.push_back({ordinal, r.startSeconds, r.endSeconds});
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
#pragma once
|
||||
// batch_capture — the REAPER-free logic behind M11 batch capture (one action fires
|
||||
// N captures: one bank sample per selected item / per razor area).
|
||||
// batch_capture — the REAPER-free logic behind batch capture (one action fires N
|
||||
// captures: one bank sample per selected item / per razor area).
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. The batch shell (main.cpp) reads the DAW
|
||||
// state (selected items -> their exact bounds; every track's P_RAZOREDITS -> areas)
|
||||
// and hands the raw ranges here so the genuinely-pure, easy-to-get-wrong pieces are
|
||||
// unit-tested outside the DAW:
|
||||
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
||||
// only. The batch shell reads the DAW state (selected items -> exact bounds;
|
||||
// each track's P_RAZOREDITS -> areas) and hands the raw ranges here:
|
||||
//
|
||||
// 1. planCaptureUnits: an ordered list of (start,end) source ranges -> an ordered
|
||||
// list of CaptureUnit, each carrying its 1-based ordinal and validated bounds.
|
||||
// Empty/inverted ranges are DROPPED (mirrors the offline backend's own
|
||||
// end>start guard) so a zero-length item/area never produces a stray render.
|
||||
// Order is preserved: unit ordinals count only the KEPT units, so a batch of
|
||||
// three valid items yields ordinals 1,2,3 regardless of dropped neighbors.
|
||||
// 2. BatchOutcome: order-preserving aggregation of per-unit results into a summary
|
||||
// (succeeded / failed counts + the ordered list of failures) so the shell can
|
||||
// report a mixed result with one console line and no partial-corruption
|
||||
// ambiguity. The AGGREGATION is pure; the render loop that feeds it is shell.
|
||||
// 1. planCaptureUnits: an ordered list of (start,end) ranges -> an ordered
|
||||
// list of CaptureUnit, each with a 1-based ordinal and validated bounds.
|
||||
// Empty/inverted ranges are dropped (mirrors the offline backend's own
|
||||
// end>start guard); ordinals count only the kept units, so three valid
|
||||
// items yield 1,2,3 regardless of dropped neighbors.
|
||||
// 2. BatchOutcome: order-preserving aggregation of per-unit results into a
|
||||
// summary (succeeded/failed counts + ordered failures) for one console
|
||||
// line with no partial-corruption ambiguity.
|
||||
//
|
||||
// Range is the ONLY thing that varies per unit here. FX scope (item vs track) is a
|
||||
// per-ACTION constant the shell already owns (fxBypassPlanFor); it is not a
|
||||
// per-unit field. Item-batch uses item scope; razor-batch uses track scope — the
|
||||
// shell passes the scope straight through to each render, unchanged from the
|
||||
// single-capture path.
|
||||
// Range is the only thing that varies per unit here. FX scope (item vs track) is
|
||||
// a per-action constant the shell already owns; item-batch uses item scope,
|
||||
// razor-batch uses track scope, passed through unchanged from the single-capture
|
||||
// path.
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
@@ -31,10 +26,10 @@
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// One capture in a batch: an exact source range plus its 1-based ordinal within the
|
||||
// KEPT set. The ordinal disambiguates per-unit file stems (the offline backend's
|
||||
// unique tag is 1-second-granular, so a fast batch could otherwise collide N files
|
||||
// onto one name) and labels a failure in the summary.
|
||||
// One capture in a batch: an exact source range plus its 1-based ordinal within
|
||||
// the kept set. The ordinal disambiguates per-unit file stems (the offline
|
||||
// backend's unique tag is 1-second-granular, so a fast batch could otherwise
|
||||
// collide N files onto one name) and labels a failure in the summary.
|
||||
struct CaptureUnit {
|
||||
int ordinal = 0; // 1-based, counts kept units only
|
||||
double startSeconds = 0.0; // exact — no rounding
|
||||
@@ -42,20 +37,18 @@ struct CaptureUnit {
|
||||
};
|
||||
|
||||
// A source range handed in by the shell (a selected item's [pos, pos+len] or one
|
||||
// razor area's [start, end]). Kept as a distinct type from CaptureUnit so the input
|
||||
// (raw, possibly-invalid) and the output (validated, ordinal-assigned) do not share
|
||||
// a shape by accident. Named BatchRange (not SourceRange) to avoid collision with
|
||||
// bank_model's SourceRange, which carries PPQ fields this planner does not need.
|
||||
// razor area's [start, end]). Named BatchRange (not SourceRange) to avoid
|
||||
// collision with bank_model's SourceRange, which carries PPQ fields this planner
|
||||
// doesn't need.
|
||||
struct BatchRange {
|
||||
double startSeconds = 0.0;
|
||||
double endSeconds = 0.0;
|
||||
};
|
||||
|
||||
// Validates + orders a batch's source ranges into capture units. Preserves input
|
||||
// order; DROPS every range with end <= start (empty/inverted) so no stray render is
|
||||
// planned; assigns 1-based ordinals over the KEPT units. An empty input (no selected
|
||||
// item / no razor area) yields an empty plan — the shell reports "nothing to batch"
|
||||
// and writes nothing (the same no-op posture the single-capture path takes).
|
||||
// order; drops every range with end <= start; assigns 1-based ordinals over the
|
||||
// kept units. An empty input yields an empty plan — the shell reports "nothing
|
||||
// to batch" and writes nothing.
|
||||
std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges);
|
||||
|
||||
// The per-unit verdict the shell records after each render attempt, in unit order.
|
||||
@@ -65,10 +58,9 @@ struct BatchUnitResult {
|
||||
std::string detail; // failure reason (empty on success) — for the summary
|
||||
};
|
||||
|
||||
// Order-preserving aggregation of a batch's per-unit results. Built incrementally by
|
||||
// the shell (record() after each unit) so a mid-batch failure is captured without
|
||||
// aborting the remaining units (no partial corruption: each unit is independent, and
|
||||
// the selection is restored on every exit path by the shell's RAII guard).
|
||||
// Order-preserving aggregation of a batch's per-unit results. Built incrementally
|
||||
// by the shell (record() after each unit) so a mid-batch failure doesn't abort
|
||||
// the remaining units — each unit is independent.
|
||||
class BatchOutcome {
|
||||
public:
|
||||
// Records one unit's verdict. Order of calls IS the reported order.
|
||||
|
||||
@@ -6,25 +6,16 @@
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// The content-identity hashes (hashBytes / hashWavContent) moved to wav_codec
|
||||
// (Q-W3, audit §4e) — one pure owner of the RIFF chunk walk, shared with the
|
||||
// layout parse so hashing and decoding cannot desynchronize.
|
||||
|
||||
std::string normalizeSlashes(const std::string& path) {
|
||||
std::string out = path;
|
||||
for (char& c : out) {
|
||||
if (c == '\\') c = '/';
|
||||
}
|
||||
// Strip a single trailing slash so joins do not double up. Preserve a lone
|
||||
// "/" (root) — stripping it would turn root into empty.
|
||||
// Strip a trailing slash but preserve a lone "/" (root).
|
||||
if (out.size() > 1 && out.back() == '/') {
|
||||
out.pop_back();
|
||||
}
|
||||
#ifdef _WIN32
|
||||
// Windows paths are case-insensitive. Fold to lowercase so that two paths
|
||||
// that differ only in drive-letter or component casing compare equal (e.g.
|
||||
// "C:/Foo/BAR.wav" == "c:/foo/bar.wav"). On macOS/Linux, exact case is
|
||||
// preserved (the filesystem is case-sensitive; folding would be wrong).
|
||||
for (char& c : out) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
#endif
|
||||
return out;
|
||||
@@ -39,8 +30,7 @@ std::string sanitizeStem(const std::string& baseName) {
|
||||
c == '-';
|
||||
out.push_back(keep ? static_cast<char>(c) : '_');
|
||||
}
|
||||
// Collapse to a stable default if nothing usable survived (e.g. all spaces).
|
||||
// A stem of only separators ('.', '_', '-') is also unhelpful as a name.
|
||||
// Collapse to a stable default if nothing alnum survived.
|
||||
bool hasAlnum = false;
|
||||
for (unsigned char c : out) {
|
||||
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
@@ -66,21 +56,16 @@ BankPaths deriveBankPaths(const std::string& projectDir,
|
||||
}
|
||||
const std::string fileName = stem + ".wav";
|
||||
|
||||
// Precondition: the capture shell must resolve a non-empty project directory
|
||||
// before calling this function. An empty projectDir would produce a bare
|
||||
// relative "reasampler_bank" path — the silent default-location fallback this
|
||||
// tool explicitly forbids. Assert in debug; leave absoluteDir empty in release
|
||||
// so any caller that ignores the precondition fails loudly at the render/stat
|
||||
// step rather than silently writing to CWD.
|
||||
// Precondition: caller must resolve a non-empty project directory — an
|
||||
// empty one would otherwise fall back to a bare relative path (forbidden).
|
||||
// Assert in debug; leave absoluteDir empty in release so a caller that
|
||||
// ignores it fails at the render/stat step, not silently onto CWD.
|
||||
assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty");
|
||||
|
||||
BankPaths p;
|
||||
p.fileStem = stem; // stem only — REAPER appends extension
|
||||
p.fileName = fileName;
|
||||
p.relativePath = std::string(kBankSubfolder) + "/" + fileName;
|
||||
// absoluteDir intentionally omits a trailing slash (RENDER_FILE wants the
|
||||
// directory itself; RENDER_PATTERN supplies the file name separately).
|
||||
// Empty when precondition is violated (dir empty) — caller must not proceed.
|
||||
p.absoluteDir = dir.empty() ? std::string{}
|
||||
: dir + "/" + kBankSubfolder;
|
||||
return p;
|
||||
@@ -88,15 +73,12 @@ BankPaths deriveBankPaths(const std::string& projectDir,
|
||||
|
||||
std::string bankRelativeForName(const std::string& fileName) {
|
||||
if (fileName.empty()) return {};
|
||||
// The SAME expression deriveBankPaths uses for relativePath, kept in one place so
|
||||
// the two spellings can never drift (Phase R spelling-consistency invariant).
|
||||
// Same expression deriveBankPaths uses, so the two spellings can't drift.
|
||||
return std::string(kBankSubfolder) + "/" + fileName;
|
||||
}
|
||||
|
||||
std::string resolveBankFile(const std::string& projectDir,
|
||||
const std::string& relativePath) {
|
||||
// No default-location fallback (CLAUDE.md invariant): an empty project dir or
|
||||
// relative path yields empty, not a bare relative path resolved against CWD.
|
||||
if (projectDir.empty() || relativePath.empty()) {
|
||||
return {};
|
||||
}
|
||||
@@ -109,10 +91,7 @@ std::string resolveBankFile(const std::string& projectDir,
|
||||
}
|
||||
|
||||
std::string projectDirOfRpp(const std::string& rppPath) {
|
||||
// An unsaved project reports an empty .rpp path; keep it empty so downstream
|
||||
// resolution refuses (no default-location fallback). Mirrors the former persist shell's
|
||||
// projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes.
|
||||
if (rppPath.empty()) return {};
|
||||
if (rppPath.empty()) return {}; // unsaved project: keep empty, no fallback
|
||||
std::string dir = std::filesystem::path(rppPath).parent_path().string();
|
||||
return normalizeSlashes(dir);
|
||||
}
|
||||
@@ -128,9 +107,7 @@ BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
|
||||
|
||||
r.oldBankDir = oldDir + "/" + kBankSubfolder;
|
||||
r.newBankDir = newDir + "/" + kBankSubfolder;
|
||||
// A Save (in place) leaves the project dir unchanged — nothing to relocate.
|
||||
// Only a Save-As to a different directory needs the bank moved.
|
||||
r.needed = (oldDir != newDir);
|
||||
r.needed = (oldDir != newDir); // Save-in-place leaves the dir unchanged
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -139,42 +116,16 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject,
|
||||
const std::string& lastPath,
|
||||
const std::string& currentGuid,
|
||||
const std::string& currentPath) {
|
||||
// 1. The GUID is the identity of record and is checked FIRST. A different
|
||||
// stored GUID means a genuinely different project is active — Load ITS index.
|
||||
// This catches the regression that pointer-primary classification missed:
|
||||
// REAPER RECYCLES ReaProject* addresses across close/open, so a reopened /
|
||||
// new project can reuse the previous project's address (sameProjectObject ==
|
||||
// true) while carrying a different stored GUID. Deciding on the pointer alone
|
||||
// then returned NoOp/SaveAsRelocate and the bank never reloaded. The GUID is
|
||||
// immune to address recycling, so it leads. Also covers new/unsaved<->saved
|
||||
// transitions (one GUID empty, the other not) and switching between two
|
||||
// distinct saved projects.
|
||||
// See capture_paths.h for the GUID-primary rationale and rule order.
|
||||
if (currentGuid != lastGuid) {
|
||||
return ProjectTransition::Load;
|
||||
}
|
||||
|
||||
// From here currentGuid == lastGuid (they are equal; both may be empty for
|
||||
// unsaved projects). The pointer now disambiguates the same-GUID case.
|
||||
|
||||
// 2. Same GUID but a DIFFERENT object is a forked sibling: Save-As copied our
|
||||
// GUID onto a distinct project object. Load its (own) index; never relocate.
|
||||
// Two unsaved projects (both GUIDs empty, distinct objects) also land here —
|
||||
// Load, so switching between them installs the right in-memory state.
|
||||
if (!sameProjectObject) {
|
||||
return ProjectTransition::Load;
|
||||
return ProjectTransition::Load; // forked sibling: same GUID, different object
|
||||
}
|
||||
|
||||
// 3. Same object AND same GUID with a NEW path is a genuine Save-As (the object
|
||||
// identity is proven and the record identity is unchanged — only the .rpp
|
||||
// moved). Also the first save of an unsaved project (both GUIDs empty, old
|
||||
// path empty): SaveAsRelocate is safe there because deriveRelocationPlan
|
||||
// no-ops on the empty old dir (empty-GUID safety preserved) while poll()
|
||||
// mints a GUID.
|
||||
if (currentPath != lastPath) {
|
||||
return ProjectTransition::SaveAsRelocate;
|
||||
}
|
||||
|
||||
// 4. Same object, same GUID, same path — Save in place / idle tick.
|
||||
return ProjectTransition::NoOp;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
#pragma once
|
||||
// capture_paths — the REAPER-free path arithmetic behind offline capture.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. The capture shell resolves the
|
||||
// current project directory via REAPER APIs, then hands the raw strings here so
|
||||
// the fiddly, easy-to-get-wrong path arithmetic (bank subfolder, unique file
|
||||
// name, absolute render dir, project-relative index path) is unit-tested outside
|
||||
// the DAW.
|
||||
//
|
||||
// Path convention: this module works in forward-slash form and does NOT touch
|
||||
// the filesystem. The bank subfolder name is a fixed constant so the same
|
||||
// project always resolves the same bank location (determinism).
|
||||
// capture_paths — the REAPER-free path arithmetic behind offline capture. The
|
||||
// capture shell resolves the current project directory via REAPER APIs, then
|
||||
// hands the raw strings here. Forward-slash form throughout, no filesystem
|
||||
// access; the bank subfolder name is a fixed constant.
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
@@ -20,7 +12,7 @@
|
||||
namespace reasampler::capture {
|
||||
|
||||
// The project-relative bank subfolder. All captured wavs live here so the bank
|
||||
// travels with the .rpp (CONTEXT.md §Settled decisions: per-project bank).
|
||||
// travels with the .rpp.
|
||||
inline constexpr const char* kBankSubfolder = "reasampler_bank";
|
||||
|
||||
// A resolved pair of paths for one capture: where REAPER must be told to write
|
||||
@@ -34,150 +26,87 @@ struct BankPaths {
|
||||
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
|
||||
};
|
||||
|
||||
// NOTE (Q-W3, audit §4e): the content-identity hashes (hashBytes / hashWavContent)
|
||||
// moved to core/capture/wav_codec.{h,cpp} — the ONE pure owner of the WAV/RIFF byte
|
||||
// format — so this module holds path arithmetic only, with no RIFF chunk knowledge.
|
||||
|
||||
// Normalizes a path to forward slashes and strips any trailing slash. Empty in
|
||||
// -> empty out. Pure string transform (does not consult the filesystem).
|
||||
// Platform case rule: on Windows (_WIN32) the result is also lowercased so that
|
||||
// paths differing only in drive-letter or component casing compare equal (Windows
|
||||
// paths are case-insensitive). On macOS/Linux the case is preserved exactly (those
|
||||
// filesystems are case-sensitive).
|
||||
// Normalizes a path to forward slashes and strips any trailing slash (does not
|
||||
// consult the filesystem). On Windows (_WIN32) also lowercases the result so
|
||||
// paths differing only in casing compare equal; macOS/Linux preserve case.
|
||||
std::string normalizeSlashes(const std::string& path);
|
||||
|
||||
// Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps
|
||||
// [A-Za-z0-9._-], replaces every other byte (spaces, slashes, quotes, control)
|
||||
// with '_', and collapses to "capture" if nothing usable remains. Deterministic:
|
||||
// the same input always yields the same stem (feeds bit-identical file naming).
|
||||
// [A-Za-z0-9._-], replaces every other byte with '_', and collapses to
|
||||
// "capture" if nothing usable remains. Deterministic.
|
||||
std::string sanitizeStem(const std::string& baseName);
|
||||
|
||||
// Derives the bank paths for one capture.
|
||||
// projectDir : absolute directory of the current .rpp (any slash style)
|
||||
// baseName : human base for the file stem (sanitized)
|
||||
// uniqueTag : caller-supplied disambiguator appended to the stem (e.g. a
|
||||
// timestamp or counter) so repeated captures do not collide.
|
||||
// Also sanitized. May be empty.
|
||||
// Produces "<stem>[_<tag>].wav". The relativePath is always project-relative and
|
||||
// forward-slashed so it satisfies BankModel::add's relative-only invariant.
|
||||
// Derives the bank paths for one capture: baseName is the sanitized file-stem
|
||||
// source, uniqueTag an optional sanitized disambiguator (timestamp/counter) so
|
||||
// repeated captures don't collide. Produces "<stem>[_<tag>].wav".
|
||||
BankPaths deriveBankPaths(const std::string& projectDir,
|
||||
const std::string& baseName,
|
||||
const std::string& uniqueTag);
|
||||
|
||||
// The project-relative index spelling for a bank file KNOWN ONLY by its file name —
|
||||
// the forward derivation the Phase R prune shell uses to spell an ENUMERATED folder
|
||||
// entry the SAME way deriveBankPaths spelled it at capture time. By construction it
|
||||
// is the identical expression deriveBankPaths().relativePath uses (kBankSubfolder +
|
||||
// "/" + fileName), so a file the capture path created and a directory listing of that
|
||||
// same file resolve to the byte-identical relative string — the safety-critical
|
||||
// spelling-consistency the prune core's exact-string match depends on (a divergence
|
||||
// here could make a referenced file look like an orphan). fileName is a bare entry
|
||||
// name (no directory component); the caller supplies forward-slash-free names from the
|
||||
// folder enumeration. Empty in -> empty out.
|
||||
// The project-relative index spelling for a bank file known only by its file
|
||||
// name (bare entry, no directory) — the prune shell uses this to spell an
|
||||
// enumerated folder entry the SAME way deriveBankPaths spelled it at capture
|
||||
// time; a divergence here could make a referenced file look like an orphan.
|
||||
std::string bankRelativeForName(const std::string& fileName);
|
||||
|
||||
// --- Persist-side path arithmetic (M4) --------------------------------------
|
||||
// --- Persist-side path arithmetic -------------------------------------------
|
||||
//
|
||||
// The index stores relative paths only; on project load the persist shell must
|
||||
// turn each entry's relativePath back into an absolute path against the CURRENT
|
||||
// project directory (so a project opened from a new location still resolves its
|
||||
// bank). This is the inverse of the relativePath the capture path produced.
|
||||
//
|
||||
// projectDir : absolute directory of the current .rpp (any slash style)
|
||||
// relativePath : a project-relative index entry (e.g. "reasampler_bank/x.wav")
|
||||
//
|
||||
// Returns "<projectDir>/<relativePath>" forward-slashed. Returns empty when
|
||||
// either input is empty (no default-location fallback — CLAUDE.md invariant) so
|
||||
// a caller that ignores an unsaved/unset project fails loudly rather than
|
||||
// resolving against CWD.
|
||||
// The index stores relative paths only; on project load the persist shell
|
||||
// turns each relativePath back into an absolute path against the current
|
||||
// project directory — the inverse of deriveBankPaths.
|
||||
|
||||
// Returns "<projectDir>/<relativePath>" forward-slashed, or empty if either
|
||||
// input is empty (no default-location fallback — an unsaved/unset project
|
||||
// fails loudly rather than resolving against CWD).
|
||||
std::string resolveBankFile(const std::string& projectDir,
|
||||
const std::string& relativePath);
|
||||
|
||||
// The project directory that holds a .rpp: its parent directory, forward-slashed,
|
||||
// trailing slash stripped. Empty in -> empty out (an unsaved project has an empty
|
||||
// .rpp path, which must stay empty so resolveBankFile refuses to resolve — the
|
||||
// no-default-location invariant). This is the M4 convention persist uses to place
|
||||
// the bank alongside the .rpp; extracted here (pure) so the VST3 instrument resolves
|
||||
// audio paths the SAME way persist does rather than re-implementing the derivation.
|
||||
// The project directory that holds a .rpp: parent directory, forward-slashed,
|
||||
// trailing slash stripped. Empty in -> empty out (an unsaved project reports
|
||||
// an empty .rpp path). Pure so the VST3 instrument resolves audio paths the
|
||||
// same way persist does.
|
||||
std::string projectDirOfRpp(const std::string& rppPath);
|
||||
|
||||
// A relocation plan for the physical bank folder on Save-As to a new project
|
||||
// location. The index's relative paths do NOT change (they are relative to the
|
||||
// project dir, which is what moved with the .rpp), so relocation is purely a
|
||||
// folder move: copy/move the whole bank subfolder from the old project dir to
|
||||
// the new one. Both dirs are absolute, forward-slashed, trailing-slash-stripped.
|
||||
// project dir, which moved with the .rpp), so relocation is purely a folder
|
||||
// move. Both dirs are absolute, forward-slashed, trailing-slash-stripped.
|
||||
struct BankRelocation {
|
||||
std::string oldBankDir; // <oldProjectDir>/reasampler_bank
|
||||
std::string newBankDir; // <newProjectDir>/reasampler_bank
|
||||
bool needed = false; // false when old==new (Save in place, not Save-As)
|
||||
};
|
||||
|
||||
// Derives the relocation plan from the old and new project directories.
|
||||
// oldProjectDir : project dir the bank currently sits under (any slash style)
|
||||
// newProjectDir : project dir the .rpp was just saved to (any slash style)
|
||||
// `needed` is true iff the normalized dirs differ (a genuine Save-As-to-new-dir).
|
||||
// Returns a plan with empty dirs and needed=false when either input is empty.
|
||||
// Derives the relocation plan: `needed` is true iff the normalized old/new
|
||||
// project dirs differ (a genuine Save-As-to-new-dir); empty dirs/needed=false
|
||||
// when either input is empty.
|
||||
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
|
||||
const std::string& newProjectDir);
|
||||
|
||||
// --- Project-identity transition (W12 combined identity fix) -----------------
|
||||
// --- Project-identity transition ---------------------------------------------
|
||||
//
|
||||
// What the persist timer must do on each tick. Identity rests on TWO facts,
|
||||
// layered GUID-PRIMARY:
|
||||
// 1. the minted GUID — content-based identity of record, stored in ext state.
|
||||
// It is IMMUNE to REAPER recycling a closed project's ReaProject* address,
|
||||
// so it is checked FIRST.
|
||||
// 2. sameProjectObject — did the same live ReaProject* stay active across the
|
||||
// two ticks (computed in poll() as `proj == lastProject_`)? Used ONLY to
|
||||
// disambiguate the same-GUID case: a forked sibling (Save-As copied our GUID
|
||||
// onto a distinct object) vs a genuine Save-As (one object, new path).
|
||||
//
|
||||
// This fix layers both prior designs, GUID-primary. M4 (GUID-only) broke Save-As
|
||||
// forks: Save-As copies the whole .rpp incl. our stored GUID, so a fork and its
|
||||
// parent share a GUID on disk. W10 (pointer-primary, GUID voided) broke pointer
|
||||
// RECYCLING: REAPER reuses a closed project's address, so a reopened/new project
|
||||
// can present the previous project's pointer with a different stored GUID —
|
||||
// pointer-primary read that as NoOp/SaveAsRelocate and the bank never reloaded.
|
||||
// Checking the GUID first catches recycling; the pointer then separates a fork
|
||||
// (same GUID, different object -> Load) from a Save-As (same GUID, same object,
|
||||
// new path -> relocate).
|
||||
//
|
||||
// The load-bearing rule: a DIFFERENT record identity (GUID) is always a Load; a
|
||||
// DIFFERENT project object with the same GUID is a fork Load, never a relocate.
|
||||
// What the persist timer must do on each tick. GUID is checked FIRST because
|
||||
// two prior pointer-primary/GUID-only designs each broke a real case: a
|
||||
// GUID-only check misreads a Save-As fork as the same project (fork and
|
||||
// parent share a GUID on disk); a pointer-primary check misreads REAPER
|
||||
// recycling a closed project's ReaProject* address onto an unrelated project
|
||||
// (a different project, same recycled pointer, read as NoOp/SaveAsRelocate —
|
||||
// the bank never reloads). Checking GUID first catches recycling; the pointer
|
||||
// (sameProjectObject) then separates a forked sibling (Load) from a genuine
|
||||
// Save-As (SaveAsRelocate).
|
||||
enum class ProjectTransition {
|
||||
NoOp, // same object, same GUID, same location — nothing to do
|
||||
Load, // a different project is active — load ITS index from ext state
|
||||
SaveAsRelocate, // SAME object + SAME GUID, new .rpp location — relocate the bank
|
||||
};
|
||||
|
||||
// Classifies what a poll tick observed.
|
||||
// sameProjectObject : true iff the SAME ReaProject* stayed active across the two
|
||||
// ticks (poll() computes `proj == lastProject_`). The pure
|
||||
// classifier takes the bool, not the raw pointer, to stay
|
||||
// REAPER-free and testable.
|
||||
// lastGuid : the GUID of the project persist last acted on ("" if none/unsaved)
|
||||
// lastPath : that project's .rpp path when last seen ("" if unsaved)
|
||||
// currentGuid : the GUID stored in the now-active project's ext state ("" if
|
||||
// unsaved or never written)
|
||||
// currentPath : the now-active project's .rpp path ("" if unsaved)
|
||||
//
|
||||
// Rules (evaluated in EXACTLY this order):
|
||||
// 1. currentGuid != lastGuid -> Load (different record identity:
|
||||
// recycled pointer w/ different GUID,
|
||||
// new/unsaved<->saved, or two distinct
|
||||
// saved projects)
|
||||
// 2. !sameProjectObject -> Load (same GUID, different object:
|
||||
// forked sibling, or two unsaved projects)
|
||||
// 3. currentPath != lastPath -> SaveAsRelocate (same object + same GUID,
|
||||
// new path: genuine Save-As, or first save
|
||||
// of an unsaved project — relocate no-ops
|
||||
// on the empty old dir, poll() mints a GUID)
|
||||
// 4. otherwise -> NoOp (same object, same GUID, same path)
|
||||
//
|
||||
// The GUID (identity of record) leads; the pointer only disambiguates the same-GUID
|
||||
// case (fork-Load in step 2 vs Save-As in step 3). The empty-GUID safety (unsaved
|
||||
// projects never physically relocate) is preserved because an empty old project dir
|
||||
// makes deriveRelocationPlan's `needed` false.
|
||||
// Classifies what a poll tick observed. sameProjectObject is passed as a bool
|
||||
// (not the raw pointer) to keep the classifier REAPER-free and testable;
|
||||
// lastGuid/lastPath is the project persist last acted on, currentGuid/
|
||||
// currentPath the now-active project (both "" if unsaved/unwritten).
|
||||
// Evaluated in order: currentGuid!=lastGuid -> Load; !sameProjectObject ->
|
||||
// Load (forked sibling); currentPath!=lastPath -> SaveAsRelocate (also covers
|
||||
// first save of an unsaved project); else NoOp.
|
||||
ProjectTransition classifyProjectTransition(bool sameProjectObject,
|
||||
const std::string& lastGuid,
|
||||
const std::string& lastPath,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// capture_realtime.cpp — pure logic for the realtime-record backend (M8). See
|
||||
// header. NO REAPER types; unit-tested by tests/test_capture_realtime.cpp.
|
||||
// (Renamed from realtime_record.cpp in Q-W3 — the Q-9 naming rider.)
|
||||
// capture_realtime.cpp — pure logic for the realtime-record backend. See header.
|
||||
// Unit-tested by tests/test_capture_realtime.cpp.
|
||||
|
||||
#include "core/capture/capture_realtime.h"
|
||||
|
||||
@@ -9,11 +8,8 @@ namespace reasampler::capture {
|
||||
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) {
|
||||
RecordModePlan p;
|
||||
|
||||
// Stereo vs mono output recording, latency-compensated either way so the
|
||||
// recorded file lines up with the source. A request asking for <= 1 channel
|
||||
// records mono-out; anything else records stereo-out. (Higher channel counts
|
||||
// still record stereo-out here — REAPER's output-record modes are mono/stereo
|
||||
// only; a >2-channel realtime capture is out of scope for this increment.)
|
||||
// REAPER's output-record modes are mono/stereo only; >2 channels still
|
||||
// records stereo-out (a >2-channel realtime capture is out of scope).
|
||||
p.recMode = (channelCount <= 1) ? kRecModeMonoOutLatComp
|
||||
: kRecModeStereoOutLatComp;
|
||||
|
||||
@@ -26,42 +22,32 @@ RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) {
|
||||
}
|
||||
|
||||
OutputTap outputTapForWetDry(double wetDry) {
|
||||
// Fully wet (1.0) taps post-fader; any dry-ward value taps pre-FX — the true
|
||||
// pre-FX dry that offline render cannot produce (the realtime backend's whole
|
||||
// reason to exist for the M10 null test). PostFxPreFader is an explicit future
|
||||
// option, not reachable from the wet/dry axis, so it is not returned here.
|
||||
return (wetDry >= 1.0) ? OutputTap::PostFader : OutputTap::PreFx;
|
||||
}
|
||||
|
||||
Sample sampleFromRecordedCapture(const RecordedCapture& cap) {
|
||||
Sample s;
|
||||
// Same id shape as the offline path: "cap-<tag>-<fileName>" would need the file
|
||||
// name; here the recorded file name is the tail of relativePath. Keep the id
|
||||
// stable + unique via the tag, and include the relative path tail so two
|
||||
// captures with the same tag (impossible in practice) still differ.
|
||||
// Relative path tail included so two same-tag captures (shouldn't happen) still differ.
|
||||
s.id = "cap-" + cap.uniqueTag + "-" + cap.relativePath;
|
||||
s.displayName = cap.displayName;
|
||||
s.relativePath = cap.relativePath; // project-relative (invariant)
|
||||
s.relativePath = cap.relativePath;
|
||||
s.sourceMode = cap.sourceMode;
|
||||
s.sourceRange.startSeconds = cap.startSeconds;
|
||||
s.sourceRange.endSeconds = cap.endSeconds;
|
||||
// PPQ/beats deferred (musical-placement concern) — identical to the offline path.
|
||||
// PPQ/beats deferred (musical-placement concern), as offline.
|
||||
s.wetDry = cap.wetDry;
|
||||
s.trackGuids = cap.trackGuids;
|
||||
s.channelCount = cap.channelCount;
|
||||
s.sampleRate = cap.sampleRate; // 0 when project rate was unknown
|
||||
s.lengthSeconds = cap.endSeconds - cap.startSeconds;
|
||||
s.captureTempo = cap.captureTempo;
|
||||
s.captureTimeSigNum = cap.captureTimeSigNum; // L7 F1 meter stamp (0/0 = unstamped)
|
||||
s.captureTimeSigNum = cap.captureTimeSigNum; // 0/0 = unstamped
|
||||
s.captureTimeSigDenom = cap.captureTimeSigDenom;
|
||||
s.tier = Tier::Scratch; // captures land in scratch by default
|
||||
// contentHash set by the caller (capture_realtime.cpp) after the file is
|
||||
// finalized and on disk — the hash is over the finished file bytes. Left empty
|
||||
// here because sampleFromRecordedCapture runs before the file exists (the
|
||||
// mapping is pure / DAW-free); the shell patches it in after the move+trim.
|
||||
// Phase S seam fields (rootNote / loop) left empty (D-B) — same reasoning as the
|
||||
// offline path: a realtime record of wet output is not a single played note, so
|
||||
// no root note is derivable; loop points are set by a later explicit action.
|
||||
s.tier = Tier::Scratch;
|
||||
// contentHash is left empty: this mapping runs before the file exists on
|
||||
// disk; the shell patches the hash in after the move+trim.
|
||||
// rootNote/loop left empty: a realtime record of wet output isn't a single
|
||||
// played note, so no root note is derivable; loop points are a later action.
|
||||
s.createdTimestamp = cap.createdTimestamp;
|
||||
return s;
|
||||
}
|
||||
@@ -72,20 +58,15 @@ RecordPhase advanceRecordPhase(RecordPhase current,
|
||||
double rangeEndSeconds) {
|
||||
switch (current) {
|
||||
case RecordPhase::Recording: {
|
||||
// Transport stopped while we still expected to be recording -> the user
|
||||
// (or REAPER) stopped early. Move to the flush wait and finalize whatever
|
||||
// was captured up to the stop.
|
||||
// Stopped early (user or REAPER) -> finalize what was captured so far.
|
||||
if (!inputs.transport.recording) return RecordPhase::Finalizing;
|
||||
|
||||
// Reached the range end (latency-compensated play position). >= (not >)
|
||||
// so a cursor landing exactly on the end completes.
|
||||
// >= (not >): a cursor landing exactly on the end completes.
|
||||
if (inputs.transport.playPosition >= rangeEndSeconds)
|
||||
return RecordPhase::Finalizing;
|
||||
|
||||
// Self-defense (review §3): the transport is running but the play cursor
|
||||
// is not advancing to the end (stuck / looping). Without this the machine
|
||||
// stays in Recording forever, leaking the temp track + armed sink. Force
|
||||
// the flush wait once wall-clock exceeds the nominal duration + margin.
|
||||
// Self-defense: a stuck/looping transport that never reaches end would
|
||||
// otherwise stay in Recording forever, leaking the temp track + armed sink.
|
||||
const double ceiling =
|
||||
(rangeEndSeconds - rangeStartSeconds) + kRecordMarginSeconds;
|
||||
if (inputs.elapsedSeconds > ceiling) return RecordPhase::Finalizing;
|
||||
@@ -94,22 +75,16 @@ RecordPhase advanceRecordPhase(RecordPhase current,
|
||||
}
|
||||
|
||||
case RecordPhase::Finalizing: {
|
||||
// The transport is stopped; wait for REAPER to flush/close the recorded
|
||||
// take on the audio thread. Finalize (move + Sample) only once the file
|
||||
// exists AND is stable (review §2) — moving it early races the flush and
|
||||
// yields a truncated / missing capture.
|
||||
// Moving the file before it's stable would race REAPER's flush and
|
||||
// yield a truncated/missing capture.
|
||||
if (inputs.fileReady) return RecordPhase::Done;
|
||||
|
||||
// Bound the wait: a file that never stabilizes fails cleanly rather than
|
||||
// hanging the in-flight state for the session.
|
||||
if (inputs.finalizingSeconds > kFinalizeFlushCeilingSeconds)
|
||||
return RecordPhase::Failed;
|
||||
|
||||
return RecordPhase::Finalizing;
|
||||
}
|
||||
|
||||
// Terminal phases are sticky: once the verdict is in, a later tick (a stray
|
||||
// extra call before the shell has finished tearing down) must not flip it.
|
||||
case RecordPhase::Done:
|
||||
case RecordPhase::Failed:
|
||||
default:
|
||||
|
||||
@@ -1,27 +1,11 @@
|
||||
#pragma once
|
||||
// capture_realtime — the REAPER-free logic behind the realtime-record backend (M8).
|
||||
// (Renamed from realtime_record in Q-W3 — the Q-9 naming rider: the PURE module
|
||||
// takes the stem, the shell takes the suffix — capture_realtime_shell.cpp /
|
||||
// capture_realtime_finalize.cpp — matching the drag_out ↔ drag_out_win model.)
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. The realtime shell drives the
|
||||
// transport, the temp track, the send routing, and the file move —
|
||||
// all REAPER-bound and DAW-verified. The genuinely pure, easy-to-get-wrong
|
||||
// pieces are split out here and unit-tested outside the DAW:
|
||||
//
|
||||
// 1. the record-mode/recipe bookkeeping: given a capture scope + a desired
|
||||
// FX-tap point (post-fader / pre-FX / post-FX-pre-fader), the I_RECMODE and
|
||||
// I_RECMODE_FLAGS integer values the temp track must carry.
|
||||
// 2. the recorded-file -> Sample mapping: given a finished capture (the
|
||||
// recorded file's project-relative path + the request's own bounds/format),
|
||||
// the populated Sample handed to bank_model. Mirrors the inline Sample
|
||||
// population OfflineRenderBackend does — factored out so it is tested once,
|
||||
// without a DAW, and shared shape with the offline path is guaranteed.
|
||||
//
|
||||
// The I_RECMODE / I_RECMODE_FLAGS bit MEANINGS are transcribed verbatim from
|
||||
// reaper_plugin_functions.h line ~2197-2198 (see kRecMode* constants); the CHOICE
|
||||
// of which values each scope needs is this module's logic and is tested.
|
||||
// capture_realtime — the REAPER-free logic behind the realtime-record backend.
|
||||
// The shell drives the transport, temp track, send routing, and file move; the
|
||||
// pure pieces split out here and unit-tested outside the DAW are: (1) record-
|
||||
// mode bookkeeping — scope + FX-tap point -> I_RECMODE/I_RECMODE_FLAGS values
|
||||
// (bit MEANINGS transcribed verbatim from reaper_plugin_functions.h ~2197-2198;
|
||||
// the CHOICE of value per scope is this module's tested logic) — and (2) the
|
||||
// recorded-file -> Sample mapping (mirrors OfflineRenderBackend's population).
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
@@ -35,26 +19,17 @@ using model::Sample;
|
||||
using model::Tier;
|
||||
using model::SourceMode;
|
||||
|
||||
// --- I_RECMODE values (verbatim from SDK header ~2197) -----------------------
|
||||
//
|
||||
// I_RECMODE : int * : record mode, 0=input, 1=stereo out, 2=none,
|
||||
// 3=stereo out w/latency compensation, 4=midi output, 5=mono out,
|
||||
// 6=mono out w/ latency compensation, 7=midi overdub, 8=midi replace.
|
||||
//
|
||||
// We record a track's OUTPUT (the scoped signal routed into the temp track),
|
||||
// latency-compensated, so the recorded file lines up sample-accurately with the
|
||||
// source. Stereo vs mono is chosen by the request's channel count.
|
||||
// I_RECMODE (verbatim from SDK header ~2197): 0=input, 1=stereo out, 2=none,
|
||||
// 3=stereo out w/latency comp, 4=midi output, 5=mono out, 6=mono out w/latency
|
||||
// comp, 7=midi overdub, 8=midi replace. We record a track's OUTPUT, latency-
|
||||
// compensated, so the recorded file lines up sample-accurately with the source.
|
||||
inline constexpr int kRecModeStereoOutLatComp = 3; // stereo out w/latency comp
|
||||
inline constexpr int kRecModeMonoOutLatComp = 6; // mono out w/latency comp
|
||||
|
||||
// --- I_RECMODE_FLAGS values (verbatim from SDK header ~2198) ------------------
|
||||
//
|
||||
// I_RECMODE_FLAGS : int * : record mode flags, &3=output recording mode
|
||||
// (0=post fader, 1=pre-fx, 2=post-fx/pre-fader).
|
||||
//
|
||||
// This is the ONLY documented pre-FX tap in the whole SDK — offline render has no
|
||||
// pre-FX bit (see render_settings.h note + the M10 null-test note in PLAN.md).
|
||||
// The realtime backend is therefore the true pre-FX "dry" path.
|
||||
// I_RECMODE_FLAGS (verbatim from SDK header ~2198): &3=output recording mode
|
||||
// (0=post fader, 1=pre-fx, 2=post-fx/pre-fader). This is the only documented
|
||||
// pre-FX tap in the SDK — offline render has no pre-FX bit — so the realtime
|
||||
// backend is the true pre-FX "dry" path.
|
||||
inline constexpr int kRecOutPostFader = 0; // &3==0: post-fader (fully wet)
|
||||
inline constexpr int kRecOutPreFx = 1; // &3==1: pre-FX (true dry)
|
||||
inline constexpr int kRecOutPostFxPreFader = 2; // &3==2: post-FX, pre-fader
|
||||
@@ -68,41 +43,34 @@ enum class OutputTap {
|
||||
};
|
||||
|
||||
// The concrete record-mode values a temp track must carry to capture the scoped
|
||||
// output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated); `recModeFlags`
|
||||
// sets the &3 output-recording tap bits (higher bits are left at their default 0
|
||||
// here — we only own the tap-point bits).
|
||||
// output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated);
|
||||
// `recModeFlags` sets the &3 output-recording tap bits (we only own those bits).
|
||||
struct RecordModePlan {
|
||||
int recMode = kRecModeStereoOutLatComp;
|
||||
int recModeFlags = kRecOutPostFader;
|
||||
};
|
||||
|
||||
// Maps (channelCount, tap) to the record-mode values.
|
||||
// channelCount <= 1 -> mono-out latency-comp; otherwise stereo-out latency-comp.
|
||||
// tap -> the &3 output-recording bits.
|
||||
// Pure so the "which I_RECMODE for N channels + this tap" rule is unit-tested
|
||||
// without a DAW; the shell reads the request and applies these via
|
||||
// SetMediaTrackInfo_Value(I_RECMODE / I_RECMODE_FLAGS).
|
||||
// Maps (channelCount, tap) to the record-mode values: channelCount <= 1 ->
|
||||
// mono-out latency-comp, else stereo-out; tap -> the &3 bits. The shell applies
|
||||
// these via SetMediaTrackInfo_Value(I_RECMODE / I_RECMODE_FLAGS).
|
||||
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap);
|
||||
|
||||
// Maps a wetDry value to the output tap point. 1.0 (fully wet) -> PostFader; any
|
||||
// value < 1.0 -> PreFx (true dry — the realtime backend's distinguishing
|
||||
// capability). Kept pure + separate from recordModePlanFor so the wet/dry ->
|
||||
// tap decision is tested on its own; PostFxPreFader is not selected by wetDry
|
||||
// (it is an explicit future option, not on the wet/dry axis).
|
||||
// Maps a wetDry value to the output tap point: 1.0 (fully wet) -> PostFader,
|
||||
// anything less -> PreFx (true dry — the realtime backend's distinguishing
|
||||
// capability over offline render). PostFxPreFader is not reachable from wetDry.
|
||||
OutputTap outputTapForWetDry(double wetDry);
|
||||
|
||||
// --- Recorded-file -> Sample mapping ----------------------------------------
|
||||
//
|
||||
|
||||
// The inputs a finished realtime capture yields, gathered by the shell into a
|
||||
// pure struct so the Sample population is a single tested transform (mirror of
|
||||
// the inline population in OfflineRenderBackend::capture).
|
||||
// pure struct so Sample population is a single tested transform (mirrors the
|
||||
// inline population in OfflineRenderBackend::capture).
|
||||
struct RecordedCapture {
|
||||
// Project-relative path of the recorded file (relative-paths-only invariant;
|
||||
// the shell resolves REAPER's recorded absolute path back to project-relative).
|
||||
// Project-relative path of the recorded file (the shell resolves REAPER's
|
||||
// absolute path back to project-relative).
|
||||
std::string relativePath;
|
||||
|
||||
// The disambiguating tag that named the file (feeds the Sample id, so id and
|
||||
// file name stay consistent — same discipline as the offline path).
|
||||
// The disambiguating tag that named the file (feeds the Sample id).
|
||||
std::string uniqueTag;
|
||||
|
||||
// Echoed from the request (exact bounds — no re-measuring the file).
|
||||
@@ -115,60 +83,41 @@ struct RecordedCapture {
|
||||
|
||||
int channelCount = 0;
|
||||
|
||||
// TEST-ONLY / dead in production (Q-W3 review follow-up): the shell no longer
|
||||
// populates these five fields before calling sampleFromRecordedCapture — the
|
||||
// finalize path (capture_realtime_finalize.cpp) leaves them at their defaults
|
||||
// and instead calls the shared stampCaptureSample(result.sample, ...) right
|
||||
// after, which writes Sample::sampleRate/captureTempo/captureTimeSigNum/
|
||||
// captureTimeSigDenom/createdTimestamp directly, overwriting whatever
|
||||
// sampleFromRecordedCapture set from these. Kept (not deleted) because the pure
|
||||
// unit tests still construct/assert them directly; removing the fields is a
|
||||
// struct-shape decision out of scope here.
|
||||
// Left at defaults here — capture_realtime_finalize.cpp calls
|
||||
// stampCaptureSample(result.sample, ...) afterward, overwriting these five
|
||||
// from the live project. Kept because the pure unit tests still assert them.
|
||||
int sampleRate = 0; // 0 when the project rate was unknown (as offline)
|
||||
double captureTempo = 0.0; // BPM at capture time (shell reads Master_GetTempo)
|
||||
// Time signature at capture start (L7 F1; shell reads TimeMap_GetTimeSigAtTime).
|
||||
// 0/0 = unstamped (matches the Sample default; formatter renders a blank read-out).
|
||||
int captureTimeSigNum = 0;
|
||||
double captureTempo = 0.0; // BPM at capture time
|
||||
int captureTimeSigNum = 0; // 0/0 = unstamped
|
||||
int captureTimeSigDenom = 0;
|
||||
std::int64_t createdTimestamp = 0; // unix epoch seconds (shell reads the clock)
|
||||
std::int64_t createdTimestamp = 0; // unix epoch seconds
|
||||
};
|
||||
|
||||
// Builds the Sample for a finished realtime capture. Deliberately identical in
|
||||
// shape to OfflineRenderBackend's population: exact request bounds (no rounding),
|
||||
// scratch tier, empty content hash (does not dedup), lengthSeconds = end - start.
|
||||
// PPQ/beats are left 0 (a musical-placement concern deferred exactly as offline).
|
||||
// Builds the Sample for a finished realtime capture: exact request bounds,
|
||||
// scratch tier, empty content hash, lengthSeconds = end - start. PPQ/beats
|
||||
// left 0 (deferred, as offline).
|
||||
Sample sampleFromRecordedCapture(const RecordedCapture& cap);
|
||||
|
||||
// --- Async record-phase state machine (M8 rework) ----------------------------
|
||||
// --- Async record-phase state machine ----------------------------------------
|
||||
//
|
||||
// A realtime record spans many timer ticks (CSurf_OnRecord starts the transport on
|
||||
// REAPER's audio thread and returns immediately — it does NOT block until the range
|
||||
// completes). The completion decision — "given where the transport is now, should
|
||||
// the tick keep waiting, stop-and-flush, finalize, or give up?" — is pure and
|
||||
// exactly the kind of off-by-one/edge logic a unit test locks without a DAW. It is
|
||||
// factored out here; the REAPER shell only reads the transport/clock/file and applies
|
||||
// the verdict (stop, wait for the file to flush, then finalize/abort + restore).
|
||||
// A realtime record spans many timer ticks (CSurf_OnRecord starts the transport
|
||||
// on REAPER's audio thread and returns immediately — it does not block until the
|
||||
// range completes). The completion decision — keep waiting, stop-and-flush,
|
||||
// finalize, or give up — is pure and unit-tested without a DAW; the shell only
|
||||
// reads the transport/clock/file and applies the verdict.
|
||||
//
|
||||
// The lifecycle has TWO waits, not one:
|
||||
// 1. the RECORD wait (Recording): the transport is running; we wait for the play
|
||||
// cursor to reach the range end — OR the user stops early — OR a wall-clock
|
||||
// safety ceiling trips (a started-but-never-advancing transport, §3 of review).
|
||||
// 2. the FLUSH wait (Finalizing): the transport is stopped but REAPER closes/flushes
|
||||
// the recorded take on the AUDIO thread — the file may not be fully written/closed
|
||||
// for a tick or two. We defer the file move until the file exists AND is stable
|
||||
// (§2 of review), bounded by a flush ceiling so a file that never appears fails
|
||||
// cleanly rather than hanging.
|
||||
// Two waits, not one:
|
||||
// 1. RECORD wait (Recording): transport running; wait for the play cursor to
|
||||
// reach the range end, OR the user stops early, OR a wall-clock safety
|
||||
// ceiling trips (a started-but-never-advancing transport).
|
||||
// 2. FLUSH wait (Finalizing): transport stopped but REAPER closes/flushes the
|
||||
// recorded take on the audio thread — the file may lag a tick or two.
|
||||
// Defer the move until the file exists AND is stable, bounded by a flush
|
||||
// ceiling so a file that never appears fails cleanly instead of hanging.
|
||||
|
||||
// Where an in-progress capture is in its lifecycle.
|
||||
// Recording — live: transport running, shell keeps ticking.
|
||||
// Finalizing — live-but-stopped: transport halted, shell stops the transport once
|
||||
// then ticks waiting for the recorded file to flush/stabilize.
|
||||
// Done — terminal: the file is flushed + stable, finalize (move + Sample) now.
|
||||
// Failed — terminal: the flush ceiling tripped without a stable file — give up
|
||||
// (RenderFailed) + restore. (A record that produced NO file at all also
|
||||
// lands here via the shell's finalize returning RenderFailed.)
|
||||
// Only Recording and Finalizing are live phases the shell advances per tick; Done and
|
||||
// Failed are the shell's verdict to act on (finalize-or-fail, then restore).
|
||||
// Where an in-progress capture is in its lifecycle: Recording (live, transport
|
||||
// running) and Finalizing (live-but-stopped, waiting for flush) are the two
|
||||
// waits above; Done/Failed are terminal — the shell's verdict to act on.
|
||||
enum class RecordPhase {
|
||||
Recording,
|
||||
Finalizing,
|
||||
@@ -176,63 +125,34 @@ enum class RecordPhase {
|
||||
Failed
|
||||
};
|
||||
|
||||
// A distilled transport reading for the pure transition, so the state machine never
|
||||
// touches a REAPER type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition`
|
||||
// is GetPlayPositionEx (latency-compensated what-you-hear position).
|
||||
// A distilled transport reading so the state machine never touches a REAPER
|
||||
// type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition` is
|
||||
// GetPlayPositionEx (latency-compensated).
|
||||
struct TransportReading {
|
||||
bool recording = false;
|
||||
double playPosition = 0.0;
|
||||
};
|
||||
|
||||
// Everything the pure transition needs beyond the current phase, gathered by the
|
||||
// shell each tick so the machine stays REAPER-free AND owns every timing/ceiling
|
||||
// decision (the shell only reads and reports; it never decides a transition itself).
|
||||
// Everything the pure transition needs beyond the current phase, gathered by
|
||||
// the shell each tick (the shell only reads and reports; never decides).
|
||||
struct RecordTickInputs {
|
||||
TransportReading transport;
|
||||
|
||||
// Wall-clock seconds since begin() (the shell reads a steady clock). Drives the
|
||||
// record safety ceiling: a transport that starts but never advances to the range
|
||||
// end (stuck / looping) would otherwise keep the machine in Recording forever.
|
||||
double elapsedSeconds = 0.0;
|
||||
|
||||
// Wall-clock seconds spent in the Finalizing phase (since the transport stop).
|
||||
// Drives the flush ceiling: bound the deferred-finalize wait so a file that never
|
||||
// stabilizes fails cleanly instead of hanging.
|
||||
double finalizingSeconds = 0.0;
|
||||
|
||||
// Whether the recorded take's file exists AND is stable/closed this tick (the
|
||||
// shell resolves the take source path and checks size-stable-across-a-tick).
|
||||
// Only consulted in Finalizing.
|
||||
bool fileReady = false;
|
||||
double elapsedSeconds = 0.0; // wall-clock since begin() — record ceiling
|
||||
double finalizingSeconds = 0.0; // wall-clock in Finalizing — flush ceiling
|
||||
bool fileReady = false; // recorded file exists+stable (Finalizing only)
|
||||
};
|
||||
|
||||
// --- Safety ceilings (named constants, review §2/§3) -------------------------
|
||||
//
|
||||
// kRecordMarginSeconds: added to the record's nominal duration (end - start) to form
|
||||
// the record wall-clock ceiling. Generous so a normal record (with pre-roll, count-in,
|
||||
// or transport latency) never trips it; tight enough that a stuck transport is force-
|
||||
// terminated within a few seconds of overrun.
|
||||
// Record ceiling margin added to nominal duration: generous enough that
|
||||
// pre-roll/count-in/latency never trips it, tight enough a stuck transport is
|
||||
// force-terminated within seconds.
|
||||
inline constexpr double kRecordMarginSeconds = 5.0;
|
||||
|
||||
// kFinalizeFlushCeilingSeconds: the max wall-clock the Finalizing phase waits for the
|
||||
// recorded file to flush/stabilize before giving up (RenderFailed). REAPER closes the
|
||||
// take on the audio thread within a tick or two in practice; this is a generous bound.
|
||||
// Max wall-clock Finalizing waits for the file to flush/stabilize before
|
||||
// giving up (REAPER closes the take within a tick or two in practice).
|
||||
inline constexpr double kFinalizeFlushCeilingSeconds = 5.0;
|
||||
|
||||
// The pure transition: given the current phase, this tick's inputs, and the record
|
||||
// range end, return the next phase. Total + deterministic.
|
||||
//
|
||||
// From Recording:
|
||||
// * recording AND cursor < end AND under the record ceiling -> Recording (wait)
|
||||
// * recording AND cursor >= end -> Finalizing (reached end)
|
||||
// * NOT recording -> Finalizing (stopped early)
|
||||
// * recording BUT over the record ceiling (end-start+margin)-> Finalizing (stuck: forced)
|
||||
// From Finalizing:
|
||||
// * fileReady -> Done (flushed + stable)
|
||||
// * over the flush ceiling without a stable file -> Failed (give up)
|
||||
// * otherwise -> Finalizing (keep flushing)
|
||||
// Done and Failed are sticky: feeding a terminal phase back returns it unchanged, so a
|
||||
// late tick before teardown finishes cannot flip the verdict (the idempotence the
|
||||
// The pure transition (total + deterministic). Done/Failed are sticky — a late
|
||||
// tick before teardown finishes cannot flip the verdict (the idempotence the
|
||||
// shell's single-restore relies on).
|
||||
RecordPhase advanceRecordPhase(RecordPhase current,
|
||||
const RecordTickInputs& inputs,
|
||||
|
||||
@@ -7,14 +7,14 @@ namespace reasampler::capture {
|
||||
namespace {
|
||||
|
||||
// Base target bits (mode&3). We use only 0 (current track) and 1 (new track).
|
||||
constexpr int kBaseCurrentTrack = 0; // add to current track
|
||||
constexpr int kBaseNewTrack = 1; // add new track
|
||||
constexpr int kBaseCurrentTrack = 0;
|
||||
constexpr int kBaseNewTrack = 1;
|
||||
|
||||
// Tempo-conform bits, verbatim from the header doc-comment.
|
||||
constexpr int kMatchTempo1x = 8; // &8: try to match tempo 1x
|
||||
constexpr int kMatchTempoHalf = 16; // &16: try to match tempo 0.5x
|
||||
constexpr int kMatchTempoDbl = 32; // &32: try to match tempo 2x
|
||||
constexpr int kDontPreservePitch = 64; // &64: don't preserve pitch when matching tempo
|
||||
constexpr int kMatchTempo1x = 8;
|
||||
constexpr int kMatchTempoHalf = 16;
|
||||
constexpr int kMatchTempoDbl = 32;
|
||||
constexpr int kDontPreservePitch = 64;
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -24,8 +24,7 @@ int computeInsertMode(const InsertOptions& opts) {
|
||||
|
||||
switch (opts.conform) {
|
||||
case TempoConform::None:
|
||||
// No tempo bits: native length, no stretch. (Also never &4.)
|
||||
return mode;
|
||||
return mode; // native length, no stretch; never &4
|
||||
case TempoConform::Ratio1x:
|
||||
mode |= kMatchTempo1x;
|
||||
break;
|
||||
@@ -37,9 +36,7 @@ int computeInsertMode(const InsertOptions& opts) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Tempo bits are set (conform != None). Add the pitch-shift bit only when the
|
||||
// caller asked NOT to preserve pitch. When conform == None we already returned
|
||||
// above, so this can never fire without a tempo bit present.
|
||||
// Reached only when a tempo bit is set (None already returned above).
|
||||
if (!opts.preservePitch)
|
||||
mode |= kDontPreservePitch;
|
||||
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
#pragma once
|
||||
// insert_plan — the REAPER-free logic behind the `insert` shell (M6): computing
|
||||
// the InsertMedia `mode` bitmask from a small options struct.
|
||||
// insert_plan — the REAPER-free logic behind the `insert` shell: computing the
|
||||
// InsertMedia `mode` bitmask from a small options struct.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. The one genuinely testable-outside-DAW
|
||||
// piece of insert is the mode-bit arithmetic — the InsertMedia bitfield is easy to
|
||||
// get wrong and its bits are load-bearing for the "no silent time-stretch"
|
||||
// invariant, so it is factored here and unit-tested. The REAPER-bound placement
|
||||
// (InsertMedia call, edit-cursor movement, undo block) lives in insert.cpp and is
|
||||
// DAW-verified.
|
||||
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
||||
// only. The InsertMedia bitfield is easy to get wrong and its bits are
|
||||
// load-bearing for the "no silent time-stretch" invariant, so it's factored here
|
||||
// and unit-tested. The REAPER-bound placement (InsertMedia call, edit-cursor
|
||||
// movement, undo block) lives in insert.cpp and is DAW-verified.
|
||||
//
|
||||
// The bit meanings below are transcribed VERBATIM from the authoritative header
|
||||
// Bit meanings below are transcribed VERBATIM from the authoritative header
|
||||
// doc-comment (vendor/reaper-sdk/sdk/reaper_plugin_functions.h, InsertMedia):
|
||||
// mode: 0=add to current track, 1=add new track, 3=add to selected items as
|
||||
// takes, &4=stretch/loop to fit time sel, &8=try to match tempo 1x,
|
||||
// &16=try to match tempo 0.5x, &32=try to match tempo 2x,
|
||||
// &64=don't preserve pitch when matching tempo, ...
|
||||
// We intentionally use only the base target (0/1) and the tempo-conform bits
|
||||
// (&8/&16/&32/&64). We NEVER set &4 (stretch/loop to fit time selection) — that is
|
||||
// the silent-time-stretch path the tool forbids (CONTEXT.md §Non-goals).
|
||||
// We use only the base target (0/1) and the tempo-conform bits (&8/&16/&32/&64).
|
||||
// We NEVER set &4 (stretch/loop to fit time selection) — the silent-time-stretch
|
||||
// path the tool forbids.
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// Where InsertMedia drops the item. Maps to the low bits of `mode` (mode&3).
|
||||
// We expose only the two placement targets M6 needs; "add as takes" (3) is a
|
||||
// later concern (YAGNI). Both insert AT THE EDIT CURSOR — that is REAPER's
|
||||
// convention for base modes 0/1 (the header names no explicit edit-cursor bit;
|
||||
// see the flagged runtime assumption in insert.cpp).
|
||||
// We expose only the two placement targets needed here; "add as takes" (3) is
|
||||
// out of scope. Both insert at the edit cursor — REAPER's convention for base
|
||||
// modes 0/1 (the header names no explicit edit-cursor bit; see the flagged
|
||||
// runtime assumption in insert.cpp).
|
||||
enum class InsertTarget {
|
||||
NewTrack, // mode base 1: add a new track for the item
|
||||
CurrentTrack, // mode base 0: add to the current/selected track
|
||||
|
||||
@@ -10,9 +10,7 @@
|
||||
namespace reasampler::capture {
|
||||
|
||||
double autoTrimEndRatio() {
|
||||
// Amplitude ratio = 10^(dB/20). Derived from kAutoTrimThresholdDb so the dB is
|
||||
// the single source of truth (header ~3062: RENDER_TRIMEND is an amplitude ratio,
|
||||
// "0.5 means -6.02 dB"). For -72 dB this is ~= 0.00025119.
|
||||
// Amplitude ratio = 10^(dB/20) (header ~3062). For -72 dB this is ~0.00025119.
|
||||
return std::pow(10.0, kAutoTrimThresholdDb / 20.0);
|
||||
}
|
||||
|
||||
@@ -20,8 +18,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
|
||||
TailRenderSettings t;
|
||||
switch (mode) {
|
||||
case TailMode::None:
|
||||
// Exact bounds — byte-identical to the pre-tail no-tail capture. Tail off,
|
||||
// disable-all normalize (the current default), no trim.
|
||||
// Exact bounds — byte-identical to the pre-tail capture.
|
||||
t.tailFlag = kTailFlagNone;
|
||||
t.tailMs = 0.0;
|
||||
t.normalize = kNormalizeDisableAll;
|
||||
@@ -29,12 +26,10 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
|
||||
return t;
|
||||
|
||||
case TailMode::Auto:
|
||||
// Generous 8 s tail, then SURGICAL normalize: ONLY the trim-ending-silence
|
||||
// bit (32768) — every other postprocessing bit clear. A fixed-threshold
|
||||
// trailing-silence trim is a pure boundary decision (it scales/limits/fades
|
||||
// nothing), so it re-introduces none of the coloring the disable-all bit
|
||||
// guarded against, and two identical requests trim at the identical sample
|
||||
// -> bit-identical repeats hold (spec §surgical normalize).
|
||||
// Surgical normalize: only the trim-ending-silence bit set, every other
|
||||
// postprocessing bit clear. A fixed-threshold trim scales/limits/fades
|
||||
// nothing, so identical requests trim at the identical sample -> holds
|
||||
// the bit-identical-repeats invariant.
|
||||
t.tailFlag = kTailFlagCustomBounds;
|
||||
t.tailMs = kMaxTailMs;
|
||||
t.normalize = kNormalizeTrimEnd;
|
||||
@@ -42,10 +37,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
|
||||
return t;
|
||||
|
||||
case TailMode::Manual:
|
||||
// Fixed tail, no trim -> keep the disable-all normalize exactly as the
|
||||
// no-tail path does. Clamp to the 8 s cap even here: the runaway guard
|
||||
// applies whether the length came from the Auto default or an explicit
|
||||
// request (spec §Manual override). Negative requests floor to 0.
|
||||
// Clamped to the cap regardless of source; negative floors to 0.
|
||||
t.tailFlag = kTailFlagCustomBounds;
|
||||
t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs);
|
||||
t.normalize = kNormalizeDisableAll;
|
||||
@@ -60,14 +52,10 @@ double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
|
||||
double manualTailMs) {
|
||||
switch (mode) {
|
||||
case TailMode::None:
|
||||
// Exact — no extra recording (byte-identical to today's realtime capture).
|
||||
return rangeEndSeconds;
|
||||
return rangeEndSeconds; // exact, no extra recording
|
||||
case TailMode::Auto:
|
||||
// The 8 s runaway cap past the range end; the decay-trim shortens it later.
|
||||
return rangeEndSeconds + kMaxTailSeconds;
|
||||
return rangeEndSeconds + kMaxTailSeconds; // runaway cap; decay-trim shortens later
|
||||
case TailMode::Manual:
|
||||
// Fixed window: range + the set length, clamped to the 8 s cap (the same
|
||||
// runaway guard the offline Manual path applies). Negative floors to 0.
|
||||
return rangeEndSeconds + std::clamp(manualTailMs, 0.0, kMaxTailMs) / 1000.0;
|
||||
}
|
||||
// Unreachable for a valid enum; fail closed to exact bounds (never a stray tail).
|
||||
@@ -75,42 +63,36 @@ double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
|
||||
}
|
||||
|
||||
RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
||||
// `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future
|
||||
// dry work (M10 null test), but it does not affect this mapping. FX scoping is
|
||||
// handled by fxBypassPlanFor, not by these render bits.
|
||||
// wetDry doesn't affect this mapping (seam for future dry work); FX scoping
|
||||
// is handled by fxBypassPlanFor, not by these render bits.
|
||||
RenderSettingsChoice c;
|
||||
|
||||
switch (mode) {
|
||||
case SourceMode::MasterMix:
|
||||
case SourceMode::TimeSelection:
|
||||
// Master IS the mix — wet-only; &(1|2)==0, no source bits.
|
||||
c.settings = kRenderMasterMix;
|
||||
c.settings = kRenderMasterMix; // wet-only, no source bits
|
||||
c.supported = true;
|
||||
return c;
|
||||
|
||||
case SourceMode::SelectedTracks:
|
||||
// Selected tracks via master (&128) — wet (post-FX). Header ~3041.
|
||||
c.settings = kRenderSelTracksViaMaster;
|
||||
c.supported = true;
|
||||
return c;
|
||||
|
||||
case SourceMode::SelectedItems:
|
||||
// Selected media items, rendered to ONE file (single-file bit) so a
|
||||
// multi-item selection yields a single bank entry, not N wavs.
|
||||
// Single-file bit so a multi-item selection yields one bank entry.
|
||||
c.settings = kRenderSelItems | kRenderSingleFile;
|
||||
c.supported = true;
|
||||
return c;
|
||||
|
||||
case SourceMode::RazorArea:
|
||||
// Render razor edits to ONE file (same single-file rationale as items).
|
||||
c.settings = kRenderRazorEdits | kRenderSingleFile;
|
||||
c.supported = true;
|
||||
return c;
|
||||
|
||||
case SourceMode::Realtime:
|
||||
// Not an offline-render source — the realtime backend (M8) owns it.
|
||||
c.settings = kRenderMasterMix;
|
||||
c.supported = false;
|
||||
c.supported = false; // not an offline-render source
|
||||
return c;
|
||||
}
|
||||
// Unreachable for a valid enum; fail closed (unsupported) rather than render.
|
||||
@@ -135,17 +117,13 @@ FxBypassPlan fxBypassPlanFor(CaptureScope scope) {
|
||||
FxBypassPlan p;
|
||||
switch (scope) {
|
||||
case CaptureScope::Item:
|
||||
// Item = take/item FX ONLY. Bypass the item's own track FX, every
|
||||
// ancestor's FX, and the master's FX. (Take FX live in the item and
|
||||
// are always rendered — there is no track to bypass them from.)
|
||||
// Take FX live in the item and are always rendered — bypass everything else.
|
||||
p.bypassSelfFx = true;
|
||||
p.bypassAncestorFx = true;
|
||||
p.bypassMaster = true;
|
||||
return p;
|
||||
case CaptureScope::Track:
|
||||
// Track = item FX + the selected track's OWN FX. Keep self FX; bypass
|
||||
// every ancestor (parent/folder) and the master. Parent/master GAIN
|
||||
// still applies (I_FXEN is FX-only) — documented boundary.
|
||||
// Keep self FX; bypass every ancestor (parent/folder) and the master.
|
||||
p.bypassSelfFx = false;
|
||||
p.bypassAncestorFx = true;
|
||||
p.bypassMaster = true;
|
||||
@@ -158,24 +136,19 @@ std::vector<RazorRange> parseRazorEdits(const std::string& razorString) {
|
||||
std::vector<RazorRange> ranges;
|
||||
std::istringstream in(razorString);
|
||||
|
||||
// The string is space-separated TRIPLES: <start> <end> <envGuidString>.
|
||||
// A track-audio area's third token is the literal two-char string `""`; an
|
||||
// envelope-lane area's is a GUID `{…}`. We keep only track-audio triples.
|
||||
std::string startTok, endTok, guidTok;
|
||||
while (in >> startTok >> endTok >> guidTok) {
|
||||
// Envelope-lane areas carry a real GUID; skip them (razor captures track audio only).
|
||||
// A track-audio area's GUID token is the empty quoted string `""`.
|
||||
// Skip envelope-lane areas (real GUID); keep only track-audio (`""`).
|
||||
if (guidTok != "\"\"") continue;
|
||||
|
||||
// Parse the two time tokens. std::stod throws on garbage — guard so one
|
||||
// malformed triple does not abort the whole parse.
|
||||
// std::stod throws on garbage — guard so one malformed triple doesn't
|
||||
// abort the whole parse.
|
||||
double start = 0.0, end = 0.0;
|
||||
try {
|
||||
std::size_t sp = 0, ep = 0;
|
||||
start = std::stod(startTok, &sp);
|
||||
end = std::stod(endTok, &ep);
|
||||
// Reject tokens with trailing garbage (e.g. "1.0x") — a partial parse
|
||||
// is a malformed area, not a valid range.
|
||||
// Reject trailing garbage (e.g. "1.0x") — a partial parse is malformed.
|
||||
if (sp != startTok.size() || ep != endTok.size()) continue;
|
||||
} catch (...) {
|
||||
continue;
|
||||
@@ -197,23 +170,13 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges) {
|
||||
}
|
||||
|
||||
const std::vector<CaptureActionDef>& captureActionTable() {
|
||||
// Built once (function-local static): two SCOPE actions, item + track. Both
|
||||
// exact bounds by default; the tail mode a capture applies is read from the
|
||||
// docked-panel setting at fire time (tail_control + bank_panel), so tail is NOT
|
||||
// a per-action variant. Ids are FOREVER-STABLE — never edit a shipped string.
|
||||
// Each action infers its range (razor-else-time) at fire time and enforces its
|
||||
// FX-scope invariant via fxBypassPlanFor. The M7 CAPTURE_TRACKS_WET /
|
||||
// CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids are RETIRED (mirror-unregistered in
|
||||
// main.cpp); the CAPTURE_MASTER scope action is REMOVED (its id is likewise
|
||||
// mirror-unregistered) — to capture the master you render a track.
|
||||
// FOREVER-STABLE ids — never edit a shipped string. No master capture
|
||||
// action (its id was retired; do not reintroduce it).
|
||||
static const std::vector<CaptureActionDef> table = {
|
||||
// Item scope — item/take FX only. Suffix + phrase are channel-agnostic; the shell
|
||||
// composes the FOREVER-STABLE id (prefix + "CAPTURE_ITEM") and the display name.
|
||||
{"CAPTURE_ITEM",
|
||||
"capture selected item(s)", "item",
|
||||
CaptureScope::Item},
|
||||
|
||||
// Track scope — item FX + the track's own FX.
|
||||
{"CAPTURE_TRACK",
|
||||
"capture selected track(s)", "track",
|
||||
CaptureScope::Track},
|
||||
|
||||
@@ -1,26 +1,9 @@
|
||||
#pragma once
|
||||
// render_settings — the REAPER-free logic behind the capture action family.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. The capture shell (capture.cpp) and
|
||||
// action layer (main.cpp) read the actual DAW state (time selection, selected
|
||||
// tracks/items, razor strings, the ancestor-track chain) and hand the raw values
|
||||
// here so the genuinely-pure, easy-to-get-wrong pieces are unit-tested outside
|
||||
// the DAW:
|
||||
//
|
||||
// 1. sourceMode -> the RENDER_SETTINGS integer bit value (wet only).
|
||||
// 2. a P_RAZOREDITS string -> the list of (start,end) ranges + their union bound.
|
||||
// 3. range inference: razor-present -> razor union, else time selection. Range
|
||||
// is a SOURCE choice orthogonal to the capture scope.
|
||||
// 4. the FX-scope bypass plan: given a scope + an ancestor-chain length, which
|
||||
// tracks' FX to bypass so each scope hears only the FX it should (the M7
|
||||
// "items captured through parent FX" defect is corrected here).
|
||||
// 5. the capture-action table (id string, description, scope) — the taxonomy,
|
||||
// in one place so main.cpp iterates it instead of hand-listing.
|
||||
//
|
||||
// The RENDER_SETTINGS bit MEANINGS are transcribed verbatim from
|
||||
// reaper_plugin_functions.h line ~3041 (see kRender* constants); the CHOICE of
|
||||
// which bits each source mode sets is this module's logic and is tested.
|
||||
// render_settings — the REAPER-free logic behind the capture action family:
|
||||
// sourceMode -> RENDER_SETTINGS bits, P_RAZOREDITS parsing + range union,
|
||||
// razor-else-time inference, the FX-scope bypass plan, and the capture-action
|
||||
// table main.cpp iterates. Bit MEANINGS below are transcribed verbatim from
|
||||
// reaper_plugin_functions.h; the CHOICE of which bits each mode sets is tested.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -32,67 +15,44 @@ namespace reasampler::capture {
|
||||
using model::SourceMode;
|
||||
|
||||
// --- RENDER_SETTINGS source/processing bits (verbatim from SDK header ~3041) --
|
||||
//
|
||||
// Only the bits this module actually uses are named. Values are the documented bit
|
||||
// weights; the DOC of each is the SDK header's, not a guess.
|
||||
inline constexpr int kRenderMasterMix = 0; // (&(1|2))==0, no source bits
|
||||
inline constexpr int kRenderSelItems = 32; // &32 selected media items
|
||||
inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master
|
||||
inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master
|
||||
inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits
|
||||
// NOTE: kRenderPreFaderStems (&8192) is NOT used. REAPER offline render has no
|
||||
// true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-render
|
||||
// mechanism (see fxBypassPlan below) — bypassing the FX-enable of the tracks that
|
||||
// fall outside a scope — NOT by any render bit. All capture actions render wet
|
||||
// (post the FX that remain enabled); the scope decides which FX remain enabled.
|
||||
// kRenderPreFaderStems (&8192) is deliberately NOT used — REAPER offline render
|
||||
// has no true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-render
|
||||
// mechanism (see fxBypassPlan below), not by any render bit. All capture actions
|
||||
// render wet; the scope decides which FX remain enabled.
|
||||
inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file
|
||||
|
||||
// --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ----------
|
||||
//
|
||||
// The capture-tail feature (docs/product/capture-tail.md) preserves reverb/release
|
||||
// decay past the range end. Every offline capture renders custom-time-bounds, so
|
||||
// the only tail-flag bit that ever applies is &1 (RENDER_TAILFLAG, header ~3047).
|
||||
// These values are the pure part — mode -> (RENDER_* values) — unit-tested outside
|
||||
// the DAW exactly like renderSettingsFor; the backend just applies them.
|
||||
//
|
||||
// RENDER_NORMALIZE bit meanings (verbatim from SDK header ~3051):
|
||||
// &32768 = trim ending silence (the surgical Auto path)
|
||||
// &(4<<16) = disable all render postprocessing (the None/Manual path)
|
||||
// Every offline capture renders custom-time-bounds, so &1 (RENDER_TAILFLAG,
|
||||
// header ~3047) is the only tail-flag bit that ever applies. RENDER_NORMALIZE
|
||||
// (verbatim, header ~3051): &32768 = trim ending silence (Auto path);
|
||||
// &(4<<16) = disable all render postprocessing (None/Manual path).
|
||||
inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence
|
||||
inline constexpr int kNormalizeDisableAll = (4 << 16); // &(4<<16) = 262144, disable all
|
||||
|
||||
// RENDER_TAILFLAG &1 = apply tail for custom time bounds (header ~3047). We render
|
||||
// custom bounds unconditionally, so this is the only tail bit that ever applies.
|
||||
inline constexpr int kTailFlagNone = 0;
|
||||
inline constexpr int kTailFlagCustomBounds = 1; // &1
|
||||
inline constexpr int kTailFlagCustomBounds = 1; // &1, header ~3047
|
||||
|
||||
// Auto-trim trailing-silence threshold. -72 dB is quiet enough that the trimmed
|
||||
// region is inaudible decay, loud enough to not chase a reverb's infinite noise
|
||||
// floor. Daniel-set. Single source of truth: the RENDER_TRIMEND ratio derives from
|
||||
// this dB, never the reverse.
|
||||
// Auto-trim trailing-silence threshold; single source of truth (RENDER_TRIMEND
|
||||
// ratio derives from this dB, never the reverse). Daniel-set.
|
||||
inline constexpr double kAutoTrimThresholdDb = -72.0;
|
||||
|
||||
// Max tail rendered past the range end. The runaway guard: a non-decaying or
|
||||
// looping signal never crosses the trim threshold, so this caps the render.
|
||||
// Daniel-set. Shared by the offline (T1) and future realtime (T2) tail paths.
|
||||
// Runaway guard: max tail rendered past the range end, so a non-decaying or
|
||||
// looping signal doesn't render forever. Daniel-set; shared by offline+realtime.
|
||||
inline constexpr double kMaxTailSeconds = 8.0;
|
||||
inline constexpr double kMaxTailMs = 8000.0;
|
||||
|
||||
// Derived linear amplitude ratio for RENDER_TRIMEND. The header (~3062) documents
|
||||
// RENDER_TRIMEND as an amplitude ratio ("0.5 means -6.02 dB"), i.e. 10^(dB/20).
|
||||
// Derived from kAutoTrimThresholdDb so the dB stays the single source of truth and
|
||||
// a future config change to the dB does not require hand-recomputing the ratio.
|
||||
//
|
||||
// std::pow is not constexpr before C++26, so this is a function, not a constant.
|
||||
// For -72 dB: 10^(-72/20) = 10^(-3.6) ~= 0.00025119 (the value the DAW confirm targets).
|
||||
// Derived linear amplitude ratio for RENDER_TRIMEND (header ~3062: an amplitude
|
||||
// ratio, "0.5 means -6.02 dB", i.e. 10^(dB/20)) from kAutoTrimThresholdDb.
|
||||
// Function not constant: std::pow isn't constexpr before C++26.
|
||||
double autoTrimEndRatio();
|
||||
|
||||
// The three tail states (docs/product/capture-tail.md §The three tail states):
|
||||
// None — exact bounds, no tail. Byte-identical to the pre-tail capture. The
|
||||
// default and the ONLY mode for null-test / verify captures.
|
||||
// Auto — generous 8 s tail then trim trailing silence to -72 dB (surgical
|
||||
// normalize). The user-facing tail-on option (panel toggle).
|
||||
// Manual — a fixed tail length (clamped to the 8 s cap), no trim.
|
||||
// The three tail states — see src/core/capture/CLAUDE.md.
|
||||
enum class TailMode {
|
||||
None,
|
||||
Auto,
|
||||
@@ -100,9 +60,9 @@ enum class TailMode {
|
||||
};
|
||||
|
||||
// The RENDER_* values a tail mode drives, in addition to the exact STARTPOS/ENDPOS
|
||||
// the backend already sets. `trimEnd` is meaningful only when the trim-end normalize
|
||||
// bit is set (Auto); it is 0 otherwise. This is the pure mapping — the backend reads
|
||||
// these four fields straight onto GetSetProjectInfo.
|
||||
// the backend already sets. `trimEnd` is meaningful only when the trim-end
|
||||
// normalize bit is set (Auto). The backend reads these straight onto
|
||||
// GetSetProjectInfo.
|
||||
struct TailRenderSettings {
|
||||
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1)
|
||||
double tailMs = 0.0; // RENDER_TAILMS
|
||||
@@ -110,54 +70,36 @@ struct TailRenderSettings {
|
||||
double trimEnd = 0.0; // RENDER_TRIMEND (only used when trim bit set)
|
||||
};
|
||||
|
||||
// Maps a tail mode (+ the requested manual tail ms) to its RENDER_* values.
|
||||
// `manualTailMs` is used ONLY for TailMode::Manual (ignored otherwise). Manual is
|
||||
// clamped to kMaxTailMs — the runaway guard applies whether the length came from
|
||||
// the Auto default or an explicit request (spec §Manual override). Pure + tested.
|
||||
// Maps a tail mode (+ requested manual tail ms, used only for Manual) to its
|
||||
// RENDER_* values. Manual is clamped to kMaxTailMs regardless of source.
|
||||
TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs);
|
||||
|
||||
// The REALTIME record-window end (in project seconds) a tail mode records to, given
|
||||
// the request's exact range end (docs/product/capture-tail.md §The realtime path).
|
||||
// Realtime does NOT drive RENDER_*; it records a generous window and trims later, so
|
||||
// the window end is where the transport actually stops:
|
||||
// None -> rangeEndSeconds (exact — no extra recording).
|
||||
// Auto -> rangeEndSeconds + kMaxTailSeconds (the 8 s runaway cap; trimmed later).
|
||||
// Manual -> rangeEndSeconds + clamp(manualTailMs, kMaxTailMs)/1000 (fixed, no trim).
|
||||
// `manualTailMs` is used ONLY for Manual. Pure so the mode->window arithmetic (and
|
||||
// the Manual clamp) is unit-tested outside the DAW; the backend applies the returned
|
||||
// end to the record time selection. Shared -72 dB / 8 s constants are the same ones
|
||||
// the offline tail uses (single source of truth).
|
||||
// The realtime record-window end (project seconds): realtime does NOT drive
|
||||
// RENDER_*, it records a generous window and trims later, so this is where the
|
||||
// transport actually stops. None -> exact rangeEndSeconds; Auto -> +8s runaway
|
||||
// cap; Manual -> + clamp(manualTailMs, kMaxTailMs)/1000.
|
||||
double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
|
||||
double manualTailMs);
|
||||
|
||||
// The RENDER_SETTINGS value for a given source mode. `supported` is false only
|
||||
// for SourceMode::Realtime (that is the M8 backend, not offline render).
|
||||
// for SourceMode::Realtime (that backend doesn't use offline render).
|
||||
struct RenderSettingsChoice {
|
||||
int settings = kRenderMasterMix;
|
||||
bool supported = true; // false => not an offline-render source (e.g. Realtime)
|
||||
};
|
||||
|
||||
// Maps a source mode to its RENDER_SETTINGS value (which content the render
|
||||
// covers). FX scoping is orthogonal — done by fxBypassPlan, not by these bits.
|
||||
// `wetDry` is accepted but ignored for the mapping — retained in CaptureRequest
|
||||
// as the seam for future dry work (M10 null test).
|
||||
//
|
||||
// CONFIRMED (SDK header ~3041):
|
||||
// MasterMix / TimeSelection -> master mix (0).
|
||||
// SelectedTracks -> &128 selected tracks via master.
|
||||
// SelectedItems -> &32 | single-file (one wav, not one-per-item).
|
||||
// RazorArea -> &4096| single-file.
|
||||
// covers); FX scoping is orthogonal (done by fxBypassPlan). `wetDry` is
|
||||
// accepted but ignored — retained as the seam for future dry work. CONFIRMED
|
||||
// (SDK header ~3041): MasterMix/TimeSelection -> 0; SelectedTracks -> &128;
|
||||
// SelectedItems -> &32|single-file; RazorArea -> &4096|single-file.
|
||||
RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry);
|
||||
|
||||
// --- Capture scope: the FX-scope invariant (Daniel, critical) ----------------
|
||||
// --- Capture scope: the FX-scope invariant ------------------------------------
|
||||
//
|
||||
// Two FX scopes. The render RANGE (razor-else-time) is orthogonal to the scope.
|
||||
// Item -> item/take FX ONLY (no track, no parent/folder, no master FX).
|
||||
// Track -> item FX + the selected track's OWN track FX (no parent/folder/master).
|
||||
// There is NO master scope: to capture the master you render a track instead. The
|
||||
// master track's FX/gain/pan are still NEUTRALIZED as part of the out-of-scope
|
||||
// chain for both item and track captures (bypassMaster below) — master is a
|
||||
// bypass target, not a capture scope.
|
||||
// See src/core/capture/CLAUDE.md for the scope contract. There is NO master
|
||||
// scope; the master track's FX/gain/pan are still NEUTRALIZED as part of the
|
||||
// out-of-scope chain (bypassMaster below) — master is a bypass target only.
|
||||
enum class CaptureScope {
|
||||
Item,
|
||||
Track,
|
||||
@@ -169,34 +111,28 @@ SourceMode sourceModeForScope(CaptureScope scope);
|
||||
|
||||
// --- Range inference: razor-else-time (orthogonal to scope) -------------------
|
||||
//
|
||||
// Every scope action infers its render range the same way: if a razor area is
|
||||
// present, use the razor union; otherwise use the time selection. Razor is a
|
||||
// range SOURCE, not a capture mode (the M7 four-mode model conflated them).
|
||||
// Razor-present -> razor union; otherwise time selection. Razor is a range
|
||||
// source, not a capture mode.
|
||||
enum class RangeSource {
|
||||
Razor, // a razor area is present -> use its union bound
|
||||
TimeSelection, // no razor -> use the time selection
|
||||
};
|
||||
|
||||
// Picks the range source. Pure so the "razor wins when present" rule is tested
|
||||
// without a DAW; the shell supplies whether any razor area was found.
|
||||
// Picks the range source. Pure so "razor wins when present" is tested without
|
||||
// a DAW; the shell supplies whether any razor area was found.
|
||||
RangeSource inferRangeSource(bool hasRazorArea);
|
||||
|
||||
// --- FX-bypass plan: which tracks' FX to bypass for a scope -------------------
|
||||
//
|
||||
// Given a CaptureScope, returns three boolean flags: whether to bypass (a) the
|
||||
// captured track's OWN FX, (b) each of its ancestor (parent/folder) tracks' FX,
|
||||
// and (c) the master FX. The caller (FxBypassGuard) resolves these flags to
|
||||
// concrete MediaTrack* by walking the ancestor chain via GetParentTrack and
|
||||
// clears I_FXEN on each flagged track, snapshotting first (RAII restore).
|
||||
//
|
||||
// SCOPE BOUNDARY: I_FXEN bypasses a track's FX plugins but NOT its volume/pan.
|
||||
// The guard (FxBypassGuard, main.cpp) therefore ALSO neutralizes the fader GAIN
|
||||
// (D_VOL -> unity) of every track in this same bypass set, so a Track/Item
|
||||
// capture rendered via master does NOT bake in the parent/folder/master fader
|
||||
// level (Daniel: the capture is likely re-routed through that chain later). PAN
|
||||
// is deliberately left untouched (D_PAN is coupled to D_WIDTH/D_PANLAW — a clean
|
||||
// neutralize is non-trivial; flagged as a follow-up, not half-done). This plan
|
||||
// selects the SET; the guard applies both the FX bypass and the gain neutralize.
|
||||
// Given a CaptureScope, returns three boolean flags: bypass (a) the captured
|
||||
// track's OWN FX, (b) every ancestor (parent/folder) track's FX, (c) the
|
||||
// master FX. The caller (FxBypassGuard, shell) walks the ancestor chain via
|
||||
// GetParentTrack, clears I_FXEN on each flagged track (RAII restore), and also
|
||||
// neutralizes D_VOL/D_PAN/D_WIDTH/D_PANLAW/I_PANMODE to unity/center on the
|
||||
// same set (I_PANMODE is load-bearing: in pan mode 6, D_PAN/D_WIDTH are
|
||||
// ignored entirely, so forcing it is what makes the other neutralizations
|
||||
// take effect) — I_FXEN alone doesn't touch a track's volume/pan. This plan
|
||||
// selects the set; the guard applies both the FX bypass and the neutralize.
|
||||
struct FxBypassPlan {
|
||||
bool bypassSelfFx = false; // the captured track's own FX
|
||||
bool bypassAncestorFx = false; // every ancestor (parent/folder) track's FX
|
||||
@@ -213,38 +149,24 @@ struct RazorRange {
|
||||
};
|
||||
|
||||
// Parses ONE track's P_RAZOREDITS string (SDK header ~2899): space-separated
|
||||
// TRIPLES of <start> <end> <envGuidString>. The envelope GUID is "" (an empty
|
||||
// quoted string, i.e. the literal two chars `""`) for a track-audio area and a
|
||||
// GUID like {…} for an envelope-lane area.
|
||||
//
|
||||
// Returns only the track-audio ranges (envelope-lane triples are skipped — razor
|
||||
// captures target track audio, not envelope lanes). Malformed/short trailing tokens are ignored, not fatal.
|
||||
// A range with end <= start is dropped (no negative/empty areas leak through).
|
||||
// TRIPLES of <start> <end> <envGuidString>, envGuid == `""` for a track-audio
|
||||
// area vs a GUID for an envelope-lane area. Returns only track-audio ranges
|
||||
// (envelope-lane triples skipped); malformed trailing tokens are ignored, not
|
||||
// fatal; a range with end <= start is dropped.
|
||||
std::vector<RazorRange> parseRazorEdits(const std::string& razorString);
|
||||
|
||||
// The union bound (min start, max end) of a set of razor ranges — the exact
|
||||
// window the offline render must cover so every area is inside the rendered file.
|
||||
// Returns {0,0} for an empty input (caller treats that as "no razor area").
|
||||
// window the offline render must cover. {0,0} for empty input ("no razor area").
|
||||
RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges);
|
||||
|
||||
// --- Capture-action taxonomy (the bindable set main.cpp registers) -----------
|
||||
//
|
||||
// One row per bindable SCOPE action: item and track. The range each captures
|
||||
// (razor-else-time) is inferred at fire time, not a mode. TAIL is NOT a per-action
|
||||
// variant — the tail MODE (None/Auto/Manual) is a panel SETTING the capture reads
|
||||
// at fire time (see tail_control + bank_panel), so a single pair of actions covers
|
||||
// every tail state. Bounded, discoverable, NO dialogs (the tool's no-clutter ethos).
|
||||
// One row per bindable scope action (item/track); range inference and tail
|
||||
// mode are read at fire time, not baked into the row. The row stores only the
|
||||
// channel-agnostic command-id SUFFIX + description PHRASE; the registering
|
||||
// shell composes the full channel-qualified id/name via app_version.
|
||||
//
|
||||
// Phase V (V4): the row stores the channel-AGNOSTIC pieces — a command-id SUFFIX (the
|
||||
// tail after the family prefix) and a description PHRASE (the label after the "ReaSampler:
|
||||
// " lead). The registering shell composes the full, channel-qualified id/name via
|
||||
// app_version's channelCommandId / channelActionName (commandIdPrefix + suffix /
|
||||
// actionDisplayPrefix + phrase). This keeps the pure table free of any channel branch:
|
||||
// stable rebuilds the exact shipped id "CEREBELLUM_REASAMPLER_CAPTURE_TRACK" from
|
||||
// prefix + "CAPTURE_TRACK"; beta yields "CEREBELLUM_REASAMPLER_BETA_CAPTURE_TRACK".
|
||||
//
|
||||
// commandSuffix is FOREVER-STABLE (user keybindings key off the composed id) — never
|
||||
// change a shipped value. baseName feeds the file stem (sanitized by capture_paths).
|
||||
// commandSuffix is FOREVER-STABLE (user keybindings key off the composed id).
|
||||
struct CaptureActionDef {
|
||||
const char* commandSuffix; // e.g. "CAPTURE_TRACK" — FOREVER-STABLE (composed w/ prefix)
|
||||
const char* descriptionPhrase; // e.g. "capture selected track(s)" — Actions-list phrase
|
||||
@@ -252,14 +174,11 @@ struct CaptureActionDef {
|
||||
CaptureScope scope; // FX scope (item / track)
|
||||
};
|
||||
|
||||
// The capture-action table. Iterated by main.cpp to register the family and route
|
||||
// each fired command back to its definition. Kept here (pure) so the taxonomy is
|
||||
// one testable list, not scattered registration code.
|
||||
// The capture-action table. Iterated by main.cpp to register the family and
|
||||
// route each fired command back to its definition.
|
||||
//
|
||||
// Two rows: CAPTURE_ITEM / CAPTURE_TRACK. There is no master capture — to capture
|
||||
// the master you render a track. Razor is an inferred range, not a mode, and each
|
||||
// scope enforces its FX-scope invariant via fxBypassPlanFor. The tail mode each
|
||||
// capture applies is read from the docked-panel setting, not baked into the row.
|
||||
// Two rows: CAPTURE_ITEM / CAPTURE_TRACK. There is no master capture — to
|
||||
// capture the master you render a track.
|
||||
const std::vector<CaptureActionDef>& captureActionTable();
|
||||
|
||||
} // namespace reasampler::capture
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// tail_control — pure implementation. See tail_control.h. NO REAPER / SWELL / vendor.
|
||||
// tail_control — pure implementation. See tail_control.h.
|
||||
|
||||
#include "core/capture/tail_control.h"
|
||||
|
||||
@@ -19,14 +19,10 @@ TailMode cycleTailMode(TailMode current) {
|
||||
}
|
||||
|
||||
double clampManualMs(double manualMs) {
|
||||
// Same runaway guard the pure tailRenderSettingsFor applies to Manual: floor a
|
||||
// negative request to 0, cap at the 8 s ceiling.
|
||||
return std::clamp(manualMs, 0.0, kMaxTailMs);
|
||||
}
|
||||
|
||||
double adjustManualMs(double current, int notches, double stepMs) {
|
||||
// Clamp the stepped value so both scroll directions saturate at the bounds rather
|
||||
// than running away (the same [0, kMaxTailMs] guard clampManualMs enforces).
|
||||
return clampManualMs(current + notches * stepMs);
|
||||
}
|
||||
|
||||
@@ -35,8 +31,8 @@ std::string tailToggleLabel(const TailSetting& setting) {
|
||||
case TailMode::None: return "Tail: Off";
|
||||
case TailMode::Auto: return "Tail: Auto";
|
||||
case TailMode::Manual: {
|
||||
// Append the CLAMPED length in seconds to one decimal so the readout can
|
||||
// never show an over-cap value even if manualMs was stored past the cap.
|
||||
// Clamped so the readout can't show an over-cap value even if
|
||||
// manualMs was stored past the cap.
|
||||
const double seconds = clampManualMs(setting.manualMs) / 1000.0;
|
||||
char buf[32];
|
||||
std::snprintf(buf, sizeof(buf), "Tail: Manual %.1fs", seconds);
|
||||
@@ -46,17 +42,9 @@ std::string tailToggleLabel(const TailSetting& setting) {
|
||||
return "Tail: Off"; // unreachable for a valid enum; fail to the safe default
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The setting is a flat object of one enum + one double, riding the shared
|
||||
// core/json layer (Q-W1, T2-02: the former substring-scan valueAfterKey reader —
|
||||
// the fifth hand-rolled JSON decoder — is retired). manualMs is emitted with 17
|
||||
// significant digits (%.17g) — the shortest form that round-trips every IEEE-754
|
||||
// double exactly — so deserialize(serialize(x)) == x holds bit-for-bit.
|
||||
// deserialize stays forgiving in outcome: any parse failure returns nullopt so
|
||||
// the caller falls back to a default, exactly as an absent ext-state key does.
|
||||
// --- JSON round-trip ---------------------------------------------------------
|
||||
// manualMs round-trips exactly (json::numToStr uses the shortest %.17g-class
|
||||
// form for doubles); deserialize returns nullopt on any parse failure.
|
||||
|
||||
namespace {
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
#pragma once
|
||||
// tail_control — the REAPER-free logic behind the docked bank_panel's tail-mode
|
||||
// toggle. The panel shell (shell/panel/) owns the SWELL window, LICE drawing, and
|
||||
// click hit-testing; what is NOT DAW-bound — the cycle order, the manual-length
|
||||
// clamp, and the toggle's label text — lives here so it is unit-tested outside the
|
||||
// DAW (CLAUDE.md §load-bearing split). Mirror of bank_grid / mode_switch.
|
||||
//
|
||||
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
||||
// only (plus render_settings for the pure TailMode enum). Builds and unit-tests
|
||||
// without REAPER.
|
||||
// toggle. The panel shell owns the SWELL window, LICE drawing, and click
|
||||
// hit-testing; the cycle order, manual-length clamp, and label text live here.
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -16,54 +10,40 @@
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// The Manual-mode starting length. 2 s is a musically useful default tail (a bar of
|
||||
// reverb throw at a moderate tempo) that is well under the 8 s cap. Also the value a
|
||||
// project with no stored tail setting (older / never-adjusted) falls back to on load.
|
||||
// The Manual-mode starting length: 2s, a musically useful default (a bar of
|
||||
// reverb throw at moderate tempo), well under the 8s cap. Also the fallback
|
||||
// for a project with no stored tail setting.
|
||||
inline constexpr double kDefaultManualTailMs = 2000.0;
|
||||
|
||||
// The fine-adjust step per scroll-wheel notch in Manual mode. 250 ms is coarse enough
|
||||
// that a few notches cover the useful range, fine enough to dial a length precisely.
|
||||
// Daniel-set. The panel maps one wheel notch to +/- this many ms via adjustManualMs.
|
||||
// Fine-adjust step per scroll-wheel notch in Manual mode. Daniel-set.
|
||||
inline constexpr double kManualStepMs = 250.0;
|
||||
|
||||
// The panel's current tail setting: the mode plus the length used ONLY when the
|
||||
// mode is Manual. Held as in-memory panel/session state (shell/panel), default
|
||||
// None so a capture with no explicit choice stays exact-bounds / byte-identical to
|
||||
// today. `manualMs` is a stored default a future fine-adjust UI can tune; it is
|
||||
// clamped to the 8 s cap (kMaxTailMs) before it ever reaches a CaptureRequest.
|
||||
// The panel's current tail setting: mode + the length used only when Manual.
|
||||
// Default None so a capture with no explicit choice stays exact-bounds.
|
||||
// `manualMs` is clamped to kMaxTailMs before it ever reaches a CaptureRequest.
|
||||
struct TailSetting {
|
||||
TailMode mode = TailMode::None;
|
||||
double manualMs = kDefaultManualTailMs;
|
||||
};
|
||||
|
||||
// Cycles the tail mode: None -> Auto -> Manual -> None. Pure so the wrap order is
|
||||
// pinned by a test and the panel's click handler owns no enum arithmetic of its own.
|
||||
// An out-of-range value (unreachable for a valid enum) cycles back to None.
|
||||
// Cycles the tail mode: None -> Auto -> Manual -> None.
|
||||
TailMode cycleTailMode(TailMode current);
|
||||
|
||||
// The effective manual length a Manual capture uses: `manualMs` clamped to
|
||||
// [0, kMaxTailMs] (the runaway guard the pure tailRenderSettingsFor also applies).
|
||||
// Exposed so the panel can show the clamped value and main.cpp hands a pre-clamped
|
||||
// tailMs into the CaptureRequest. Meaningful only for TailMode::Manual.
|
||||
// The effective manual length a Manual capture uses: clamped to [0, kMaxTailMs].
|
||||
// Exposed so the panel can show the clamped value. Meaningful only for Manual.
|
||||
double clampManualMs(double manualMs);
|
||||
|
||||
// Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped to
|
||||
// [0, kMaxTailMs]. Positive notches lengthen, negative shorten. Pure so the fine-adjust
|
||||
// arithmetic (and its clamp at both bounds) is unit-tested; the panel wheel handler
|
||||
// owns no arithmetic of its own. Meaningful only for TailMode::Manual.
|
||||
// Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped
|
||||
// to [0, kMaxTailMs]. Meaningful only for TailMode::Manual.
|
||||
double adjustManualMs(double current, int notches, double stepMs);
|
||||
|
||||
// The toggle's label for a setting, e.g. "Tail: Off", "Tail: Auto". In Manual mode the
|
||||
// clamped length is appended in seconds to one decimal, e.g. "Tail: Manual 2.0s" —
|
||||
// Off/Auto carry no length. Pure so the exact strings (and the Manual format) are
|
||||
// test-pinned, including the boundary lengths (0.0s, 8.0s).
|
||||
// The toggle's label, e.g. "Tail: Off", "Tail: Auto", or (Manual, clamped
|
||||
// length to one decimal) "Tail: Manual 2.0s".
|
||||
std::string tailToggleLabel(const TailSetting& setting);
|
||||
|
||||
// JSON round-trip of a TailSetting (mode + manualMs), for persist to store the tail
|
||||
// setting per-project alongside the bank and view model. Kept pure/testable here —
|
||||
// the natural home, mirroring bank_model's serialize/deserialize. serialize emits a
|
||||
// compact object; deserialize returns std::nullopt on malformed input so the caller
|
||||
// (persist) falls back to a default setting, exactly as an absent key does.
|
||||
// JSON round-trip of a TailSetting, for persist to store per-project. Pure/
|
||||
// testable here, mirroring bank_model's serialize/deserialize; deserialize
|
||||
// returns nullopt on malformed input so the caller falls back to a default.
|
||||
std::string serializeTailSetting(const TailSetting& setting);
|
||||
std::optional<TailSetting> deserializeTailSetting(const std::string& json);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// wav_codec — pure implementation. See wav_codec.h. NO REAPER / SWELL / vendor.
|
||||
//
|
||||
// The ONE RIFF chunk traversal lives here (nextWavChunk); the layout parse and the
|
||||
// content hash both walk with it, so their view of the container cannot drift.
|
||||
// wav_codec — pure implementation. See wav_codec.h. The one RIFF chunk
|
||||
// traversal lives here (nextWavChunk); layout parse and content hash both
|
||||
// walk with it, so their view of the container cannot drift.
|
||||
|
||||
#include "core/capture/wav_codec.h"
|
||||
|
||||
@@ -12,8 +11,7 @@ namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
// Little-endian readers. Bounds are checked by the caller before each read; these
|
||||
// assume `off + N <= bytes.size()`. memcpy avoids alignment/aliasing UB.
|
||||
// Little-endian readers. Caller checks bounds before each read (off + N <= size).
|
||||
std::uint16_t readU16LE(const std::vector<std::uint8_t>& b, std::size_t off) {
|
||||
return static_cast<std::uint16_t>(b[off] | (b[off + 1] << 8));
|
||||
}
|
||||
@@ -28,7 +26,7 @@ bool tagEquals(const std::vector<std::uint8_t>& b, std::size_t off, const char*
|
||||
return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0;
|
||||
}
|
||||
|
||||
// WAVE format tags we accept as 32-bit float (see wav_codec.h FORMAT ASSUMPTION).
|
||||
// WAVE format tags we accept as 32-bit float (see wav_codec.h).
|
||||
constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003;
|
||||
constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE;
|
||||
|
||||
@@ -37,19 +35,16 @@ constexpr std::uint64_t kFnvOffsetBasis = 14695981039346656037ULL;
|
||||
constexpr std::uint64_t kFnvPrime = 1099511628211ULL;
|
||||
|
||||
std::string fnvHex(std::uint64_t h) {
|
||||
// 16-digit lowercase hex (zero-padded) for a fixed-length string.
|
||||
char buf[17];
|
||||
char buf[17]; // 16 hex digits, zero-padded
|
||||
std::snprintf(buf, sizeof(buf), "%016llx", static_cast<unsigned long long>(h));
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// --- The ONE RIFF chunk traversal --------------------------------------------
|
||||
// --- The one RIFF chunk traversal --------------------------------------------
|
||||
//
|
||||
// One sub-chunk of a RIFF/WAVE container as the walk sees it: header at
|
||||
// `headerOffset` (id(4) + size(4)), body at `bodyOffset` with declared `bodySize`.
|
||||
// `bodyInBounds` is whether the declared body fits inside the buffer — a chunk
|
||||
// whose declared size lies past the end is still REPORTED (callers decide how to
|
||||
// treat it) but its body must not be read.
|
||||
// One sub-chunk of a RIFF/WAVE container: header at `headerOffset` (id(4) +
|
||||
// size(4)), body at `bodyOffset`/`bodySize`. `bodyInBounds` false means the
|
||||
// declared body runs past the buffer — still reported, but must not be read.
|
||||
struct WavChunkView {
|
||||
std::size_t headerOffset = 0;
|
||||
std::size_t bodyOffset = 0;
|
||||
@@ -60,9 +55,8 @@ struct WavChunkView {
|
||||
// Advances one chunk. `pos` starts at 12 (after "RIFF" size "WAVE"); each call
|
||||
// fills `out` and moves `pos` past the chunk's body, honoring RIFF even-byte
|
||||
// padding. Returns false when no further chunk header fits. If the padded advance
|
||||
// would overrun the buffer, the chunk is still reported (return true) and `pos` is
|
||||
// parked past the end so the NEXT call returns false — exactly the process-then-
|
||||
// break shape the pre-consolidation walkers shared.
|
||||
// would overrun the buffer, the chunk is still reported (return true) and `pos`
|
||||
// is parked past the end so the next call returns false.
|
||||
bool nextWavChunk(const std::vector<std::uint8_t>& bytes, std::size_t& pos,
|
||||
WavChunkView& out) {
|
||||
if (pos + 8 > bytes.size()) return false;
|
||||
@@ -100,8 +94,8 @@ WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
|
||||
std::uint32_t sampleRate = 0;
|
||||
std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible
|
||||
|
||||
// Walk the sub-chunks after "WAVE" (offset 12) with the shared traversal. A
|
||||
// malformed/truncated file is "invalid", never an OOB read.
|
||||
// Walk the sub-chunks after "WAVE" (offset 12). A malformed/truncated file
|
||||
// is "invalid", never an OOB read.
|
||||
std::size_t pos = 12;
|
||||
WavChunkView c;
|
||||
while (nextWavChunk(bytes, pos, c)) {
|
||||
@@ -112,11 +106,9 @@ WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
|
||||
channels = readU16LE(bytes, c.bodyOffset + 2);
|
||||
sampleRate = readU32LE(bytes, c.bodyOffset + 4);
|
||||
bitsPerSample = readU16LE(bytes, c.bodyOffset + 14);
|
||||
// For WAVE_FORMAT_EXTENSIBLE (0xFFFE), read the SubFormat GUID's leading
|
||||
// 2-byte tag at body offset 24 to distinguish float (0x0003) from PCM
|
||||
// integer (0x0001) and all other sub-formats. Body must be >= 40 bytes to
|
||||
// reach GUID offset 24 + 16 bytes of GUID, and the full GUID must fit in
|
||||
// the buffer; otherwise we leave extensibleSubFormatTag at 0 (rejected).
|
||||
// WAVE_FORMAT_EXTENSIBLE: the real format lives in the SubFormat GUID's
|
||||
// leading 2-byte tag at body offset 24, not in fmtTag itself. Body must
|
||||
// reach offset 24+16; otherwise leave the tag at 0 (rejected).
|
||||
if (fmtTag == kWaveFormatExtensible) {
|
||||
if (c.bodySize >= 40 && c.bodyOffset + 40 <= bytes.size()) {
|
||||
extensibleSubFormatTag = readU16LE(bytes, c.bodyOffset + 24);
|
||||
@@ -124,16 +116,13 @@ WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
|
||||
}
|
||||
haveFmt = true;
|
||||
} else if (tagEquals(bytes, c.headerOffset, "data")) {
|
||||
// The data chunk: PCM starts at bodyOffset, declared length bodySize.
|
||||
// Reject if it runs past the buffer (truncated / lying header).
|
||||
// Reject if the declared body runs past the buffer (truncated/lying
|
||||
// header), or if data arrived before fmt.
|
||||
if (!c.bodyInBounds) return out;
|
||||
if (!haveFmt) return out; // data before fmt — not a WAV we parse
|
||||
if (!haveFmt) return out;
|
||||
|
||||
// Plain IEEE-float tag (0x0003): accept as-is.
|
||||
// Extensible tag (0xFFFE): accept only when the SubFormat tag read from
|
||||
// the GUID at body offset 24 is also 0x0003 (IEEE float). SubFormat tag
|
||||
// 0x0001 (PCM integer) or anything else with bitsPerSample==32 is NOT
|
||||
// float and must be rejected to prevent mis-decoding as float.
|
||||
// Extensible tag (0xFFFE) is float only when its SubFormat sub-tag is
|
||||
// also IEEE-float (0x0003) — PCM-integer-in-extensible must be rejected.
|
||||
const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) ||
|
||||
(fmtTag == kWaveFormatExtensible &&
|
||||
extensibleSubFormatTag == kWaveFormatIeeeFloat);
|
||||
@@ -279,12 +268,6 @@ std::string hashBytes(const std::uint8_t* data, std::size_t len) {
|
||||
}
|
||||
|
||||
std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
|
||||
// Walk the RIFF/WAVE container (the shared traversal) and feed only the `fmt `
|
||||
// body and `data` body through FNV-1a, prefixed with the domain-separation tag
|
||||
// byte 'W' (0x57). Any render-varying metadata chunks (bext, iXML, LIST, SMED,
|
||||
// etc.) are skipped. If the file does not parse as RIFF/WAVE with both fmt and
|
||||
// data chunks, fall back to whole-file hashBytes (no prefix) so an unrecognized
|
||||
// file still gets a hash.
|
||||
if (isRiffWave(bytes)) {
|
||||
std::uint64_t h = kFnvOffsetBasis;
|
||||
auto feedByte = [&](std::uint8_t b) {
|
||||
@@ -295,23 +278,18 @@ std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
|
||||
bool haveFmt = false;
|
||||
bool haveData = false;
|
||||
|
||||
// Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a
|
||||
// whole-file hash of different bytes that happen to be the same length.
|
||||
feedByte(static_cast<std::uint8_t>('W'));
|
||||
feedByte(static_cast<std::uint8_t>('W')); // domain-separation prefix
|
||||
|
||||
std::size_t pos = 12;
|
||||
WavChunkView c;
|
||||
while (nextWavChunk(bytes, pos, c)) {
|
||||
if (tagEquals(bytes, c.headerOffset, "fmt ")) {
|
||||
// Feed the entire fmt body (all fields, including format tag, channels,
|
||||
// sample rate, bits-per-sample — everything that defines the audio format).
|
||||
if (c.bodyInBounds) {
|
||||
for (std::uint32_t i = 0; i < c.bodySize; ++i)
|
||||
feedByte(bytes[c.bodyOffset + i]);
|
||||
haveFmt = true;
|
||||
}
|
||||
} else if (tagEquals(bytes, c.headerOffset, "data")) {
|
||||
// Feed the entire PCM payload.
|
||||
if (c.bodyInBounds) {
|
||||
for (std::uint32_t i = 0; i < c.bodySize; ++i)
|
||||
feedByte(bytes[c.bodyOffset + i]);
|
||||
|
||||
+38
-103
@@ -1,39 +1,9 @@
|
||||
#pragma once
|
||||
// wav_codec — the ONE pure owner of the WAV/RIFF byte format (Q-W3, audit §4e:
|
||||
// T2-08 / T4-10 / T4-23 consolidation). Chunk walker + layout parse + float32
|
||||
// build + size-field patch + the WAV-aware content hash, in one tested module.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. Builds and unit-tests without REAPER.
|
||||
//
|
||||
// Before this module, RIFF container knowledge (chunk-header arithmetic, even-byte
|
||||
// padding, size fields) was minted at four sites: wav_trim's layout parse,
|
||||
// capture_paths' content-hash chunk walk, ingest's hand-built float32 writer, and
|
||||
// capture_realtime's in-place size patch. A drift in any one (e.g. pad-byte
|
||||
// handling) would desynchronize hashing from decoding — the dedup-by-hash and
|
||||
// null-test invariants both sit on this. Now every walker/builder/patcher is here,
|
||||
// on ONE chunk-traversal implementation.
|
||||
//
|
||||
// WHY TRIM EXISTS (docs/product/capture-tail.md §The realtime path). The realtime
|
||||
// backend records a generous tail window, then trims the trailing decay by
|
||||
// truncating the recorded WAV at a frame boundary. Truncating a WAV correctly is
|
||||
// not "chop the bytes": the RIFF container's size fields (the top-level RIFF chunk
|
||||
// size and the `data` sub-chunk size) must be patched to the kept byte count, or
|
||||
// the file is a corrupt / mis-lengthed WAV. That header arithmetic — chunk walking,
|
||||
// format verification, and the size-field patch offsets — is exactly the fiddly,
|
||||
// easy-to-get-wrong logic the discipline unit-tests OUTSIDE the DAW. The REAPER
|
||||
// shell does only the file I/O: read the bytes, call the pure parse, run the decay
|
||||
// scan, call the pure plan, patch + write the truncated bytes.
|
||||
//
|
||||
// FORMAT ASSUMPTION (flagged for DAW-verify). We record 32-bit float WAV
|
||||
// (capture.cpp kRenderFormatWavFloat32; realtime records via REAPER's project
|
||||
// record format, which the manual procedure sets to WAV/32-bit-float). The parser
|
||||
// therefore verifies canonical PCM/IEEE-float WAV: a RIFF/WAVE container, a `fmt `
|
||||
// chunk declaring 32-bit float (format tag 3, or tag 0xFFFE WAVE_FORMAT_EXTENSIBLE
|
||||
// with 32 bits), and a `data` chunk of interleaved little-endian float32. Anything
|
||||
// else (a different depth, a non-WAV, a compressed source) is reported invalid and
|
||||
// the shell SKIPS the trim (keeps the untrimmed window) rather than corrupting a
|
||||
// file it does not understand. This is deliberately conservative.
|
||||
// wav_codec — the pure owner of the WAV/RIFF byte format: chunk walker, layout
|
||||
// parse, float32 build, size-field patch, and the WAV-aware content hash — one
|
||||
// chunk traversal shared by all of them so hashing and decoding cannot desync.
|
||||
// Handles 32-bit float WAV only (RIFF/WAVE, `fmt ` tag 3 or 0xFFFE-extensible
|
||||
// w/ float subformat, float32 `data`); anything else parses as invalid.
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
@@ -49,22 +19,20 @@ using audio::AudioSample;
|
||||
// --- Layout parse ------------------------------------------------------------
|
||||
|
||||
// The parsed geometry of a canonical 32-bit-float WAV. `valid` is false when the
|
||||
// bytes are not a WAV we can safely trim (see FORMAT ASSUMPTION); every other field
|
||||
// is meaningful only when valid.
|
||||
// bytes are not a WAV we can safely trim; every other field is meaningful only
|
||||
// when valid.
|
||||
struct WavLayout {
|
||||
bool valid = false;
|
||||
|
||||
std::uint16_t channelCount = 0; // from `fmt ` (the interleave stride)
|
||||
std::uint32_t sampleRate = 0; // from `fmt ` (for frame<->seconds, if needed)
|
||||
std::uint16_t channelCount = 0; // from `fmt ` (interleave stride)
|
||||
std::uint32_t sampleRate = 0;
|
||||
|
||||
// The `data` chunk: byte offset of its first PCM byte within the file, and its
|
||||
// declared PCM byte length. frameCount = dataByteLength / (channelCount * 4).
|
||||
// The `data` chunk: PCM byte offset + declared length.
|
||||
// frameCount = dataByteLength / (channelCount * 4).
|
||||
std::size_t dataByteOffset = 0;
|
||||
std::size_t dataByteLength = 0;
|
||||
|
||||
// Byte offset of the two little-endian uint32 size fields the truncate patch
|
||||
// rewrites: the top-level RIFF chunk size (bytes 4..7) and the `data` sub-chunk
|
||||
// size (the 4 bytes immediately before dataByteOffset).
|
||||
// Offsets of the two LE uint32 size fields the truncate patch rewrites.
|
||||
std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file
|
||||
std::size_t dataSizeFieldOffset = 0;
|
||||
|
||||
@@ -74,19 +42,15 @@ struct WavLayout {
|
||||
}
|
||||
};
|
||||
|
||||
// Parses a WAV byte buffer's header geometry. Returns {valid=false} for anything
|
||||
// that is not a canonical 32-bit-float RIFF/WAVE with a `fmt ` and a `data` chunk,
|
||||
// or whose declared `data` length runs past the buffer. Does NOT copy PCM — it only
|
||||
// locates it (extractFloatFrames does the copy). Pure + total (no throw, no UB).
|
||||
// Parses a WAV byte buffer's header geometry; {valid=false} for anything not a
|
||||
// canonical float32 RIFF/WAVE, or a `data` length running past the buffer.
|
||||
// Does not copy PCM, only locates it. Pure + total (no throw, no UB).
|
||||
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes);
|
||||
|
||||
// Copies `frameCount` interleaved float frames starting at `startFrame` out of the
|
||||
// WAV's `data` region into a flat [f0c0,f0c1,...] buffer (the shape peaks consumes).
|
||||
// Clamps to the frames the buffer actually holds — never reads past `data`. Returns
|
||||
// empty for an invalid layout or an out-of-range start. The floats are read
|
||||
// little-endian via std::memcpy (no aliasing UB); on a big-endian host they would
|
||||
// need a byte-swap — flagged, not handled, because the target (Windows/macOS/Linux
|
||||
// on x86/ARM-LE) is little-endian and REAPER writes LE WAV.
|
||||
// Copies `frameCount` interleaved float frames starting at `startFrame` out of
|
||||
// the WAV's `data` region into a flat [f0c0,f0c1,...] buffer, clamped to frames
|
||||
// actually present; never reads past `data`. Reads little-endian via memcpy —
|
||||
// target is x86/ARM-LE only, no big-endian byte-swap.
|
||||
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
|
||||
const WavLayout& layout,
|
||||
std::size_t startFrame,
|
||||
@@ -94,10 +58,8 @@ std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& byt
|
||||
|
||||
// --- Truncate plan + size-field patch ---------------------------------------
|
||||
|
||||
// The plan to truncate a parsed WAV to `keptFrames` frames: the new total file byte
|
||||
// length and the two size-field values to patch. `valid` is false if the layout is
|
||||
// invalid or keptFrames exceeds the file's frames (never GROW a file — the caller
|
||||
// clamps beforehand; this guards it too).
|
||||
// The plan to truncate a parsed WAV to `keptFrames` frames. `valid` is false if
|
||||
// the layout is invalid or keptFrames exceeds the file's frames (never grow).
|
||||
struct WavTruncatePlan {
|
||||
bool valid = false;
|
||||
|
||||
@@ -109,64 +71,37 @@ struct WavTruncatePlan {
|
||||
// the 8-byte "RIFF"+size prefix)
|
||||
};
|
||||
|
||||
// Computes the truncate plan to keep exactly `keptFrames` frames of a parsed WAV.
|
||||
// keptFrames == layout.frameCount() is a valid no-op plan (file unchanged). Pure +
|
||||
// total. The shell applies it: patch the two size fields in the byte buffer
|
||||
// (patchU32LE), then truncate the file to newFileByteLength.
|
||||
// Computes the truncate plan to keep exactly `keptFrames` frames. The shell
|
||||
// applies it: patch the two size fields (patchU32LE), then truncate to
|
||||
// newFileByteLength.
|
||||
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames);
|
||||
|
||||
// Patches a little-endian uint32 into a byte buffer at `off` — the RIFF/data size
|
||||
// fields the truncate plan names. The caller guarantees off + 4 <= bytes.size()
|
||||
// (the plan's offsets came from a valid parse of the same buffer).
|
||||
// Patches a little-endian uint32 into a byte buffer at `off`. Caller guarantees
|
||||
// off + 4 <= bytes.size() (the plan's offsets came from a valid parse of the same
|
||||
// buffer).
|
||||
void patchU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v);
|
||||
|
||||
// --- Float32 WAV build -------------------------------------------------------
|
||||
|
||||
// Builds a minimal canonical 32-bit-float RIFF/WAVE byte buffer from interleaved
|
||||
// double samples: RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT,
|
||||
// 16-byte body), data chunk (interleaved little-endian float32). `nch` channels,
|
||||
// `rate` Hz, `frameCount` frames (total samples = frameCount * nch). Each double is
|
||||
// narrowed to float by cast — the bank contract is 32-bit float (see FORMAT
|
||||
// ASSUMPTION above); the reduction is intentional. The output round-trips through
|
||||
// parseWavLayout/extractFloatFrames. The ingest shell decodes any non-canonical
|
||||
// source through REAPER's PCM_source, then writes the bank copy with this.
|
||||
// Builds a minimal canonical float32 RIFF/WAVE byte buffer from interleaved
|
||||
// double samples (narrowed to float by cast). Round-trips through
|
||||
// parseWavLayout/extractFloatFrames.
|
||||
std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
|
||||
std::size_t frameCount,
|
||||
const std::vector<double>& interleaved);
|
||||
|
||||
// --- Content identity (dedup hashes) -----------------------------------------
|
||||
|
||||
// Computes a deterministic FNV-1a 64-bit content hash over `len` bytes at `data`
|
||||
// and returns it as a 16-character lowercase hex string. Designed to fill
|
||||
// Sample::contentHash so the confirm-on-last-reference guardrail
|
||||
// (BankBook::hashReferencedElsewhere) can distinguish "no other bank holds this
|
||||
// file" from "another bank holds the same file." An empty buffer returns the bare
|
||||
// FNV-1a 64-bit offset basis in hex (a stable, non-empty sentinel that two empty
|
||||
// files would share, but real WAV files are never empty).
|
||||
// Deterministic FNV-1a 64-bit content hash over `len` bytes, as 16-char lowercase
|
||||
// hex. Fills Sample::contentHash for the confirm-on-last-reference dedup guardrail.
|
||||
std::string hashBytes(const std::uint8_t* data, std::size_t len);
|
||||
|
||||
// WAV-aware content hash: hashes only the audio-defining content of a 32-bit-float
|
||||
// RIFF/WAVE file — the `fmt ` chunk body + the `data` chunk payload — skipping all
|
||||
// other RIFF chunks (e.g. `bext` origination timestamp, `iXML`, `LIST`/`INFO`, SMED).
|
||||
//
|
||||
// WHY: REAPER's offline renderer embeds render-varying metadata chunks (at minimum a
|
||||
// `bext` chunk containing the origination date/time) even when the format config blob
|
||||
// requests no BWF metadata. Two renders of identical audio therefore differ in those
|
||||
// bytes, making whole-file hashes diverge and preventing dedup collapse.
|
||||
//
|
||||
// DOMAIN SEPARATION: the FNV-1a input is prefixed with the tag byte 'W' (0x57) before
|
||||
// the fmt/data bytes are fed in, so a content hash can never equal a whole-file
|
||||
// hashBytes result for a different file of the same size.
|
||||
//
|
||||
// FALLBACK: if `bytes` does not parse as a valid RIFF/WAVE with both a `fmt ` and a
|
||||
// `data` chunk, the function falls back to whole-file hashBytes (no prefix tag) —
|
||||
// identical to calling hashBytes(bytes.data(), bytes.size()). This ensures that an
|
||||
// unrecognized or malformed file still gets a non-empty hash rather than silently
|
||||
// skipping dedup.
|
||||
//
|
||||
// Called by both capture commit paths (offline and realtime) and the ingest import
|
||||
// in place of the raw hashBytes call. Walks the container with the SAME chunk
|
||||
// traversal parseWavLayout uses, so hashing and decoding can never desynchronize.
|
||||
// WAV-aware content hash: hashes only the `fmt ` body + `data` payload, skipping
|
||||
// other chunks. WHY: REAPER's offline renderer embeds a render-varying `bext`
|
||||
// timestamp chunk even with no BWF metadata requested, so two renders of
|
||||
// identical audio would otherwise hash differently and never dedup. Prefixed
|
||||
// with tag byte 'W' so it can't collide with a same-size hashBytes result.
|
||||
// Falls back to whole-file hashBytes (no prefix) for a file that doesn't parse.
|
||||
std::string hashWavContent(const std::vector<std::uint8_t>& bytes);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
|
||||
@@ -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 0–1 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 0–1'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 (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato toggle), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots); per-zone `ZonePlayParams` (Gate/Trigger, AHDSR, pitch engine Varispeed/Preserve, AD pitch mod envelope), repitch/interpolation with loop-point-aware sustain. Preview injects a synthetic note-on at the loaded capture's root note into the main `VoiceEngine` — no dedicated `PreviewCard`; preview obeys polyphony/mono/voice-stealing/envelopes.
|
||||
- `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 Fritsch–Carlson 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.
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
|
||||
using util::clamp01;
|
||||
|
||||
double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); }
|
||||
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
// master_gain.h — PURE dB<->linear<->knob-taper math for the FB1 post-mixer master gain.
|
||||
// NO VST3, NO REAPER, NO SWELL/LICE types. The mirror of trigger_seam: one tiny module owns
|
||||
// the ONE formula both sides of a seam share — here the editor's Gain knob (normalized 0..1)
|
||||
// and the processor's stored/applied linear gain — so the drawn needle, the persisted value,
|
||||
// and the audio-thread multiply can never drift.
|
||||
//
|
||||
// THE CONTROL (Daniel, FB1). A post-mixer master gain, range -inf .. +24 dB, dB-scaled taper
|
||||
// with -inf at the BOTTOM of the knob: normalized 0 maps to TRUE ZERO linear gain (silence,
|
||||
// not a tiny epsilon), and the remaining travel maps linearly in dB from kMasterGainMinDb
|
||||
// (the finite taper floor) up to kMasterGainMaxDb. Unity (0 dB) sits at norm
|
||||
// kMasterGainMinDb/(kMasterGainMinDb - kMasterGainMaxDb) ~= 0.714 — most of the throw is
|
||||
// usable trim, the last stretch is boost. The PERSISTED value is the LINEAR gain (a plain
|
||||
// finite double, 0 = silence — no -inf on the wire); the taper is a UI-side view of it.
|
||||
//
|
||||
// RT DISCIPLINE: the processor applies the linear gain as one multiply over the summed
|
||||
// output — these functions run on the UI/state threads only.
|
||||
// master_gain.h — dB<->linear<->knob-taper math for the post-mixer master gain.
|
||||
// One shared formula so the drawn needle, the persisted value, and the audio-thread
|
||||
// multiply can't drift. Norm 0 = true zero gain (not an epsilon); persisted value is
|
||||
// linear gain, the dB taper is a UI-side view of it. Unity (0 dB) sits at ~0.714 norm.
|
||||
// RT: the processor applies the linear gain as one multiply over the summed output;
|
||||
// these functions themselves run on UI/state threads only.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -21,38 +11,28 @@
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
// The dB taper endpoints. norm 0 is -inf (true zero); norm just above 0 starts at the
|
||||
// finite floor kMasterGainMinDb and sweeps linearly in dB to kMasterGainMaxDb at norm 1.
|
||||
// norm 0 is -inf (true zero); norm just above 0 starts at the finite floor kMasterGainMinDb
|
||||
// and sweeps linearly in dB to kMasterGainMaxDb at norm 1.
|
||||
inline constexpr double kMasterGainMinDb = -60.0;
|
||||
inline constexpr double kMasterGainMaxDb = 24.0;
|
||||
|
||||
// The largest linear gain the control can produce (kMasterGainMaxDb as a ratio, ~15.849).
|
||||
// Largest linear gain the control can produce (kMasterGainMaxDb as a ratio, ~15.849).
|
||||
double masterGainMaxLinear();
|
||||
|
||||
// Knob taper: normalized [0,1] -> dB. norm <= 0 -> -infinity; else the linear-in-dB sweep
|
||||
// [kMasterGainMinDb, kMasterGainMaxDb]. norm is clamped to [0,1]. Pure.
|
||||
double masterGainDbFromNorm(double norm);
|
||||
|
||||
// Inverse taper: dB -> normalized [0,1]. -infinity (or any dB at or below kMasterGainMinDb,
|
||||
// including below-floor values like -80 dB) maps to norm 0 (the -inf bottom detent) — the
|
||||
// finite sweep only covers the range above kMasterGainMinDb; everything at or below it collapses
|
||||
// to the same true-zero bottom. +24 -> 1. Pure.
|
||||
// Anything at or below kMasterGainMinDb (including -inf) collapses to norm 0 — the finite
|
||||
// sweep only covers the range above the floor.
|
||||
double masterGainNormFromDb(double db);
|
||||
|
||||
// Knob taper composed with dB->ratio: normalized [0,1] -> LINEAR gain. norm 0 -> exactly
|
||||
// 0.0 (true silence); norm 1 -> masterGainMaxLinear(). Pure.
|
||||
double masterGainLinearFromNorm(double norm);
|
||||
|
||||
// Inverse: LINEAR gain -> normalized [0,1]. linear <= 0 -> 0 (the -inf bottom); a linear at
|
||||
// or below the kMasterGainMinDb floor (e.g. 0.001 = -60 dB, or anything below) also maps to 0
|
||||
// — the floor IS the -inf detent; values between true-zero and the floor cannot be represented
|
||||
// on the knob and collapse to the bottom. unity -> ~0.714; masterGainMaxLinear() -> 1.
|
||||
// Out-of-range/non-finite input clamps. Pure.
|
||||
// linear <= 0, or at/below the kMasterGainMinDb floor, collapses to norm 0 — values between
|
||||
// true-zero and the floor aren't representable on the knob. Out-of-range/non-finite clamps.
|
||||
double masterGainNormFromLinear(double linear);
|
||||
|
||||
// The knob's hover/drag value label for a normalized value: "-inf" at the bottom, else a
|
||||
// signed one-decimal dB string ("-12.0dB", "+0.0dB", "+2.4dB"). Writes at most `len` bytes
|
||||
// including the terminator. Pure.
|
||||
// "-inf" at the bottom, else a signed one-decimal dB string ("-12.0dB", "+2.4dB").
|
||||
// Writes at most `len` bytes including the terminator.
|
||||
void formatMasterGainLabel(double norm, char* buf, std::size_t len);
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
// pitch_shift — pure implementation. See pitch_shift.h for the contract, the S16-F2
|
||||
// route-(b) rationale (WDL drags <windows.h>), and the GA-Preserve root cause that replaced
|
||||
// the naive dual-tap OLA with correlation-aligned splices.
|
||||
// NO VST3 / REAPER / SWELL / vendor includes; standard library only.
|
||||
// pitch_shift — pure implementation. See pitch_shift.h for the contract and regression history.
|
||||
//
|
||||
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per input
|
||||
// sample (source rate -> duration preserved). ONE active read tap advances by the shift
|
||||
// sample (source rate, duration preserved). One active read tap advances by the shift
|
||||
// `ratio_` per frame, so its delay behind the writer drifts at (1 - ratio) per frame. When
|
||||
// that delay leaves the safe band [dLow, dHigh], the tap is RELOCATED by a nominal jump of
|
||||
// one window (+window toward older content for up-shifts, -window toward the writer for
|
||||
// down-shifts) — CLAMPED to the filled span so it can never land in unwritten silence (the
|
||||
// GA2 onset fix) — refined by a cross-correlation search over +/- maxLag PLUS a parabolic
|
||||
// peak interpolation for a SUB-SAMPLE lag, so the relocated read point is waveform-aligned
|
||||
// to a fraction of a sample (integer-lag splices left +/-0.5-sample errors: a -59 dB
|
||||
// sideband comb at the splice cadence on a repitched pure sine — the GA2 "alias lines" on
|
||||
// the spectrogram). Old and new taps then crossfade over fadeFrames with a raised-cosine,
|
||||
// amplitude-complementary pair (in-phase content sums to exactly unity gain). For a pure
|
||||
// sine the correlation snaps the jump to an (integer + fraction) period count, so the output
|
||||
// stays a single tone at the shifted frequency — the GA-Preserve acceptance bar. At unity
|
||||
// ratio the delay is frozen mid-band and no splice ever fires: a primed shifter passes the
|
||||
// stream through with ZERO added latency; a silence-warmed one is a clean window delay.
|
||||
// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump of
|
||||
// one window — clamped to the filled span so it never lands in unwritten silence — refined
|
||||
// by a cross-correlation search over +/- maxLag plus a parabolic peak interpolation for a
|
||||
// sub-sample lag (an integer-only lag left +/-0.5-sample errors: a sideband comb at the
|
||||
// splice cadence on a repitched pure sine). Old and new taps then crossfade over fadeFrames
|
||||
// with a raised-cosine, amplitude-complementary pair (in-phase content sums to unity gain).
|
||||
// At unity ratio the delay is frozen mid-band and no splice ever fires.
|
||||
|
||||
#include "core/instrument/engine/pitch_shift.h"
|
||||
|
||||
@@ -55,17 +46,15 @@ void PitchShifter::configure(std::int64_t windowFrames) {
|
||||
ringLen_ = 2 * window_;
|
||||
ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f);
|
||||
// Geometry (all quarters of the window):
|
||||
// - fadeFrames_: the NOMINAL splice crossfade. This window/4 length is only safe when
|
||||
// the outgoing tap cannot reach the writer before the fade ends; splice() scales the
|
||||
// live fade length (fadeLen_) down by the current ratio for up-shifts past ~2x, so
|
||||
// ordinary sampler transpositions (+24 st = ratio 4) never read stale data mid-fade.
|
||||
// - maxLag_: the alignment search half-range — one window/4 covers a full period of any
|
||||
// tone down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
|
||||
// - dLow_/dHigh_: the safe delay band; unity parks the tap mid-band (window/2 delay).
|
||||
// - corrFrames_: the correlation segment length. At an up-splice the reference segment
|
||||
// reads FORWARD from the tap at delay ~dLow_, so dLow_-1 frames is exactly what exists
|
||||
// between the tap and the writer — the cap expresses that safety rather than leaving
|
||||
// it coincidental. 512 bounds the splice burst.
|
||||
// - fadeFrames_: nominal splice crossfade; only safe while the outgoing tap can't reach
|
||||
// the writer before the fade ends. splice() scales fadeLen_ down by ratio for up-shifts
|
||||
// past ~2x so ordinary transpositions (+24 st) never read stale data mid-fade.
|
||||
// - maxLag_: alignment search half-range — one window/4 covers a full period of any tone
|
||||
// down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
|
||||
// - dLow_/dHigh_: safe delay band; unity parks the tap mid-band (window/2 delay).
|
||||
// - corrFrames_: at an up-splice the reference segment reads forward from the tap at
|
||||
// delay ~dLow_, so dLow_-1 is exactly what exists between tap and writer; 512 bounds
|
||||
// the splice burst.
|
||||
fadeFrames_ = std::max<std::int64_t>(window_ / 4, 1);
|
||||
maxLag_ = window_ / 4;
|
||||
dLow_ = window_ / 4;
|
||||
@@ -77,10 +66,9 @@ void PitchShifter::configure(std::int64_t windowFrames) {
|
||||
|
||||
void PitchShifter::reset() {
|
||||
if (window_ > 1) {
|
||||
// Zero the ring and seed the active tap one window behind the writer — the exact
|
||||
// middle of the safe band [dLow, dHigh] = [w/4, 2w - w/4], so unity holds it there
|
||||
// forever and either shift direction has maximal drift room. No history is declared
|
||||
// (filled_ = 0): follow with prime() or warm() before streaming.
|
||||
// Seed the active tap one window behind the writer — the exact middle of the safe
|
||||
// band [dLow, dHigh], so unity holds it there forever with maximal drift room either
|
||||
// direction. No history declared (filled_ = 0): follow with prime() or warm().
|
||||
std::fill(ring_.begin(), ring_.end(), 0.0f);
|
||||
writePos_ = 0;
|
||||
posA_ = static_cast<double>(ringLen_ - window_);
|
||||
@@ -104,15 +92,12 @@ void PitchShifter::reset() {
|
||||
void PitchShifter::freezeTail() {
|
||||
if (window_ <= 1 || tailFrozen_) return;
|
||||
tailFrozen_ = true;
|
||||
// An in-flight crossfade was sized for a RETREATING writer (outgoing tap drains at
|
||||
// ratio-1 per frame); frozen, the outgoing tap closes at the full ratio. Cap the live
|
||||
// fade so it completes before tap B reaches the parked writer and reads lapped (oldest-
|
||||
// window) content mid-fade. fadePos_ is re-anchored to the same fractional t so gNew is
|
||||
// continuous at the freeze frame (no gain step); see the re-anchor block below.
|
||||
// An in-flight crossfade was sized for a retreating writer (outgoing tap drains at
|
||||
// ratio-1 per frame); frozen, it closes at the full ratio instead. Cap the live fade so
|
||||
// it completes before tap B reaches the parked writer and reads lapped content mid-fade.
|
||||
if (fading_) {
|
||||
// Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the
|
||||
// freeze frame (no gain step). Compute tOld BEFORE overwriting fadeLen_, then
|
||||
// re-anchor fadePos_ to the same fractional position in the new (shorter) fade.
|
||||
// freeze frame (no gain step). Compute tOld before overwriting fadeLen_.
|
||||
const double tOld =
|
||||
static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
|
||||
double dB = static_cast<double>(writePos_) - posB_;
|
||||
@@ -133,8 +118,8 @@ void PitchShifter::freezeTail() {
|
||||
|
||||
void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
|
||||
if (window_ <= 1) return; // pass-through needs no priming
|
||||
// Clamp to one window: the intended call primes exactly window() frames, and delay ==
|
||||
// count must stay inside the safe band so the seed does not itself trigger a splice.
|
||||
// Clamp to one window: delay == count must stay inside the safe band so the seed itself
|
||||
// never triggers a splice.
|
||||
if (count < 0) count = 0;
|
||||
if (count > window_) count = window_;
|
||||
std::fill(ring_.begin(), ring_.end(), 0.0f);
|
||||
@@ -189,24 +174,21 @@ double PitchShifter::readTap(double pos) const {
|
||||
}
|
||||
|
||||
void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
// Relocate the active tap by `nominalJump` frames of ADDED delay (+window_ = jump toward
|
||||
// older content, -window_ = jump toward the writer), refined by a correlation search so
|
||||
// the relocated read point is waveform-aligned with the outgoing tap's upcoming content.
|
||||
// The search is coarse (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best,
|
||||
// then a parabolic sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_
|
||||
// multiply-adds, once per splice.
|
||||
// Relocate the active tap by `nominalJump` frames of added delay (+window_ = toward older
|
||||
// content, -window_ = toward the writer), refined by a correlation search so the relocated
|
||||
// read point is waveform-aligned with the outgoing tap's upcoming content. Search is coarse
|
||||
// (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best, then a parabolic
|
||||
// sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_ multiply-adds, once
|
||||
// per splice.
|
||||
const std::int64_t d = static_cast<std::int64_t>(delay);
|
||||
|
||||
// GA2 onset fix: an up-jump may only relocate into VALID history. The deepest slot the
|
||||
// search (and the +/-1-lag parabolic refinement calls at bestLag ± 1, and the interpolator's
|
||||
// read-ahead) can touch is delay d + jump + maxLag + 2 (maxLag from the coarse/fine search,
|
||||
// +1 for the parabola's outer ± 1 probe, +1 for the interpolator's i1 = i0+1 read-ahead),
|
||||
// so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample LOOSER
|
||||
// than that derived cap (not extra margin); ring indexing wraps via modulo everywhere, so
|
||||
// this never runs off the physical ring_ array. In steady state (filled_ == ringLen_) this is
|
||||
// > window_ and the nominal jump is untouched; near a primed onset it shrinks the jump to
|
||||
// what real history exists (still many source periods with a full-window prime). The floor of
|
||||
// 1 is only reachable on the documented degenerate reset-without-prime path — garbage-tolerant.
|
||||
// An up-jump may only relocate into valid history. The deepest slot the search (plus the
|
||||
// parabola's +/-1 probe and the interpolator's read-ahead) can touch is d + jump + maxLag + 2,
|
||||
// so the cap is filled_ - d - maxLag_ - 1 (one sample looser than that derived bound, not
|
||||
// extra margin — ring indexing wraps via modulo everywhere regardless). In steady state
|
||||
// (filled_ == ringLen_) this exceeds window_ and the nominal jump is untouched; near a primed
|
||||
// onset it shrinks the jump to what real history exists. The floor of 1 only fires on the
|
||||
// degenerate reset-without-prime path.
|
||||
std::int64_t jump = nominalJump;
|
||||
if (jump > 0) {
|
||||
const std::int64_t maxJump = filled_ - d - maxLag_ - 1;
|
||||
@@ -233,12 +215,10 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
if (++ia >= ringLen_) ia = 0;
|
||||
if (++ic >= ringLen_) ic = 0;
|
||||
}
|
||||
// NORMALIZED cross-correlation (standard SOLA): a raw dot product is biased toward
|
||||
// the higher-energy lag, so on a decaying tail every up-splice would prefer the
|
||||
// loudest candidate over the best-ALIGNED one — a small level step per splice that
|
||||
// the amplitude-complementary fade cannot hide. The reference segment's energy is
|
||||
// constant across lags, so dividing by sqrt(Ec) alone ranks identically to the full
|
||||
// normalized form. A zero-energy candidate scores 0 (splicing into silence is benign).
|
||||
// Normalized cross-correlation: a raw dot product biases toward the higher-energy lag,
|
||||
// so on a decaying tail every up-splice would prefer the loudest candidate over the
|
||||
// best-aligned one. The reference segment's energy is constant across lags, so dividing
|
||||
// by sqrt(Ec) alone ranks identically to the full normalized form.
|
||||
return ec > 0.0 ? s / std::sqrt(ec) : 0.0;
|
||||
};
|
||||
|
||||
@@ -261,13 +241,11 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
}
|
||||
}
|
||||
|
||||
// SUB-SAMPLE peak (GA2 alias fix): the integer-lag best leaves a residual misalignment of
|
||||
// up to half a sample; at the splice cadence that residual phase-modulates a pure tone
|
||||
// into a ~-59 dB sideband comb (the DAW spectrogram "alias lines"). A parabola through
|
||||
// the scores at bestLag-1/bestLag/bestLag+1 locates the correlation peak to a fraction of
|
||||
// a sample; readTap()'s linear interpolation realizes the fractional tap position. The
|
||||
// denominator is negative at a genuine peak — anything else (flat correlation: DC or
|
||||
// silence) keeps the integer lag, which is already benign there.
|
||||
// Sub-sample peak: the integer-lag best leaves a residual misalignment of up to half a
|
||||
// sample, which at the splice cadence phase-modulates a pure tone into an audible sideband
|
||||
// comb. A parabola through the scores at bestLag-1/bestLag/bestLag+1 locates the peak to a
|
||||
// fraction of a sample; readTap()'s linear interpolation realizes it. The denominator is
|
||||
// negative at a genuine peak — flat correlation (DC/silence) keeps the integer lag, benign.
|
||||
double frac = 0.0;
|
||||
{
|
||||
const double sM = scoreAt(bestLag - 1);
|
||||
@@ -287,22 +265,17 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
while (p < 0.0) p += len;
|
||||
while (p >= len) p -= len;
|
||||
posA_ = p;
|
||||
// RATIO-SCALED fade length. At an up-splice the OUTGOING tap starts at ~dLow_ delay and
|
||||
// keeps draining toward the writer at (ratio - 1) per output frame; the nominal window/4
|
||||
// fade only keeps it behind the writer for ratios up to 2. Beyond that (e.g. +24 st =
|
||||
// ratio 4, an ordinary sampler transposition) it would cross mid-fade and play stale
|
||||
// read-ahead data at substantial gain — a periodic seam. So cap the live fade at the
|
||||
// frames of drain headroom actually available, minus 2 (1 for the trigger's sub-dLow_
|
||||
// undershoot, 1 for the interpolator's read-ahead). Ratios <= ~2 keep the full nominal
|
||||
// fade; ratio 4 gets ~window/12 — shorter but still a smooth burst. Down-shifts grow the
|
||||
// outgoing delay at (1 - ratio) < 1 per frame and cannot reach the ring end within
|
||||
// window/4 frames, so they always keep the full fade. A pitch-envelope ratio slew
|
||||
// mid-fade is covered by the same margin for any realistic per-frame bias.
|
||||
// Ratio-scaled fade length. At an up-splice the outgoing tap keeps draining toward the
|
||||
// writer at (ratio - 1) per frame; the nominal window/4 fade only keeps it behind the
|
||||
// writer for ratios up to 2 — beyond that (e.g. +24 st = ratio 4) it would cross mid-fade
|
||||
// and play stale read-ahead data. Cap the live fade at the drain headroom actually
|
||||
// available, minus 2 (trigger undershoot + interpolator read-ahead margin). Down-shifts
|
||||
// drain at (1 - ratio) < 1 per frame and can't reach the ring end within window/4 frames,
|
||||
// so they always keep the full fade.
|
||||
//
|
||||
// TAIL-FROZEN (GA3): with the writer parked, the outgoing tap closes on it at the FULL
|
||||
// ratio (there is no retreating write head), in EITHER shift direction — so the drain
|
||||
// rate is ratio_ instead of (ratio_ - 1), and the cap applies at every ratio (unity
|
||||
// included: splices fire in the frozen tail because the delay now drains at unity too).
|
||||
// Tail-frozen: with the writer parked, the outgoing tap closes on it at the full ratio in
|
||||
// either shift direction, so the drain rate is ratio_ instead of (ratio_ - 1) and the cap
|
||||
// applies at every ratio (including unity, since delay now drains at unity too).
|
||||
fadeLen_ = fadeFrames_;
|
||||
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0);
|
||||
if (drainRate > 0.0) {
|
||||
@@ -316,16 +289,15 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||
}
|
||||
fading_ = true;
|
||||
fadePos_ = 0;
|
||||
// Record the decision for a linked follower channel (T1-01): the follower applies this
|
||||
// verbatim so both channels share one lag and one splice schedule.
|
||||
// Record the decision for a linked follower channel — applied verbatim there so both
|
||||
// channels share one lag and one splice schedule.
|
||||
lastSplice_ = SpliceEvent{true, jump, bestLag, frac, fadeLen_};
|
||||
}
|
||||
|
||||
void PitchShifter::applySplice(const SpliceEvent& ev) {
|
||||
// Follower half of the T1-01 linked lag: relocate + fade with the master's decision, no
|
||||
// correlation search of our own. The master's jump was clamped against ITS filled_/delay,
|
||||
// which match ours by the lockstep contract (identical configure/prime/ratio history);
|
||||
// the fade length likewise derives only from shared geometry + ratio.
|
||||
// Follower half of the linked lag: relocate + fade with the master's decision, no
|
||||
// correlation search of our own — the master's jump/fade derive from shared geometry +
|
||||
// ratio, which match ours by the lockstep contract (identical configure/prime/ratio history).
|
||||
posB_ = posA_;
|
||||
double p = posA_ - static_cast<double>(ev.jump) + static_cast<double>(ev.lag) + ev.frac;
|
||||
const double len = static_cast<double>(ringLen_);
|
||||
@@ -347,24 +319,21 @@ AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& maste
|
||||
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) {
|
||||
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
|
||||
|
||||
// Copy the linked decision BEFORE clearing lastSplice_ (guards a self-aliased pointer;
|
||||
// 5 plain fields, negligible on the RT path).
|
||||
// Copy the linked decision before clearing lastSplice_ (guards a self-aliased pointer).
|
||||
const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{};
|
||||
lastSplice_ = SpliceEvent{}; // cleared every frame; set again if this frame splices
|
||||
|
||||
// 1. Write the incoming sample at the write head (source rate). One more slot of the
|
||||
// ring now holds valid history (capped at the ring length once it has wrapped).
|
||||
// TAIL-FROZEN (GA3): the source is exhausted — `in` is padding, not stream. Write
|
||||
// NOTHING (the ring keeps its all-real final two windows) and hold the write head;
|
||||
// the read/splice/fade machinery below runs unchanged over the frozen content.
|
||||
// Tail-frozen: the source is exhausted, `in` is padding, not stream — write nothing (the
|
||||
// ring keeps its all-real final two windows) and hold the write head; read/splice/fade
|
||||
// below run unchanged over the frozen content.
|
||||
if (!tailFrozen_) {
|
||||
ring_[static_cast<std::size_t>(writePos_)] = in;
|
||||
if (filled_ < ringLen_) ++filled_;
|
||||
}
|
||||
|
||||
// 2. Read the active tap; while a splice fade is live, crossfade against the outgoing tap.
|
||||
// Raised-cosine COMPLEMENTARY gains (gNew + gOld == 1): correlation-aligned content is
|
||||
// in phase, so the sum holds unity amplitude through the fade (equal-power would bulge).
|
||||
// Read the active tap; while a splice fade is live, crossfade against the outgoing tap.
|
||||
// Raised-cosine complementary gains (gNew + gOld == 1): correlation-aligned content is in
|
||||
// phase, so the sum holds unity amplitude through the fade (equal-power would bulge).
|
||||
double out = readTap(posA_);
|
||||
if (fading_) {
|
||||
const double t = static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
|
||||
@@ -372,22 +341,17 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
|
||||
out = gNew * out + (1.0 - gNew) * readTap(posB_);
|
||||
if (++fadePos_ >= fadeLen_) fading_ = false;
|
||||
} else if (linked != nullptr) {
|
||||
// 3a. FOLLOWER (T1-01): no trigger test, no search — splice exactly when and how the
|
||||
// master channel did this frame. Lockstep state means our own trigger would have
|
||||
// fired on the same frame; applying the master's decision keeps the two rings
|
||||
// sample-aligned (one shared lag, one shared schedule).
|
||||
// Follower: no trigger test, no search — splice exactly when and how the master did
|
||||
// this frame (lockstep means our own trigger would have fired the same frame anyway).
|
||||
if (linkedEv.fired) {
|
||||
applySplice(linkedEv);
|
||||
} else {
|
||||
// Self-healing fallback (review rider): the master not firing normally means this
|
||||
// channel's own trigger wouldn't fire either (lockstep). But if the processor ever
|
||||
// renders a mono block mid-note, this follower channel is skipped for that block
|
||||
// while the master keeps advancing — its writePos_/filled_ falls behind and, with
|
||||
// only the `if (linkedEv.fired)` path above, could never resync. So check this
|
||||
// follower's OWN tap distance against the safe band and splice via its own search
|
||||
// when it has left [dLow_, dHigh_], exactly as the master would. Reuses splice() —
|
||||
// no allocation, no new RT cost. In the normal (non-mono-block) case this branch
|
||||
// never triggers: the master's trigger fires first and this whole `if` is false.
|
||||
// Self-healing fallback: if the processor ever renders a mono block mid-note, this
|
||||
// follower is skipped for that block while the master keeps advancing, and could
|
||||
// never resync via the `linkedEv.fired` path alone. So also check this follower's
|
||||
// own tap distance against the safe band and splice via its own search when it has
|
||||
// left [dLow_, dHigh_] — never triggers in the normal (non-mono-block) case, since
|
||||
// the master's trigger always fires first.
|
||||
double d = static_cast<double>(writePos_) - posA_;
|
||||
const double len = static_cast<double>(ringLen_);
|
||||
while (d < 0.0) d += len;
|
||||
@@ -399,10 +363,10 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 3. Splice scheduling: relocate when the active tap's delay leaves the safe band.
|
||||
// Up-shifts (ratio > 1) drain the delay toward 0 -> jump one window OLDER; down-
|
||||
// shifts grow it toward the ring length -> jump one window TOWARD the writer. At
|
||||
// unity the delay is frozen at window/2 and neither trigger ever fires.
|
||||
// Splice scheduling: relocate when the active tap's delay leaves the safe band.
|
||||
// Up-shifts drain the delay toward 0 -> jump one window older; down-shifts grow it
|
||||
// toward the ring length -> jump one window toward the writer. At unity the delay is
|
||||
// frozen at window/2 and neither trigger ever fires.
|
||||
double d = static_cast<double>(writePos_) - posA_;
|
||||
const double len = static_cast<double>(ringLen_);
|
||||
while (d < 0.0) d += len;
|
||||
@@ -414,8 +378,7 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Advance heads: write head one frame (source rate; parked while tail-frozen),
|
||||
// tap(s) by the shift ratio.
|
||||
// Advance heads: write head one frame (parked while tail-frozen), tap(s) by the shift ratio.
|
||||
if (!tailFrozen_) {
|
||||
++writePos_;
|
||||
if (writePos_ >= ringLen_) writePos_ = 0;
|
||||
|
||||
@@ -1,66 +1,34 @@
|
||||
#pragma once
|
||||
// pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve"
|
||||
// engine's DSP core. Time-domain delay-line shifter with CORRELATION-ALIGNED SPLICES
|
||||
// (SOLA-style): one active read tap chases the write head at the shift ratio; when it drifts
|
||||
// out of its safe delay band it is relocated by a nominal window jump REFINED BY A
|
||||
// CROSS-CORRELATION SEARCH so the new read point is waveform-aligned, then the old and new
|
||||
// taps are crossfaded (raised-cosine, amplitude-complementary). Source is consumed 1:1 and
|
||||
// output produced 1:1 (duration held); only the PITCH changes — an octave up plays the same
|
||||
// wall-clock length as the root note, unlike the Varispeed `readPos_ += ratio_` resample path.
|
||||
// pitch_shift — per-voice, duration-preserving pitch shifter (the Preserve engine's DSP core).
|
||||
// Time-domain delay-line with correlation-aligned splices (SOLA-style): one active read tap
|
||||
// chases the write head at the shift ratio; when it drifts out of its safe delay band it is
|
||||
// relocated by a nominal window jump, refined by a cross-correlation search so the new read
|
||||
// point is waveform-aligned, then old/new taps crossfade (raised-cosine). Source and output are
|
||||
// both consumed/produced 1:1 — only pitch changes, duration is held (unlike the Varispeed
|
||||
// `readPos_ += ratio_` resample path).
|
||||
//
|
||||
// WHY CORRELATED SPLICES (GA-Preserve fix, 2026-07). The first S16 implementation was the
|
||||
// naive two-tap OLA: taps hard-locked half a window apart, Hann-crossfaded by write-head
|
||||
// distance. Its taps read the same stream at delays differing by exactly w/2, so their outputs
|
||||
// carried a FIXED relative phase of 2*pi*f_src*(w/2) — arbitrary and source-frequency-
|
||||
// dependent. Near anti-phase (roughly half of all frequencies) every crossfade midpoint
|
||||
// nearly CANCELLED: deep periodic AM + phase slew = strong sidebands. A repitched pure sine
|
||||
// came out mangled ("multiple partials" on a spectrogram) while the root stayed clean (unity
|
||||
// freezes the crossfade). The fix is structural: splices must be PHASE-ALIGNED, so each jump
|
||||
// is snapped to the best waveform match within a bounded lag search — a pure sine's jump
|
||||
// lands on an integer period count and the output stays a single shifted tone.
|
||||
// Regression history — do not revert any of these:
|
||||
// - Correlated splices, vs. the original two-tap OLA (taps hard-locked w/2 apart, Hann
|
||||
// crossfaded by write-head distance): that fixed offset gave the two taps a fixed relative
|
||||
// phase, so near-anti-phase source frequencies (roughly half of them) nearly cancelled at
|
||||
// every crossfade midpoint — a repitched pure sine came out mangled while unity stayed clean.
|
||||
// Splices must be phase-aligned (snapped to the best waveform match), not just distance-fired.
|
||||
// - Hand-rolled, not WDL_SimplePitchShifter: its include chain pulls <windows.h> unconditionally,
|
||||
// which cannot enter this REAPER/VST3-free core (sampler_core_tests links neither SDK). Swap
|
||||
// to WDL, if ever wanted, happens at the shell, never in this pure core.
|
||||
// - prime() fills the ring with real upcoming source before streaming starts, not silence: a
|
||||
// silence-warmed ring made every early splice land in zeros — burst/gap/burst stutter at
|
||||
// note onset. Since the caller owns the whole decoded sample up front, prime() can know the
|
||||
// future and gives output frame 0 == source frame 0 with zero structural latency at any ratio.
|
||||
// - freezeTail() parks the write head once the source is exhausted instead of feeding the last
|
||||
// real sample as a DC plateau: splices against a flat plateau are unalignable and produced
|
||||
// ring-modulation-like troughs near the note end. Freezing keeps late splices aligned against
|
||||
// the ring's real frozen tail.
|
||||
//
|
||||
// WHY A HAND-ROLLED PURE MODULE, NOT WDL (S16-F2, decided at build). The spec's lean was
|
||||
// route (a) `WDL_SimplePitchShifter`. But its include chain
|
||||
// (simple_pitchshift.h -> queue.h -> heapbuf.h -> wdltypes.h) does `#ifdef _WIN32 ->
|
||||
// #include <windows.h>` unconditionally, which CANNOT enter the pure sampler_core module
|
||||
// (CLAUDE.md load-bearing split: NO vendor/host/SDK types; sampler_core_tests links neither
|
||||
// SDK and compiles outside the DAW). So the Preserve DSP lands as route (b): a house-native
|
||||
// pure module alongside peaks / wav_codec, CTest-testable, RT-disciplined. Same
|
||||
// PitchEngine::Preserve contract behind the seam — if WDL is ever preferred it swaps in at
|
||||
// the SHELL, never in the pure core.
|
||||
//
|
||||
// WHY PRIME WITH REAL CONTENT (GA2-Preserve onset fix, 2026-07). Splices RELOCATE the tap
|
||||
// into ring HISTORY — at note onset a silence-warmed ring has none, so every early splice
|
||||
// jumped into zeros: a burst/gap/burst stutter for the first ~2 windows of every off-root
|
||||
// note (the DAW "zero-sample gaps in the first few ms"; at +48 st the ~300 Hz gap cadence
|
||||
// reads as a square-ish buzz). But this engine is NOT a streaming context: the caller owns
|
||||
// the whole decoded sample, so the FUTURE of the stream is known at note-on. `prime()`
|
||||
// pre-fills the ring with the actual first window of upcoming source and parks the tap on
|
||||
// its oldest frame — output frame 0 IS source frame 0 (zero structural latency at every
|
||||
// ratio), and `splice()` clamps its jump to the really-filled span so no splice can ever
|
||||
// land in unwritten silence.
|
||||
//
|
||||
// WHY FREEZE THE TAIL (GA3-Preserve tail fix, 2026-07). GA2's prime fixed the ONSET; the
|
||||
// mirror problem lived at the note END. When the source ran out, the caller held the LAST
|
||||
// REAL SAMPLE as the feed — a DC plateau with no waveform for the correlation to align on.
|
||||
// Splices landing in or referenced against it were unalignable, so the tap alternated
|
||||
// real-tone / dead-DC at the splice cadence, the dead fraction growing as the plateau
|
||||
// displaced real ring history (the DAW report: periodic troughs "almost like ring
|
||||
// modulation", ~1:20 tone-to-silence at the very end). freezeTail() removes the padding at
|
||||
// the source: the WRITER parks, the ring keeps its all-real final two windows, and the
|
||||
// aligned-splice machinery recycles that frozen tail — a continuous tone until the caller's
|
||||
// own note end. See freezeTail() below.
|
||||
//
|
||||
// PURE MODULE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes. Standard library only.
|
||||
// Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core /
|
||||
// wav_codec does the same).
|
||||
//
|
||||
// RT DISCIPLINE (S16 hard constraint). `configure()` sizes the ring ONCE (off the audio
|
||||
// thread, at voice allocation). `prime()` / `warm()` only copy into the pre-sized ring
|
||||
// (bounded, allocation-free — safe on the audio thread at note-on). `process()` does
|
||||
// NO allocation and NO locks — it reads/writes the pre-sized ring only. The splice-time
|
||||
// correlation search is a bounded burst of multiply-adds (coarse+refine over a fixed lag
|
||||
// range) that fires once per splice cadence (window / |ratio-1| frames), never per frame.
|
||||
// RT discipline: configure() sizes the ring once, off the audio thread. prime()/warm() only
|
||||
// copy into the pre-sized ring (bounded, allocation-free). process() does no allocation and no
|
||||
// locks; the correlation search is a bounded burst that fires once per splice cadence
|
||||
// (window / |ratio-1| frames), never per frame.
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
@@ -72,100 +40,76 @@ namespace reasampler::instrument::engine {
|
||||
|
||||
using audio::AudioSample;
|
||||
|
||||
// The splice decision made by the most recent process()/processLinked() call — the LINKED-LAG
|
||||
// stereo contract (Q-W0 T1-01). A stereo voice runs channel 0 as the MASTER (full correlation
|
||||
// search) and channel 1 as the FOLLOWER: after the master's process() for a frame, the caller
|
||||
// passes master.lastSplice() to the follower's processLinked() for the SAME frame, and the
|
||||
// follower applies exactly this decision instead of running its own search. Both channels
|
||||
// therefore share one lag and one splice schedule (standard stereo SOLA) — per-channel
|
||||
// independent searches re-drew an inter-channel offset of up to +/-maxLag at every splice:
|
||||
// stereo image wander at the splice cadence plus comb coloration on any mono sum.
|
||||
// The splice decision made by the most recent process()/processLinked() call — the linked-lag
|
||||
// stereo contract. A stereo voice runs channel 0 as the master (full correlation search) and
|
||||
// channel 1 as the follower: after the master's process() for a frame, the caller passes
|
||||
// master.lastSplice() to the follower's processLinked() for the same frame, and the follower
|
||||
// applies exactly this decision instead of running its own search. Both channels therefore
|
||||
// share one lag and one splice schedule — independent per-channel searches drew an inter-channel
|
||||
// offset of up to +/-maxLag at every splice, causing stereo image wander and comb coloration on
|
||||
// a mono sum.
|
||||
struct SpliceEvent {
|
||||
bool fired = false; // a splice was scheduled on this frame
|
||||
std::int64_t jump = 0; // the CLAMPED nominal jump actually applied (signed)
|
||||
std::int64_t jump = 0; // the clamped nominal jump actually applied (signed)
|
||||
std::int64_t lag = 0; // correlation best integer lag
|
||||
double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5]
|
||||
std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen
|
||||
};
|
||||
|
||||
// A per-channel time-domain splice-aligned pitch shifter. One instance transposes ONE channel;
|
||||
// a stereo voice owns two, LINKED: channel 0 is the master, channel 1 follows its splice
|
||||
// decisions via processLinked() (see SpliceEvent above) so the two rings stay sample-aligned.
|
||||
// A per-channel time-domain splice-aligned pitch shifter. A stereo voice owns two, linked:
|
||||
// channel 0 is the master, channel 1 follows its splice decisions via processLinked() so the
|
||||
// two rings stay sample-aligned.
|
||||
//
|
||||
// The default-constructed shifter is INERT: with no configure() it passes input through
|
||||
// unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is
|
||||
// byte-identical to the pre-S16 engine.
|
||||
// Default-constructed is inert: with no configure() it passes input through unchanged (ratio
|
||||
// 1.0, empty ring), so a Varispeed voice that never touches it sees no behavior change.
|
||||
class PitchShifter {
|
||||
public:
|
||||
// Size the delay ring for `windowFrames` (the nominal splice-jump length; the ring is 2x
|
||||
// that for splice/search headroom) and derive the fade/search geometry. `windowFrames`
|
||||
// <= 1 degrades to pass-through (no ring), so a degenerate configure never divides by
|
||||
// zero or wraps a zero span. Called OFF the audio thread (allocates). Resets all running
|
||||
// state. A larger window = fewer splices and a deeper alignment search; a PRIMED shifter
|
||||
// has no added latency regardless (see prime()); the shell picks it from kPreserveWindowMs.
|
||||
// Sizes the delay ring for `windowFrames` (the ring is 2x that for splice/search headroom).
|
||||
// <= 1 degrades to pass-through. Off the audio thread (allocates); resets all running state.
|
||||
// A larger window means fewer splices and a deeper alignment search; a primed shifter has no
|
||||
// added latency regardless of window size (see prime()).
|
||||
void configure(std::int64_t windowFrames);
|
||||
|
||||
// Pre-fill the ring with the first `count` frames of the UPCOMING source stream and park
|
||||
// the tap on src[0] (delay == count, mid safe band at count == window()). The caller then
|
||||
// feeds process() the stream CONTINUING at src[count]. Output frame 0 is src[0]: ZERO
|
||||
// structural latency at every ratio, and splices always have `count` frames of real
|
||||
// history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()].
|
||||
// When the PLAYABLE source is shorter than one window, prime only the real span and call
|
||||
// freezeTail() immediately after (Q-W0 T1-03): the GA3 machinery then recycles the real
|
||||
// short tail. Do NOT pad with silence and declare it valid — padded zeros inside the ring
|
||||
// are splice targets, re-creating the pre-GA2 burst/gap onset on sub-window material.
|
||||
// RT-safe: bounded copy into the pre-sized ring, no allocation. No-op when unconfigured.
|
||||
// The current shift ratio is left untouched.
|
||||
// Pre-fills the ring with the first `count` frames of the upcoming source stream and parks
|
||||
// the tap on src[0]; the caller then feeds process() the stream continuing at src[count].
|
||||
// `count` is clamped to [0, window()]. If the playable source is shorter than one window,
|
||||
// prime only the real span and call freezeTail() immediately after — never pad with silence
|
||||
// and declare it valid; padded zeros are splice targets and reintroduce the onset gap.
|
||||
// RT-safe: bounded copy, no allocation. No-op when unconfigured; ratio is left untouched.
|
||||
void prime(const AudioSample* src, std::int64_t count);
|
||||
|
||||
// prime()-with-silence: zero the ring, park the tap one window behind the writer, and
|
||||
// declare that window of silence as valid history. Kept for callers with no access to the
|
||||
// upcoming stream (a silence-primed up-shift plays ~a window of silence before speaking —
|
||||
// the pre-GA2 onset; the Voice path uses prime() instead). At unity a warmed shifter is a
|
||||
// bit-exact window() delay. No-op when unconfigured.
|
||||
// Silence-prime: zero the ring, park the tap one window behind the writer, declare that
|
||||
// window silence as valid history. Kept for callers with no access to the upcoming stream
|
||||
// (a silence-primed up-shift plays ~a window of silence before speaking; the Voice path uses
|
||||
// prime() instead). At unity a warmed shifter is a bit-exact window() delay.
|
||||
void warm();
|
||||
|
||||
// The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias.
|
||||
// 1.0 = no shift (pass-through-equivalent output, no splices ever fire). Set per frame is
|
||||
// fine (cheap); the tap advance simply uses the current value. Values <= 0 are ignored
|
||||
// (kept at the last valid ratio) so a bad input never runs the tap backward or stalls it.
|
||||
// 2^((note - root)/12) plus any per-frame pitch-envelope bias; 1.0 = no shift, no splices
|
||||
// ever fire. Cheap enough to set per frame. Values <= 0 are ignored (kept at the last valid
|
||||
// ratio) so a bad input never runs the tap backward or stalls it.
|
||||
void setShiftRatio(double ratio);
|
||||
|
||||
// Transform ONE input frame into ONE output frame (duration-preserving: 1 in, 1 out).
|
||||
// RT-safe: reads/writes the pre-sized ring only, no allocation, no lock. When unconfigured
|
||||
// (window <= 1) returns `in` unchanged (pass-through). Otherwise writes `in` at the write
|
||||
// head, reads the active tap (crossfading against the outgoing tap while a splice fade is
|
||||
// live), then advances the write head by one and the tap(s) by the shift ratio. When the
|
||||
// active tap leaves its safe delay band, a correlation-aligned splice is scheduled.
|
||||
// Transforms one input frame into one output frame (1 in, 1 out). RT-safe: reads/writes the
|
||||
// pre-sized ring only, no allocation, no lock. Unconfigured returns `in` unchanged. Otherwise
|
||||
// writes `in` at the write head, reads the active tap (crossfading against the outgoing tap
|
||||
// during a splice fade), then advances the write head and tap(s). When the active tap leaves
|
||||
// its safe delay band, a correlation-aligned splice is scheduled.
|
||||
AudioSample process(AudioSample in);
|
||||
|
||||
// FOLLOWER-mode process (Q-W0 T1-01, the stereo linked lag): identical to process()
|
||||
// except the splice decision is NOT computed here — when `master.fired` is true this
|
||||
// frame splices with exactly the master's jump/lag/frac/fadeLen; otherwise no splice is
|
||||
// considered. The caller must process the master channel FIRST each frame and pass its
|
||||
// lastSplice() here, with both shifters configured/primed/ratio'd identically — their
|
||||
// ring state then advances in lockstep, so the follower's own trigger would have fired
|
||||
// on the same frame anyway; skipping its search only removes the second correlation
|
||||
// burst (strictly cheaper, never costlier). RT-safe: same guarantees as process().
|
||||
// Follower-mode process: identical to process() except the splice decision isn't computed
|
||||
// here — when `master.fired` is true this frame splices with exactly the master's
|
||||
// jump/lag/frac/fadeLen. The caller must process the master channel first each frame and
|
||||
// pass its lastSplice() here; both shifters must be configured/primed/ratio'd identically so
|
||||
// their ring state advances in lockstep. RT-safe: same guarantees as process().
|
||||
AudioSample processLinked(AudioSample in, const SpliceEvent& master);
|
||||
|
||||
// The splice decision made by the most recent process()/processLinked() call (fired ==
|
||||
// false when that frame spliced nothing). Feed to a follower channel's processLinked().
|
||||
const SpliceEvent& lastSplice() const { return lastSplice_; }
|
||||
|
||||
// TAIL WIND-DOWN (GA3, 2026-07). Call when the SOURCE STREAM IS EXHAUSTED — no real frame
|
||||
// remains to feed process(). Freezes the WRITE head: subsequent process() calls ignore
|
||||
// their input and write nothing, but read, splice, and crossfade exactly as before over
|
||||
// the ring's frozen (all-real) final two windows. WHY: the pre-GA3 tail held the last
|
||||
// real sample as the feed — a DC plateau with no waveform to correlate on. Splices
|
||||
// landing in or referenced against it were unalignable, so the tap alternated real-tone /
|
||||
// dead-DC at the splice cadence (the DAW "ring modulation" troughs, growing toward the
|
||||
// note end as the plateau displaced real history). With the writer frozen the padding
|
||||
// never enters the ring: every splice stays waveform-aligned against real content and
|
||||
// the output remains a continuous tone — the final <= one window recycles the frozen
|
||||
// tail (correlation-aligned, crossfaded) instead of decaying into chopped DC, and the
|
||||
// caller's own note end (its output-frame anchor) bounds how long that lasts. Idempotent;
|
||||
// RT-safe (flag + bounded arithmetic, no allocation); cleared by reset()/prime()/warm().
|
||||
// Call once the source stream is exhausted — no real frame remains to feed process().
|
||||
// Freezes the write head: subsequent process() calls ignore input and write nothing, but
|
||||
// read/splice/crossfade as before over the ring's frozen (all-real) final two windows, so
|
||||
// every late splice stays waveform-aligned against real content instead of a DC plateau.
|
||||
// Idempotent; RT-safe (flag + bounded arithmetic); cleared by reset()/prime()/warm().
|
||||
void freezeTail();
|
||||
|
||||
bool tailFrozen() const { return tailFrozen_; }
|
||||
@@ -188,8 +132,8 @@ private:
|
||||
// the writer (the caller just computed it for the trigger test). Records the decision in
|
||||
// lastSplice_ for a linked follower channel.
|
||||
void splice(std::int64_t nominalJump, double delay);
|
||||
// Apply a master channel's already-computed splice decision verbatim (no search) —
|
||||
// the follower half of the T1-01 linked-lag contract. Mirrors it into lastSplice_.
|
||||
// Applies a master channel's already-computed splice decision verbatim (no search) —
|
||||
// the follower half of the linked-lag contract. Mirrors it into lastSplice_.
|
||||
void applySplice(const SpliceEvent& ev);
|
||||
// Shared body of process()/processLinked(); `linked` null = master mode (own trigger +
|
||||
// search), non-null = follower mode (splice iff linked->fired, with linked's decision).
|
||||
@@ -210,21 +154,19 @@ private:
|
||||
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4)
|
||||
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so
|
||||
// the reference read forward from the tap stays behind
|
||||
// the writer BY CONSTRUCTION at an up-splice)
|
||||
// the writer by construction at an up-splice)
|
||||
std::int64_t dLow_ = 0; // splice trigger: active-tap delay below this (up-shift)
|
||||
std::int64_t dHigh_ = 0; // splice trigger: active-tap delay above this (down-shift)
|
||||
std::int64_t filled_ = 0; // frames of VALID history behind the writer (prime count
|
||||
// + frames streamed, capped at ringLen_). splice() clamps
|
||||
// its up-jump to this so no splice lands in unwritten
|
||||
// silence — the GA2 onset-gap fix.
|
||||
std::int64_t filled_ = 0; // frames of valid history behind the writer; splice()
|
||||
// clamps its up-jump to this so it never lands in
|
||||
// unwritten silence
|
||||
double ratio_ = 1.0; // current shift ratio (>0)
|
||||
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame (T1-01):
|
||||
// cleared at the top of every frame, set on a splice
|
||||
bool tailFrozen_ = false; // GA3 wind-down: writer frozen (source exhausted); the tap
|
||||
// recycles the ring's frozen real tail, splices still
|
||||
// aligned. With the writer parked, a tap drains toward it
|
||||
// at ratio_ (not ratio_-1) per frame — splice() scales the
|
||||
// live fade by that rate.
|
||||
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame; cleared
|
||||
// at the top of every frame, set on a splice
|
||||
bool tailFrozen_ = false; // writer frozen (source exhausted); tap recycles the
|
||||
// frozen real tail, drains toward the writer at ratio_
|
||||
// (not ratio_-1) per frame — splice() scales the fade
|
||||
// by that rate
|
||||
};
|
||||
|
||||
} // namespace reasampler::instrument::engine
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
// sampler_core — pure sampler engine implementation. See sampler_core.h for the
|
||||
// contract and the design rationale (keymap resolution, pitch ratio, ADSR shape,
|
||||
// voice allocation + stealing policy). NO VST3 / REAPER / SWELL / vendor includes.
|
||||
// sampler_core — pure sampler engine implementation. See sampler_core.h for the contract.
|
||||
//
|
||||
// DOCUMENTED HOT-PATH EXCEPTION to the Phase Q ~600-line file ceiling (Q-W2v,
|
||||
// T4-14/T4-27 — Daniel-settled 2026-07-28): this TU deliberately STAYS WHOLE.
|
||||
// AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called
|
||||
// per-voice-per-sample from Voice::advanceFrame, which is called per-sample from
|
||||
// VoiceEngine::render — same-TU definition is what lets the compiler inline that
|
||||
// stack (the build configures NO LTO). A by-class TU split would put the hottest
|
||||
// inner loop across TU boundaries — the exact heuristic-(3) dispatch blowout the
|
||||
// phase forbids. Do NOT "fix" this file's length; the header is split instead
|
||||
// (zone_params.h carries the shared value structs).
|
||||
// Documented hot-path exception to the ~600-line file ceiling: this TU deliberately stays
|
||||
// whole. AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called
|
||||
// per-voice-per-sample from Voice::advanceFrame, called per-sample from VoiceEngine::render
|
||||
// — same-TU definition is what lets the compiler inline that stack (no LTO configured). A
|
||||
// by-class TU split would put the hottest inner loop across TU boundaries. Do not split
|
||||
// this file further; the header is split instead (zone_params.h carries the value structs).
|
||||
|
||||
#include "core/instrument/engine/sampler_core.h"
|
||||
|
||||
@@ -28,11 +23,8 @@ double pitchRatio(int note, int rootNote) {
|
||||
}
|
||||
|
||||
double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
||||
// Scale the semitone offset by keyTrack before the ET conversion. keyTrack == 1.0 yields
|
||||
// (note-root)*1.0, which is EXACT in IEEE-754 for an integer-valued double, so the argument
|
||||
// to std::pow is bit-identical to pitchRatio(note, rootNote) — the 100% default is byte-for-
|
||||
// byte unchanged from the pre-S-VIEW-6 engine. keyTrack == 0.0 -> offset 0 -> ratio 1.0 on
|
||||
// every key (no tracking); keyTrack == 2.0 -> doubled offset. Root note stays unity always.
|
||||
// keyTrack == 1.0 yields (note-root)*1.0, exact in IEEE-754 for an integer-valued double,
|
||||
// so the argument to std::pow is bit-identical to pitchRatio(note, rootNote).
|
||||
const double semis = static_cast<double>(note - rootNote) * keyTrack;
|
||||
return std::pow(2.0, semis / 12.0);
|
||||
}
|
||||
@@ -105,8 +97,7 @@ double AdsrEnvelope::tick() {
|
||||
const double out = level_;
|
||||
++framesInStage_;
|
||||
if (framesInStage_ >= params_.attackFrames) {
|
||||
// S15: Attack -> Hold (holds 1.0 for holdFrames). holdFrames == 0 falls straight
|
||||
// through Hold on the next tick to Decay, which is EXACTLY the pre-S15 A->D path.
|
||||
// holdFrames == 0 falls straight through Hold on the next tick to Decay.
|
||||
stage_ = Stage::Hold;
|
||||
framesInStage_ = 0;
|
||||
level_ = 1.0;
|
||||
@@ -115,16 +106,13 @@ double AdsrEnvelope::tick() {
|
||||
}
|
||||
|
||||
case Stage::Hold: {
|
||||
// S15 hold stage: level pinned at 1.0 for holdFrames. holdFrames <= 0 leaves the
|
||||
// stage on this same tick (no frame consumed at 1.0 beyond what Attack already
|
||||
// emitted), so hold=0 is byte-identical to the pre-S15 envelope.
|
||||
// holdFrames <= 0 leaves the stage on this same tick (no frame consumed at 1.0
|
||||
// beyond what Attack already emitted) so a zero-length hold emits no extra sample.
|
||||
if (params_.holdFrames <= 0) {
|
||||
stage_ = Stage::Decay;
|
||||
framesInStage_ = 0;
|
||||
// Fall through to Decay this frame so no extra unity sample is emitted for a
|
||||
// zero-length hold (preserving the exact pre-S15 sample-for-sample shape).
|
||||
level_ = 1.0;
|
||||
// Single re-dispatch into Decay (bounded: Hold→Decay only; not a general recursion).
|
||||
// Single re-dispatch into Decay (bounded: Hold->Decay only, not general recursion).
|
||||
return tick();
|
||||
}
|
||||
level_ = 1.0;
|
||||
@@ -183,7 +171,7 @@ double AdsrEnvelope::tick() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TriggerEnvelope (S15) — a time-boxed fade-in/hold/fade-out amplitude function.
|
||||
// TriggerEnvelope — a time-boxed fade-in/hold/fade-out amplitude function.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void TriggerEnvelope::configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
@@ -233,7 +221,7 @@ double TriggerEnvelope::amplitudeAt(double sourceOffset) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PitchEnvelope (S16) — AD pitch offset in semitones, off when disabled.
|
||||
// PitchEnvelope — AD pitch offset in semitones, off when disabled.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
double PitchEnvelope::tick() {
|
||||
@@ -263,10 +251,10 @@ double PitchEnvelope::tick() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void Voice::presizePreserveShifters(std::int64_t windowFrames) {
|
||||
// OFF the audio thread (allocates). Both channels are sized so a stereo Preserve voice needs
|
||||
// no allocation at note-on; a mono Preserve voice simply never process()es shiftR_. The
|
||||
// prime scratch (one window, reused per channel) is sized here for the same reason: start()
|
||||
// assembles the first window of the upcoming source stream into it with zero allocation.
|
||||
// Off the audio thread (allocates). Both channels are sized so a stereo Preserve voice
|
||||
// needs no allocation at note-on; a mono voice simply never process()es shiftR_. The
|
||||
// prime scratch is sized here for the same reason: start() assembles the first window
|
||||
// of the upcoming source into it with zero allocation.
|
||||
shiftL_.configure(windowFrames);
|
||||
shiftR_.configure(windowFrames);
|
||||
primeBuf_.assign(windowFrames > 1 ? static_cast<std::size_t>(windowFrames) : 0, 0.0f);
|
||||
@@ -282,20 +270,16 @@ bool Voice::sustainLoopUsable() const {
|
||||
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
double keyTrack, const VelocityCurve& velocityCurve,
|
||||
bool declickTakeover) {
|
||||
// Takeover declick (Phase S GA fix, rev 2): BEFORE any state reset, record the PRE-CUT
|
||||
// REFERENCE — the last rendered output — and mark the compensation PENDING iff this
|
||||
// start is a takeover/steal of a SOUNDING voice and the caller opted in. The ramp itself
|
||||
// is seeded on the FIRST frame rendered after the restart, from the DIFFERENCE between
|
||||
// this reference and the new voice's raw output that frame (seedDeclick), so the
|
||||
// boundary frame reproduces the old level EXACTLY — whatever the new envelope does
|
||||
// (Gate attack, zero attack, Trigger's no-fade-in instant-unity onset) and whatever
|
||||
// value the new sample starts on. [Rev 1 seeded the OLD value here and gated the add by
|
||||
// (1 − newAmp) in the epilogue: every restart whose new amplitude was instantly ~1 got
|
||||
// ZERO compensation and kept the full click — exactly the DAW-reported mono-retrig case
|
||||
// on Trigger / zero-attack zones.] A fresh start (idle voice) clears the declick state —
|
||||
// no phantom ramp. lastOut{L,R}_ are deliberately NOT zeroed here: a SECOND same-block
|
||||
// takeover (two steals of this voice with no frame rendered between) must record the
|
||||
// same pre-cut reference, not a phantom 0. The next rendered frame overwrites lastOut.
|
||||
// Before any state reset, record the pre-cut reference (last rendered output) and mark
|
||||
// the compensation pending iff this start is a takeover/steal of a sounding voice and the
|
||||
// caller opted in. The ramp is seeded on the first frame rendered after the restart, from
|
||||
// the difference between this reference and the new voice's raw output that frame
|
||||
// (seedDeclick), so the boundary frame reproduces the old level exactly regardless of the
|
||||
// new envelope's first value. (An earlier revision gated the add by (1 - newAmp): any
|
||||
// restart whose new amplitude was instantly ~1 got zero compensation and kept the full
|
||||
// click.) A fresh start (idle voice) clears the declick state. lastOut{L,R}_ are
|
||||
// deliberately not zeroed here: a second same-block takeover (two steals with no frame
|
||||
// rendered between) must record the same pre-cut reference, not a phantom 0.
|
||||
if (declickTakeover && active_) {
|
||||
// Clamp the reference to ±1.0 full scale: a bounded seed whatever the voice was doing.
|
||||
declickRefL_ = (lastOutL_ > 1.0) ? 1.0 : (lastOutL_ < -1.0) ? -1.0 : lastOutL_;
|
||||
@@ -313,14 +297,11 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
|
||||
releasing_ = false;
|
||||
amplitudeDone_ = false;
|
||||
note_ = note;
|
||||
// S-VIEW-9: the velocity->amp transfer curve maps MIDI velocity to gain, ONCE at note-on (the
|
||||
// per-frame render just multiplies the cached velocityGain_ — no new process-thread work). The
|
||||
// clamp lives inside eval (velocity box-clamped to [0,127]). Replaces the pre-r10 linear
|
||||
// velocity/127; the default flat y=1 curve (R10-F1 Option A) plays every velocity at unity.
|
||||
// Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
|
||||
// velocityGain_.
|
||||
velocityGain_ = velocityCurve.eval(static_cast<double>(velocity));
|
||||
// S-VIEW-6: the key-tracked repitch ratio feeds BOTH engines through baseRatio_ (Varispeed
|
||||
// read-rate bias and Preserve shift amount both derive from it below). keyTrack == 1.0 is
|
||||
// the pre-S-VIEW-6 pitchRatio bit-for-bit.
|
||||
// Feeds both engines through baseRatio_ (Varispeed read-rate bias and Preserve shift
|
||||
// amount both derive from it below).
|
||||
baseRatio_ = keyTrackedRatio(note, rootNote, keyTrack);
|
||||
sample_ = &sample;
|
||||
|
||||
@@ -328,25 +309,17 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
|
||||
playMode_ = p.playMode;
|
||||
pitchEngine_ = p.pitchEngine;
|
||||
|
||||
// Initial read position honors the sample's start-point offset (S11), in BOTH modes. Clamp
|
||||
// into [0, frames): a start at or past the end degrades to 0 (play from the top) rather than
|
||||
// starting a voice already off the end. A negative start (shouldn't occur) is pinned to 0.
|
||||
// Clamp into [0, frames): a start at or past the end degrades to 0 (play from the top)
|
||||
// rather than starting a voice already off the end.
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
|
||||
std::int64_t start = sample.startFrame;
|
||||
if (start < 0 || start >= frameCount) start = 0;
|
||||
readPos_ = static_cast<double>(start);
|
||||
startFrame_ = start; // Trigger fade offset origin (readPos - startFrame = span offset)
|
||||
|
||||
// --- Amplitude envelope: Gate = AHDSR (fully per-zone: A/H/D/S/R all read from the zone's
|
||||
// play.adsr); Trigger = the time-boxed fade-in/out over the % play length.
|
||||
//
|
||||
// All five AHDSR fields come from sample.play.adsr (in FRAMES), resolved by
|
||||
// buildTier0Keymap / buildZonedKeymap at reload time from the stored SECONDS against
|
||||
// the live sample rate.
|
||||
//
|
||||
// Back-compat invariant: a zone whose stored ADSR seconds carry the tier-0 defaults
|
||||
// (resolved to frames at the live sample rate) sounds identical to the pre-S12 build at
|
||||
// every DAW rate — now trivially true, since the times are wall-clock seconds. ---
|
||||
// Amplitude envelope: Gate = AHDSR (all five fields read from the zone's play.adsr,
|
||||
// resolved to frames from stored seconds at reload time); Trigger = the time-boxed
|
||||
// fade-in/out over the % play length.
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
env_.configure(p.adsr);
|
||||
env_.noteOn();
|
||||
@@ -366,38 +339,34 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
|
||||
kDefaultFadeCurve);
|
||||
}
|
||||
|
||||
// --- Pitch envelope (S16): per-voice AD, off by default (offset always 0). ---
|
||||
pitchEnv_.configure(p.pitchEnv);
|
||||
pitchEnv_.noteOn();
|
||||
|
||||
// --- Preserve engine (S16, GA2 onset fix): PRIME the ALREADY-SIZED per-channel shifters
|
||||
// with the first window of the ACTUAL upcoming source stream (loop-unrolled under the
|
||||
// sustain-loop wrap rule, silence past the sample end — that silence IS the true
|
||||
// stream there). The tap parks on source frame `start`, so the voice speaks on output
|
||||
// frame 0 at EVERY ratio (no ring-fill silence), and every splice has a full window
|
||||
// of real history to land in — the fix for the DAW onset zero-gaps (a silence-warmed
|
||||
// ring made every early splice jump into zeros). The rings and the prime scratch were
|
||||
// allocated off-thread by presizePreserveShifters (the engine calls it at
|
||||
// construction); this path is a bounded copy — NO allocation here. Varispeed voices
|
||||
// never touch the shifters (advanceFrame checks configured()), so a Varispeed
|
||||
// instrument is byte-identical to pre-S16 and pays no per-frame shifter cost. ---
|
||||
// Prime the already-sized per-channel shifters with the first window of the actual
|
||||
// upcoming source stream (loop-unrolled under the sustain-loop wrap rule; silence past
|
||||
// the sample end, since that silence is the true stream there). The tap parks on source
|
||||
// frame `start`, so the voice speaks on output frame 0 at every ratio, and every splice
|
||||
// has a full window of real history to land in — a silence-warmed ring instead makes
|
||||
// every early splice jump into zeros (burst/gap onset). The rings and prime scratch were
|
||||
// allocated off-thread by presizePreserveShifters; this path is a bounded copy, no
|
||||
// allocation. Varispeed voices never touch the shifters, so a Varispeed instrument pays
|
||||
// no per-frame shifter cost.
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
const std::int64_t w = shiftL_.window();
|
||||
const bool loopWrap = sustainLoopUsable();
|
||||
const SampleLoop& loop = sample.loop;
|
||||
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
|
||||
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
|
||||
// Q-W0 T1-03: the prime may only carry PLAYABLE source. The per-frame feed stops at
|
||||
// feedBound (playEnd_ for a bounded Trigger span, the sample end for Gate) and
|
||||
// freezes the writer there (GA3) — but the prime used to pull a FULL window bounded
|
||||
// only by frameCount: a Trigger ring held real PCM past the user's chosen stop (an
|
||||
// up-shifted tap could play it, transposed, before the voice freed), and a
|
||||
// shorter-than-window sample got zero padding declared as valid history (splices
|
||||
// landing in silence — the pre-GA2 burst/gap onset, re-entered for sub-window
|
||||
// material). So bound the prime by the same playable span and, when that span is
|
||||
// shorter than a window, freeze the tail IMMEDIATELY after the prime — the GA3
|
||||
// machinery then recycles the real short tail, its designed behavior. The sustain-
|
||||
// loop path is unbounded by construction (the wrap keeps q inside the loop forever).
|
||||
// The prime may only carry playable source. The per-frame feed stops at feedBound
|
||||
// (playEnd_ for a bounded Trigger span, the sample end for Gate) and freezes the
|
||||
// writer there — but a full window bounded only by frameCount would let a Trigger
|
||||
// ring hold real PCM past the user's chosen stop (an up-shifted tap could play it,
|
||||
// transposed, before the voice freed), and a shorter-than-window sample would get
|
||||
// zero padding declared as valid history (splices landing in silence). So bound the
|
||||
// prime by the same playable span and, when that span is shorter than a window,
|
||||
// freeze the tail immediately after the prime — that machinery then recycles the
|
||||
// real short tail. The sustain-loop path is unbounded by construction (the wrap
|
||||
// keeps q inside the loop forever).
|
||||
const std::int64_t primeBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
@@ -422,11 +391,11 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
|
||||
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
|
||||
if (ch == 0) p = q; // capture the end position once from channel 0's walk
|
||||
}
|
||||
// Per-frame feed continues at `p` (== the feed bound when the prime exhausted the
|
||||
// playable span — advanceFrame's own exhaustion test then holds from frame 0).
|
||||
// Per-frame feed continues at `p` (the feed bound when the prime exhausted the
|
||||
// playable span).
|
||||
feedPos_ = p;
|
||||
if (!loopWrap && primeCount < w) {
|
||||
// Sub-window playable span: the source is ALREADY exhausted at prime time.
|
||||
// Sub-window playable span: the source is already exhausted at prime time.
|
||||
shiftL_.freezeTail();
|
||||
if (stereoSample) shiftR_.freezeTail();
|
||||
}
|
||||
@@ -447,28 +416,26 @@ void Voice::retune(int note, int rootNote, double keyTrack) {
|
||||
|
||||
void Voice::release() {
|
||||
if (!active_) return;
|
||||
// TRIGGER ignores note-off entirely (S15): the one-shot plays through to its play length.
|
||||
if (playMode_ == PlayMode::Trigger) return;
|
||||
if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
|
||||
releasing_ = true;
|
||||
env_.noteOff();
|
||||
}
|
||||
|
||||
void Voice::hardStop() {
|
||||
// CC 120 (All Sounds Off): immediate silence regardless of play mode. Stops Trigger one-shots
|
||||
// that ignore release(), and short-circuits Gate release tails. RT-safe: no allocation.
|
||||
// Immediate silence regardless of play mode: stops Trigger one-shots that ignore
|
||||
// release(), and short-circuits Gate release tails. RT-safe: no allocation.
|
||||
active_ = false;
|
||||
}
|
||||
|
||||
double Voice::tickAmplitude() {
|
||||
double amp;
|
||||
if (playMode_ == PlayMode::Gate) {
|
||||
// AHDSR is wall-clock (one tick per output frame), independent of the read rate.
|
||||
amp = env_.tick();
|
||||
if (env_.finished()) amplitudeDone_ = true;
|
||||
} else {
|
||||
// Trigger fade shape anchored to the SOURCE offset (readPos - startFrame), so the fades
|
||||
// land on the same source frames under either engine's read rate. The voice ALSO frees on
|
||||
// readPos_ >= playEnd_ in advanceFrame; finished() here is the belt to that suspenders.
|
||||
// Anchored to the source offset so fades land on the same source frames under either
|
||||
// engine's read rate. The voice also frees on readPos_ >= playEnd_ in advanceFrame;
|
||||
// finished() here is the belt to that suspenders.
|
||||
amp = trigEnv_.amplitudeAt(readPos_ - static_cast<double>(startFrame_));
|
||||
if (trigEnv_.finished()) amplitudeDone_ = true;
|
||||
}
|
||||
@@ -476,34 +443,26 @@ double Voice::tickAmplitude() {
|
||||
}
|
||||
|
||||
void Voice::seedDeclick(double newOutL, double newOutR) {
|
||||
// First frame after a takeover restart: ARM the bounded blend. The weight starts at 1.0
|
||||
// First frame after a takeover restart: arm the bounded blend. The weight starts at 1.0
|
||||
// so this frame's output is `out*(1-1) + ref*1 == ref` — exact boundary identity whatever
|
||||
// the new envelope's first value. Each subsequent frame adds `w*(ref − outCurrent)` then
|
||||
// decays w, so output is provably bounded by max(|ref|, |outCurrent|) — mid-ramp overshoot
|
||||
// is impossible even if outCurrent rises while the weight is still significant.
|
||||
// [Rev 1 stored the frozen difference (ref − x₀); if outₙ rose while that residue was
|
||||
// still large the sum could exceed full scale. The ±2.0 clamp there was the only guard
|
||||
// and it silently broke the boundary identity when |x₀| > 1. The bounded blend removes
|
||||
// both the overshoot hole and the need for a clamp on the stored value.]
|
||||
// newOutL/R are used only to decide whether an active ramp exists (the seed is purely
|
||||
// the weight 1.0; ref was clamped to ±1 at start()). The ±2 clamp on the difference is
|
||||
// gone: the blend formula keeps every output within max(|ref|,|outₙ|) by construction.
|
||||
// is impossible even if outCurrent rises while the weight is still significant. (An
|
||||
// earlier revision stored the frozen difference (ref − x₀), which could exceed full scale
|
||||
// if outₙ rose while that residue was still large.)
|
||||
(void)newOutL; (void)newOutR; // consumed only for the floor guard below
|
||||
declickPending_ = false;
|
||||
declickWeight_ = 1.0; // ONE weight for both channels (T1-09: the per-R copy was dead state)
|
||||
// The reference is already clamped to ±1.0 at start() (lines in start(): the ±1 clamp
|
||||
// on lastOutL_/R_ before storing into declickRefL_/R_). No secondary clamp needed here.
|
||||
// Activate only when the ref itself is above the floor — if ref ≈ 0 there is nothing to blend.
|
||||
declickWeight_ = 1.0; // one weight for both channels
|
||||
// ref is already clamped to ±1.0 at start(). Activate only when it's above the floor —
|
||||
// if ref ≈ 0 there is nothing to blend.
|
||||
declickActive_ = (declickRefL_ > kDeclickFloor || declickRefL_ < -kDeclickFloor ||
|
||||
declickRefR_ > kDeclickFloor || declickRefR_ < -kDeclickFloor);
|
||||
}
|
||||
|
||||
AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
|
||||
// Shared read/advance for the mono and stereo paths. The read-head geometry (loop wrap,
|
||||
// bracketing indices, interpolation partner) is computed ONCE and applied identically to
|
||||
// every channel — only the PCM value read differs. The amplitude + pitch envelopes tick ONCE
|
||||
// per frame and scale all channels equally (a voice is one envelope). The head advances by
|
||||
// exactly one source-frame step per call, so mono and stereo consume the sample at one rate.
|
||||
// Shared read/advance for the mono and stereo paths: the read-head geometry is computed
|
||||
// once and applied identically to every channel — only the PCM value read differs. The
|
||||
// amplitude + pitch envelopes tick once per frame and scale all channels equally.
|
||||
if (!active_ || sample_ == nullptr) {
|
||||
if (stereo) outR = 0.0f;
|
||||
return 0.0f;
|
||||
@@ -516,10 +475,10 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
|
||||
const bool haveR = stereo && sample_->channelCount() == 2;
|
||||
const std::vector<AudioSample>& pcmR = haveR ? sample_->framesR : pcm;
|
||||
|
||||
// Loop-aware sustain (GATE only — Trigger is a one-shot with no sustain loop, S15). If a
|
||||
// valid, non-zero-length loop exists and the read head has advanced past the loop end, wrap
|
||||
// it back into [start, end). A zero-length loop is treated as "no loop". Under Preserve the
|
||||
// loop is over the SOURCE read (loop the source, shift the output — S15×S16 contract).
|
||||
// Loop-aware sustain (Gate only — Trigger is a one-shot with no sustain loop). A valid,
|
||||
// non-zero-length loop wraps the read head back into [start, end); a zero-length loop is
|
||||
// "no loop". Under Preserve the loop is over the source read (loop the source, shift the
|
||||
// output).
|
||||
const SampleLoop& loop = sample_->loop;
|
||||
const bool loopUsable = sustainLoopUsable();
|
||||
if (loopUsable) {
|
||||
@@ -529,16 +488,15 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
|
||||
}
|
||||
}
|
||||
|
||||
// TRIGGER end: the voice frees once the read head reaches playEnd (source-frame stop). The
|
||||
// trigger envelope also finishes at the same frame count; either latches the voice idle.
|
||||
// Trigger frees once the read head reaches playEnd; the envelope also finishes at the
|
||||
// same count, either latches idle.
|
||||
const bool triggerRanOff =
|
||||
playMode_ == PlayMode::Trigger && readPos_ >= static_cast<double>(playEnd_);
|
||||
// Ran off the sample end with no usable loop -> voice is done. Peer path of the
|
||||
// epilogue: an in-flight takeover declick RINGS OUT here instead of hard-cutting —
|
||||
// dropping it would re-introduce a step on exactly the path the ramp exists for (a
|
||||
// restart whose new play span ends within the ~4 ms ramp). The voice stays active only
|
||||
// until the ramp floors; with no declick (the common case, and the entire opt-out
|
||||
// baseline) this is byte-identical to the plain idle-out.
|
||||
// Ran off the sample end with no usable loop -> voice is done, except an in-flight
|
||||
// takeover declick rings out here instead of hard-cutting — dropping it would
|
||||
// re-introduce a step on exactly the path the ramp exists for (a restart whose new play
|
||||
// span ends within the ramp). With no declick (the common case) this is byte-identical
|
||||
// to the plain idle-out.
|
||||
if (triggerRanOff || readPos_ >= static_cast<double>(frameCount)) {
|
||||
if (declickPending_) seedDeclick(0.0, 0.0); // the new output here is silence
|
||||
if (declickActive_) {
|
||||
@@ -561,41 +519,35 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
// Envelopes tick once per output frame. Pitch envelope biases pitch under EITHER engine.
|
||||
// Envelopes tick once per output frame. Pitch envelope biases pitch under either engine.
|
||||
const double amp = tickAmplitude();
|
||||
const double gain = amp * velocityGain_;
|
||||
const double pitchEnvSemis = pitchEnv_.tick();
|
||||
|
||||
// The pitch-envelope bias factor 2^(semis/12). When the envelope is off (semis exactly 0)
|
||||
// this is 1.0 and we skip the pow entirely — the Varispeed-off path stays a bare ratio read
|
||||
// (no per-frame transcendental), byte-identical to pre-S16.
|
||||
// 2^(semis/12); when the envelope is off (semis exactly 0) this is 1.0 and skips the pow
|
||||
// entirely — no per-frame transcendental on the common path.
|
||||
const double envFactor = (pitchEnvSemis == 0.0) ? 1.0 : std::pow(2.0, pitchEnvSemis / 12.0);
|
||||
|
||||
double outL, outRlocal = 0.0;
|
||||
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||
// PRESERVE: feed the shifters the SOURCE stream at unity rate (duration held) and
|
||||
// TRANSPOSE the output by 2^((note-root + pitchEnvSemis)/12). Pitch envelope adds to
|
||||
// the shift amount, not the read rate — pitch bends, duration unchanged (S16
|
||||
// contract). The feed runs one window AHEAD of readPos_ (the rings were primed with
|
||||
// that window at start()), under the SAME sustain-loop wrap rule as the anchor, and
|
||||
// reads integer source frames (readPos_ advances by exactly 1.0 under Preserve, so
|
||||
// there is nothing to interpolate). Past the last real frame the shifter's writer is
|
||||
// FROZEN (GA3 wind-down below) — it recycles the real tail it already holds.
|
||||
// Feed the shifters the source stream at unity rate (duration held) and transpose the
|
||||
// output by 2^((note-root + pitchEnvSemis)/12) — pitch envelope adds to the shift
|
||||
// amount, not the read rate. The feed runs one window ahead of readPos_ (the rings
|
||||
// were primed with that window at start()), under the same sustain-loop wrap rule,
|
||||
// reading integer source frames (nothing to interpolate). Past the last real frame
|
||||
// the shifter's writer is frozen — it recycles the real tail it already holds.
|
||||
if (loopUsable) {
|
||||
const std::int64_t loopLen = loop.end - loop.start;
|
||||
while (feedPos_ >= loop.end) feedPos_ -= loopLen;
|
||||
}
|
||||
// GA3 tail wind-down (supersedes the GA2 hold-last-sample clamp). feedPos_ runs one
|
||||
// window AHEAD of readPos_; the last real source frame is playEnd_-1 for Trigger (the
|
||||
// user's chosen stop) or frameCount-1 for Gate (the sample's own end). Once feedPos_
|
||||
// reaches that bound the source is EXHAUSTED — GA2 fed the held last sample from here,
|
||||
// a DC plateau the splice correlation cannot align on (the DAW tail chop: periodic
|
||||
// troughs at the splice cadence, growing toward the note end as the plateau displaced
|
||||
// real ring history). Instead FREEZE the shifter's writer: no padding ever enters the
|
||||
// ring, and the splice machinery keeps recycling the frozen all-real tail, every jump
|
||||
// still waveform-aligned — a continuous tone through the final window and the release,
|
||||
// bounded by the voice's own end (readPos_ >= frameCount / playEnd_ frees it). The
|
||||
// sustain-loop path never gets here: the wrap above keeps feedPos_ < loop.end forever.
|
||||
// feedPos_ runs one window ahead of readPos_; the last real source frame is
|
||||
// playEnd_-1 for Trigger or frameCount-1 for Gate. Once feedPos_ reaches that bound
|
||||
// the source is exhausted — feeding the held last sample instead would give the
|
||||
// splice correlation a DC plateau it can't align on (periodic troughs at the splice
|
||||
// cadence, growing toward the note end). Freezing the shifter's writer means no
|
||||
// padding ever enters the ring, so the splice machinery keeps recycling the frozen
|
||||
// all-real tail — a continuous tone through the voice's own end. The sustain-loop
|
||||
// path never gets here: the wrap above keeps feedPos_ < loop.end forever.
|
||||
const std::int64_t feedBound =
|
||||
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
|
||||
? playEnd_ : frameCount;
|
||||
|
||||
@@ -1,142 +1,101 @@
|
||||
#pragma once
|
||||
// sampler_core — the HEART of the Phase S MIDI-playback instrument (D3), deliberately
|
||||
// free of any VST3 *and* any REAPER type so it compiles and unit-tests OUTSIDE the DAW
|
||||
// and outside any plugin host. It owns the pure sampler engine: polyphonic voice
|
||||
// allocation with bounded stealing, an ADSR amplitude envelope, a key/velocity keymap
|
||||
// with (note, velocity) -> zone resolution, and repitch/interpolation from a root note
|
||||
// with loop-point-aware sustain.
|
||||
// sampler_core — the polyphonic voice engine: bounded-stealing allocation, an ADSR
|
||||
// amplitude envelope, a key/velocity keymap resolving (note, velocity) -> zone, and
|
||||
// repitch/interpolation from a root note with loop-point-aware sustain.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO VST3 types, NO REAPER types, NO SWELL,
|
||||
// NO vendor/ includes, no include from either SDK. Standard library only. The VST3 shell
|
||||
// (src/vst/reasampler_processor.cpp) marshals MIDI events + audio buffers to and from
|
||||
// this core; the core never sees a VST3 ProcessData or a REAPER MediaTrack. Enforced
|
||||
// structurally: sampler_core_tests links neither SDK (see CMakeLists §2i).
|
||||
//
|
||||
// It shares the `AudioSample` float alias from peaks — the one house precedent for a
|
||||
// pure module leaning on peaks for the audio-domain type (wav_codec does the same). The
|
||||
// S2 seam fields (root note, loop points) enter as plain int / frame-index inputs; the
|
||||
// core does no file I/O — it is handed decoded sample frames and produces audio frames.
|
||||
// Shares the `AudioSample` float alias from peaks. Seam fields (root note, loop points)
|
||||
// enter as plain int/frame-index inputs; the core does no file I/O.
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // AudioSample (float)
|
||||
#include "core/instrument/engine/zone_params.h" // per-zone play params + mode enums (Q-W2v header split)
|
||||
#include "core/instrument/engine/pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core)
|
||||
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (S-VIEW-9 velocity->amp transfer curve; eval at start)
|
||||
#include "core/audio/peaks.h"
|
||||
#include "core/instrument/engine/zone_params.h"
|
||||
#include "core/instrument/engine/pitch_shift.h"
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Q-W1 interim: the engine deps live in their sub-namespace homes now; sampler_core
|
||||
// re-namespaces in its own split wave (Q-W2v).
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::PitchShifter;
|
||||
using instrument::engine::VelocityCurve;
|
||||
using instrument::engine::VelocityPoint;
|
||||
|
||||
// The per-zone play-parameter VALUE STRUCTS + per-instance mode enums (ChannelMode /
|
||||
// VoiceMode / MonoTrigger, AdsrParams / TriggerParams / PitchEnvParams / ZonePlayParams,
|
||||
// SampleLoop / SampleData, and their constants) live in zone_params.h (Q-W2v header
|
||||
// split, T4-14/T4-17) so param-reading TUs stop recompiling on engine-class edits.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keymap — the performance map (instrument-owned, D-B). A note+velocity resolves
|
||||
// to at most one zone; a zone names which SampleData to play and the root note to
|
||||
// repitch from. Tier-0 degenerate case: a single zone spanning [0,127] with the
|
||||
// sample's own root. Tier-1: several zones, each a key range with its own root.
|
||||
// Keymap — the performance map. A note+velocity resolves to at most one zone; a zone
|
||||
// names which SampleData to play and the root note to repitch from. Tier-0 degenerate
|
||||
// case: a single zone spanning [0,127] with the sample's own root. Tier-1: several
|
||||
// zones, each a key range with its own root.
|
||||
//
|
||||
// TIER-2 EXTENSION (velocity layers / round-robin) — designed for, not built:
|
||||
// resolution returns a zone; a zone today owns one sampleIndex. Tier 2 makes a zone
|
||||
// own a *list* of (velocity-range, sampleIndex) layers (and round-robin sets), and
|
||||
// resolve() gains the velocity dimension it already receives but currently ignores
|
||||
// for selection. The (note, velocity) signature and the "resolve to a zone, then a
|
||||
// sample within it" shape are already in place — Tier 2 fills in the second step
|
||||
// without changing callers or the voice engine. See the report note.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier-2 extension (velocity layers/round-robin) — designed for, not built: a zone
|
||||
// today owns one sampleIndex; Tier 2 would make it own a list of (velocity-range,
|
||||
// sampleIndex) layers, and resolve() would gain the velocity dimension it already
|
||||
// receives but currently ignores for selection — no signature change needed.
|
||||
|
||||
// A key range [lowNote, highNote] (inclusive both ends) mapping to one sample, with
|
||||
// the root note to repitch from (defaults to the sample's own root, overridable in
|
||||
// the performance map per S5). velocityLow/High reserved for Tier-2 layers; today a
|
||||
// zone accepts the full 1..127 velocity range (0 is note-off by MIDI convention).
|
||||
// A key range [lowNote, highNote] (inclusive) mapping to one sample, with the root
|
||||
// note to repitch from (defaults to the sample's own root, overridable per zone).
|
||||
// velocityLow/High reserved for Tier-2 layers; today a zone accepts the full 1..127
|
||||
// velocity range (0 is note-off by MIDI convention).
|
||||
struct KeyZone {
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
int rootNote = 60; // repitch reference for this zone
|
||||
// S-VIEW-6 key-tracking scalar: how far keyboard pitch tracks the root. 1.0 (100%) is
|
||||
// standard 12-tone-ET (default; bit-identical to pre-S-VIEW-6); 0.0 = no tracking (every
|
||||
// key plays root pitch); 2.0 = double-rate tracking. Scales the (note-root) semitone offset
|
||||
// in the repitch math (keyTrackedRatio); rides BOTH engines via the voice's baseRatio_.
|
||||
// How far keyboard pitch tracks the root: 1.0 = standard 12-tone-ET (default); 0.0 =
|
||||
// no tracking (every key plays root pitch); 2.0 = double-rate. Scales the (note-root)
|
||||
// semitone offset in keyTrackedRatio; rides both engines via the voice's baseRatio_.
|
||||
double keyTrack = 1.0;
|
||||
// S-VIEW-9 velocity->amp transfer curve: maps the note-on velocity (0..127) to the voice's amp
|
||||
// gain, replacing the fixed linear velocity/127. A per-zone performance characteristic (mirror
|
||||
// of keyTrack), carried from PerformanceZone by resolvePerformance and eval'd ONCE in
|
||||
// Voice::start (never per frame). DEFAULT flat y=1 (R10-F1 Option A) — every velocity plays at
|
||||
// unity, a deliberate behavior change from the pre-r10 linear map.
|
||||
// Maps note-on velocity (0..127) to the voice's amp gain, eval'd once in Voice::start
|
||||
// (never per frame). Default flat y=1 — every velocity plays at unity.
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
std::size_t sampleIndex = 0; // index into Keymap::samples
|
||||
};
|
||||
|
||||
// Result of resolving a (note, velocity). `matched == false` means the note falls in
|
||||
// no zone (out-of-zone) — a defined no-play result, NOT an error and NOT voice 0.
|
||||
// `matched == false` means the note falls in no zone — a defined no-play result, not an
|
||||
// error and not voice 0.
|
||||
struct ZoneResolution {
|
||||
bool matched = false;
|
||||
std::size_t zoneIndex = 0; // valid only when matched
|
||||
};
|
||||
|
||||
// The keymap: the decoded samples plus the zones that map keys onto them. Owns
|
||||
// resolution. Pure: no host types. Zones are tested first-match in order, so an
|
||||
// earlier zone wins an overlap (deterministic, documented).
|
||||
// Decoded samples plus the zones that map keys onto them. Zones are tested first-match
|
||||
// in order, so an earlier zone wins an overlap (deterministic, documented).
|
||||
struct Keymap {
|
||||
std::vector<SampleData> samples;
|
||||
std::vector<KeyZone> zones;
|
||||
|
||||
// Resolves (note, velocity) to a zone. First zone (in order) whose [low,high]
|
||||
// contains `note` wins. velocity is accepted now (Tier-2 seam) but does not
|
||||
// affect zone choice at Tier 0-1. Returns {matched=false} when no zone contains
|
||||
// the note.
|
||||
// First zone (in order) whose [low,high] contains `note` wins. velocity is accepted
|
||||
// (Tier-2 seam) but doesn't affect zone choice at Tier 0-1.
|
||||
ZoneResolution resolve(int note, int velocity) const;
|
||||
|
||||
// Convenience: build the Tier-0 degenerate keymap — one sample mapped
|
||||
// chromatically across the whole keyboard from its own root note.
|
||||
// The Tier-0 degenerate keymap: one sample mapped chromatically across the whole
|
||||
// keyboard from its own root note.
|
||||
static Keymap singleSampleChromatic(SampleData sample);
|
||||
};
|
||||
|
||||
// The chromatic pitch ratio to play `note` given a sample recorded at `rootNote`:
|
||||
// 2^((note - rootNote) / 12). note == rootNote -> 1.0 (unity). One octave up -> 2.0,
|
||||
// one octave down -> 0.5. Pure equal-temperament; no reference-frequency needed.
|
||||
// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal-temperament; no
|
||||
// reference-frequency needed.
|
||||
double pitchRatio(int note, int rootNote);
|
||||
|
||||
// The key-tracked pitch ratio (S-VIEW-6): 2^(((note - rootNote) * keyTrack) / 12). The
|
||||
// keyTrack scalar scales the semitone offset before the ET conversion, so it governs how
|
||||
// far playback pitch tracks the keyboard around the root:
|
||||
// keyTrack == 1.0 -> standard 12-tone-ET (BIT-IDENTICAL to pitchRatio(note, rootNote) —
|
||||
// (note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call).
|
||||
// keyTrack == 0.0 -> no tracking: every key plays the root pitch (ratio 1.0 for all notes).
|
||||
// keyTrack == 2.0 -> double-rate tracking: each key is twice as far from the root in pitch.
|
||||
// At the root note the offset is 0 regardless of keyTrack, so the root always plays at unity.
|
||||
// Pure; both repitch engines (Varispeed read-rate, Preserve shift-amount) derive from it via
|
||||
// the voice's baseRatio_.
|
||||
// 2^(((note - rootNote) * keyTrack) / 12) — keyTrack scales the semitone offset before
|
||||
// the ET conversion. keyTrack == 1.0 is bit-identical to pitchRatio(note, rootNote)
|
||||
// ((note-root)*1.0 is exact in IEEE-754, feeding the same std::pow call); 0.0 means every
|
||||
// key plays the root pitch; 2.0 doubles the tracking rate. At the root note the offset is
|
||||
// 0 regardless of keyTrack. Both repitch engines derive from it via the voice's baseRatio_.
|
||||
double keyTrackedRatio(int note, int rootNote, double keyTrack);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AHDSR amplitude envelope (S15 grows the S3 ADSR with a HOLD stage). Sample-based
|
||||
// (times in frames), linear segments. A gate: noteOn() enters Attack; noteOff() enters
|
||||
// Release from wherever it is. Asserted against a known signal in the tests (mirror of peaks).
|
||||
// AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate:
|
||||
// noteOn() enters Attack; noteOff() enters Release from wherever it is.
|
||||
//
|
||||
// Segment math (all linear ramps):
|
||||
// Segment math:
|
||||
// Attack: 0 -> 1 over attackFrames
|
||||
// Hold: hold 1 over holdFrames (S15: NEW stage between A and D)
|
||||
// Hold: hold 1 over holdFrames
|
||||
// Decay: 1 -> sustainLevel over decayFrames
|
||||
// Sustain: hold sustainLevel until noteOff
|
||||
// Release: currentLevel -> 0 over releaseFrames
|
||||
// A zero-length attack jumps straight to 1 on the first frame; HOLDFRAMES == 0 skips Hold
|
||||
// entirely, which is EXACTLY the pre-S15 ADSR (back-compat — existing Gate play is unchanged);
|
||||
// zero decay jumps to sustain; a noteOff during attack/hold/decay (release-before-sustain)
|
||||
// releases from the current partial level, not from sustainLevel. AdsrParams is defined above
|
||||
// (with the other per-zone value structs); this section holds only the per-frame evaluator.
|
||||
// ---------------------------------------------------------------------------
|
||||
// A zero-length attack jumps straight to 1 on the first frame; holdFrames == 0 skips Hold
|
||||
// entirely (the pre-hold-stage ADSR, back-compat); zero decay jumps to sustain; a noteOff
|
||||
// during attack/hold/decay releases from the current partial level, not from sustainLevel.
|
||||
|
||||
class AdsrEnvelope {
|
||||
public:
|
||||
@@ -167,30 +126,22 @@ private:
|
||||
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S15 Trigger amplitude envelope (per-frame evaluator). The PlayMode / TriggerParams /
|
||||
// FadeCurve value structs are defined above with the other per-zone params.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Trigger amplitude envelope: a stateless-shape amplitude function over the play span, evaluated
|
||||
// at a SOURCE-frame offset into the span. Anchoring the fades to SOURCE frames (not output
|
||||
// frames) is what makes S15 compose with S16: under Preserve the read advances at source rate so
|
||||
// output and source frames coincide, but under Varispeed a transposed voice consumes source
|
||||
// faster — driving the fades off the read position keeps the fade-in/out anchored to the SAME
|
||||
// source frames regardless of engine (the play-length end is a source-frame fact, S15×S16). The
|
||||
// voice reports the read offset; this maps it to amplitude. Distinct from AHDSR — time-boxed by
|
||||
// the play length and note-off-immune. Reports finished() once the offset reaches the play length.
|
||||
// A stateless-shape amplitude function over the play span, evaluated at a source-frame
|
||||
// offset into the span (not output frames): under Varispeed a transposed voice consumes
|
||||
// source faster than output, so driving the fades off the read position keeps fade-in/out
|
||||
// anchored to the same source frames regardless of engine. Distinct from AHDSR —
|
||||
// time-boxed by the play length and note-off-immune.
|
||||
class TriggerEnvelope {
|
||||
public:
|
||||
// Configure from the play span + fades. `playLengthFrames` is (playEnd - startFrame): the
|
||||
// SOURCE-frame length of the play span. Fades are clamped so fadeIn + fadeOut <= playLength
|
||||
// (fadeOut anchored to the end). A zero/negative play length finishes immediately.
|
||||
// `playLengthFrames` is (playEnd - startFrame). Fades are clamped so
|
||||
// fadeIn + fadeOut <= playLength (fadeOut anchored to the end). A zero/negative play
|
||||
// length finishes immediately.
|
||||
void configure(std::int64_t playLengthFrames, std::int64_t fadeInFrames,
|
||||
std::int64_t fadeOutFrames, FadeCurve curve = kDefaultFadeCurve);
|
||||
|
||||
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame) source frames into the play
|
||||
// span. Latches finished() once the offset reaches the play length (>= playLength). Pure over
|
||||
// the offset (no internal advance) so it composes with either pitch engine's read rate.
|
||||
// Amplitude in [0,1] at `sourceOffset` = (readPos - startFrame). Latches finished() at
|
||||
// or past playLength. Pure over the offset so it composes with either pitch engine's
|
||||
// read rate.
|
||||
double amplitudeAt(double sourceOffset);
|
||||
|
||||
bool finished() const { return finished_; }
|
||||
@@ -203,21 +154,14 @@ private:
|
||||
bool finished_ = false;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S16 pitch envelope (per-frame evaluator). The PitchEngine / PitchEnvParams value structs
|
||||
// and the kDefaultPitchEngine / kPreserveWindowMs constants are defined above.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Per-frame AD pitch-envelope evaluator. tick() returns the CURRENT pitch offset in semitones
|
||||
// (0 when disabled or past attack+decay), advancing one frame. The voice converts the semitone
|
||||
// offset to a ratio multiply (Varispeed) or a shift-amount add (Preserve). Pure, unit-tested
|
||||
// for offset at t=0, peak at t=attack, and 0 at t=attack+decay.
|
||||
// tick() returns the current pitch offset in semitones (0 when disabled or past
|
||||
// attack+decay), advancing one frame. The voice converts it to a ratio multiply
|
||||
// (Varispeed) or a shift-amount add (Preserve).
|
||||
class PitchEnvelope {
|
||||
public:
|
||||
void configure(const PitchEnvParams& params) { params_ = params; pos_ = 0; }
|
||||
void noteOn() { pos_ = 0; }
|
||||
|
||||
// Advance one frame, return this frame's pitch offset in semitones.
|
||||
double tick();
|
||||
|
||||
private:
|
||||
@@ -225,29 +169,19 @@ private:
|
||||
std::int64_t pos_ = 0;
|
||||
};
|
||||
|
||||
// Takeover declick (Phase S GA fix, rev 2 — audible click when a sounding voice is
|
||||
// restarted). A takeover restart HARD-CUTS the sounding tone: the read head and envelope
|
||||
// restart in one frame, a step discontinuity that clicks. This is the same physics on EVERY
|
||||
// restart-of-a-sounding-voice path — the MONO Retrigger takeover/fallback, the mono
|
||||
// cross-sample legato restart, and the POLY at-cap voice steal (the editor's preview is a
|
||||
// plain engine noteOn since the PreviewCard retirement, so a preview re-fire at cap is just
|
||||
// an at-cap steal). When the caller opts in (start()'s declickTakeover; the engine passes
|
||||
// it on all of those restart paths when constructed with takeoverDeclick),
|
||||
// the restart smooths the ACTUAL output discontinuity: start() records the last rendered
|
||||
// output as the pre-cut reference, and the FIRST frame rendered after the restart seeds a
|
||||
// compensation equal to (reference − that frame's raw new output). The compensation is
|
||||
// summed into the output UNGATED and decays by kDeclickDecay per frame, so the boundary
|
||||
// frame reproduces the old level EXACTLY — zero step whatever the new envelope's first
|
||||
// value (Gate attack, zero attack, or Trigger's no-fade-in instant-unity onset) and
|
||||
// whatever value the new sample starts on — and the residue fades in ~2-4 ms to the -80 dB
|
||||
// floor across 44.1-96 kHz (a per-FRAME DSP micro-ramp, not a stored wall-clock quantity).
|
||||
// [Rev 1 decayed the OLD output gated by (1 − newAmp): any restart whose new amplitude was
|
||||
// instantly ~1 — a Trigger zone with no fade-in, a zero-attack Gate — got ZERO compensation
|
||||
// and kept the full click. The difference seed has no such hole and needs no gate: when old
|
||||
// and new levels already match, the seed is ~0 and nothing is added, so the +6 dB sum the
|
||||
// gate defended against is structurally impossible.] OFF by default so the bare core stays
|
||||
// byte-identical to the pre-fix engine (the regression baseline); the processor shell opts
|
||||
// in for the engine, mirroring the kDefaultPitchEngine layering.
|
||||
// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback, a
|
||||
// cross-sample legato restart, or a poly at-cap steal) hard-cuts the old tone in one
|
||||
// frame — a step discontinuity that clicks. When the caller opts in (start()'s
|
||||
// declickTakeover), start() records the last rendered output as a pre-cut reference, and
|
||||
// the first frame after the restart seeds a compensation equal to
|
||||
// (reference - that frame's raw new output), summed in ungated and decaying by
|
||||
// kDeclickDecay/frame — so the boundary frame reproduces the old level exactly regardless
|
||||
// of the new envelope's first value, and the residue fades to the -80 dB floor in a few ms.
|
||||
// An earlier revision gated the compensation by (1 - newAmp): any restart whose new
|
||||
// amplitude was instantly ~1 (Trigger with no fade-in, zero-attack Gate) got zero
|
||||
// compensation and kept the full click — the difference-seed has no such hole. Off by
|
||||
// default so the bare core stays byte-identical to the pre-fix engine; the processor
|
||||
// shell opts in.
|
||||
inline constexpr double kDeclickDecay = 0.95; // per-frame decay of the compensation
|
||||
inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~ -80 dB)
|
||||
|
||||
@@ -259,119 +193,95 @@ inline constexpr double kDeclickFloor = 1e-4; // below this the ramp is done (~
|
||||
|
||||
class Voice {
|
||||
public:
|
||||
// Starts this voice on `note` at `velocity`, playing `sample` (a stable reference
|
||||
// the caller must keep alive for the voice's lifetime — the Keymap owns it), repitched
|
||||
// from `rootNote`. All five AHDSR fields (A/H/D/S/R) are read directly from
|
||||
// sample.play.adsr — the per-zone values (in FRAMES) resolved from the stored seconds by
|
||||
// buildTier0Keymap / buildZonedKeymap against the live sample rate. The S15 play MODE +
|
||||
// Trigger params and the S16 pitch ENGINE + pitch envelope are read from `sample.play`.
|
||||
// The Preserve shifters MUST already be pre-sized (presizePreserveShifters, off-thread) —
|
||||
// start() only reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio
|
||||
// thread inside process(). The warm silence pass settles the OLA taps before the first
|
||||
// output frame (no cold-start click). Byte-identical to the pre-S15 engine when sample.play
|
||||
// is default (Gate + Varispeed + no pitch env).
|
||||
// `keyTrack` (S-VIEW-6) scales the (note-root) semitone offset feeding the repitch ratio;
|
||||
// 1.0 (the default) is standard 12-tone-ET, bit-identical to the pre-S-VIEW-6 baseRatio_.
|
||||
// `velocityCurve` (S-VIEW-9) maps the note-on velocity to the voice's amp gain, evaluated ONCE
|
||||
// here (off the per-frame path); defaults to flat y=1 (R10-F1) — every velocity plays at unity.
|
||||
// `declickTakeover` (Phase S GA fix): when TRUE and this voice is currently ACTIVE (a
|
||||
// takeover/steal restart, not a fresh start), smooth the restart's output discontinuity —
|
||||
// the pre-cut output is recorded here and the difference-seeded compensation is armed on
|
||||
// the first frame rendered after the restart (see the takeover-declick block above
|
||||
// kDeclickDecay). A fresh start never declicks.
|
||||
// Plays `sample` (a stable reference the caller must keep alive — the Keymap owns it),
|
||||
// repitched from `rootNote`. AHDSR/play-mode/pitch-engine params are read from
|
||||
// sample.play (frames, resolved from stored seconds at keymap build). Preserve shifters
|
||||
// must already be pre-sized (presizePreserveShifters, off-thread) — start() only
|
||||
// reset()s + warm()s them (RT-safe, no allocation) since it runs on the audio thread
|
||||
// inside process(); the warm silence pass settles the OLA taps before the first output
|
||||
// frame. Byte-identical to the bare engine when sample.play is default.
|
||||
// `keyTrack` scales the (note-root) semitone offset feeding the repitch ratio; 1.0 is
|
||||
// standard 12-tone-ET. `velocityCurve` maps note-on velocity to amp gain, evaluated once
|
||||
// here (off the per-frame path); defaults to flat y=1. `declickTakeover`: when true and
|
||||
// this voice is currently active (a takeover/steal restart, not a fresh start), arms the
|
||||
// difference-seeded declick compensation on the first frame after the restart (see
|
||||
// kDeclickDecay above). A fresh start never declicks.
|
||||
void start(int note, int velocity, const SampleData& sample, int rootNote,
|
||||
double keyTrack = 1.0,
|
||||
const VelocityCurve& velocityCurve = VelocityCurve::flat(),
|
||||
bool declickTakeover = false);
|
||||
|
||||
// MONO LEGATO takeover (Phase S): re-pitch this ACTIVE voice to `note` without touching the
|
||||
// amplitude envelope, the read position, or the shifter state — pitch moves, no re-attack.
|
||||
// Both engines pick the new baseRatio_ up on the next frame (Varispeed via the read rate,
|
||||
// Preserve via the per-frame setShiftRatio). No-op on an idle voice. The caller guarantees
|
||||
// the voice is playing the SAME SampleData the (note-resolved) zone names — a cross-sample
|
||||
// takeover must restart the voice instead (see MonoTrigger).
|
||||
// Mono legato takeover: re-pitch this active voice to `note` without touching the
|
||||
// amplitude envelope, read position, or shifter state — pitch moves, no re-attack. Both
|
||||
// engines pick the new baseRatio_ up on the next frame. No-op on an idle voice. Caller
|
||||
// guarantees the voice is playing the same SampleData the resolved zone names — a
|
||||
// cross-sample takeover must restart the voice instead.
|
||||
void retune(int note, int rootNote, double keyTrack = 1.0);
|
||||
|
||||
// Gate off — begins the amplitude release. In GATE mode this enters the AHDSR release; in
|
||||
// TRIGGER mode it is a NO-OP (Trigger ignores note-off and plays through to its play length).
|
||||
// Gate off. In Gate mode enters the AHDSR release; in Trigger mode a no-op (Trigger
|
||||
// ignores note-off and plays through to its play length).
|
||||
void release();
|
||||
|
||||
// HARD STOP — CC 120 (All Sounds Off) semantics. Immediately silences this voice regardless
|
||||
// of play mode: sets active_ = false with no release ramp. Stops a ringing Trigger one-shot
|
||||
// instantly (which release() cannot do). RT-safe: no allocation, no lock.
|
||||
// Hard stop (CC 120 semantics): immediately silences this voice regardless of play mode,
|
||||
// no release ramp. Stops a ringing Trigger one-shot instantly (release() cannot).
|
||||
// RT-safe: no allocation, no lock.
|
||||
void hardStop();
|
||||
|
||||
// True while this voice is producing (or about to produce) sound (including any
|
||||
// declick ring-out tail past the note's playable span).
|
||||
// True while producing (or about to produce) sound, including any declick ring-out
|
||||
// tail past the note's playable span.
|
||||
bool active() const { return active_; }
|
||||
// True while this voice is sounding a PLAYABLE NOTE — active AND the amplitude
|
||||
// envelope has not yet finished. A voice whose note has run to its end but is still
|
||||
// ringing out a declick tail is active() but NOT soundingNote(). Use this to
|
||||
// distinguish "note is alive" (active) from "note occupies a voice slot" (soundingNote)
|
||||
// for the Preserve-cap count and the mono-Legato takeover predicate — both must ignore
|
||||
// a ramp-only past-end voice or a new note-on can be dropped / silently muted.
|
||||
// True while sounding a playable note — active and the amplitude envelope hasn't
|
||||
// finished. A voice ringing out a declick tail past note end is active() but not
|
||||
// soundingNote(); the Preserve-cap count and the mono-legato takeover predicate must
|
||||
// ignore a ramp-only past-end voice or a new note-on could be dropped/silently muted.
|
||||
bool soundingNote() const { return active_ && !amplitudeDone_; }
|
||||
// The note this voice was started on (for note-off routing). Meaningless if idle.
|
||||
int note() const { return note_; }
|
||||
// Monotonic age counter — higher = started earlier relative to others. The voice
|
||||
// engine uses this for its stealing policy (oldest first). Set by the engine.
|
||||
// Monotonic age counter for the engine's oldest-first stealing policy. Set by the engine.
|
||||
std::uint64_t startOrder() const { return startOrder_; }
|
||||
void setStartOrder(std::uint64_t order) { startOrder_ = order; }
|
||||
bool releasing() const { return releasing_; }
|
||||
// The S16 pitch engine this voice is running (for the engine's Preserve-voice tally). Only
|
||||
// meaningful while active(). NOTE: the FA1 unity-shift demotion to Varispeed is GONE —
|
||||
// it was scoped to the retired PreviewCard, and since GA2 the primed shifter speaks on
|
||||
// frame 0 at every ratio, so a Preserve voice keeps its shifter at every note (one code
|
||||
// path, uniform onset across the keyboard).
|
||||
// The pitch engine this voice is running (for the engine's Preserve-voice tally). Only
|
||||
// meaningful while active().
|
||||
PitchEngine pitchEngine() const { return pitchEngine_; }
|
||||
// The SampleData this voice is playing (nullptr when never started). The engine's mono
|
||||
// legato path compares it against the new note's resolved sample — a same-sample takeover
|
||||
// retunes; a cross-sample one restarts. Identity only; callers never mutate through it.
|
||||
// Identity only, never mutated through; the engine's mono legato path compares it
|
||||
// against the new note's resolved sample to decide retune vs. restart.
|
||||
const SampleData* playingSample() const { return sample_; }
|
||||
|
||||
// Pre-SIZE this voice's Preserve pitch shifters (both channels) to `windowFrames`, OFF the
|
||||
// audio thread (this allocates; also sizes the prime scratch buffer). The engine calls it
|
||||
// once at construction so start() — which runs on the audio thread inside process() — never
|
||||
// allocates: start() only prime()s the already-sized rings with the first window of source
|
||||
// (a bounded copy). `windowFrames` <= 1 leaves the shifters as pass-through (Varispeed
|
||||
// instruments pay no ring cost). Idempotent: a re-presize to the same window is a cheap
|
||||
// no-op in the underlying vector.
|
||||
// Pre-sizes this voice's Preserve pitch shifters (both channels) to `windowFrames`, off
|
||||
// the audio thread (allocates; also sizes the prime scratch buffer), so start() — which
|
||||
// runs inside process() — never allocates. <= 1 leaves the shifters pass-through.
|
||||
// Idempotent: a re-presize to the same window is a cheap no-op.
|
||||
void presizePreserveShifters(std::int64_t windowFrames);
|
||||
|
||||
// Renders one frame's contribution, advancing the read head and envelope by one
|
||||
// output frame. Returns 0.0 (and goes idle) once the envelope finishes or the
|
||||
// sample runs out with no loop. The value is already velocity- and
|
||||
// envelope-scaled — the engine sums voices directly. This is the MONO path (channel
|
||||
// 0 only) — byte-identical to the pre-S7 engine, so mono play is unchanged.
|
||||
// Renders one frame's contribution, advancing the read head and envelope by one output
|
||||
// frame. Returns 0.0 (and goes idle) once the envelope finishes or the sample runs out
|
||||
// with no loop. Already velocity- and envelope-scaled — the engine sums voices directly.
|
||||
// Mono path (channel 0 only).
|
||||
AudioSample renderFrame();
|
||||
|
||||
// STEREO render: writes THIS frame's per-channel contribution into `l`/`r` and advances
|
||||
// the read head + envelope by exactly one frame (the same single advance the mono path
|
||||
// performs — the envelope ticks ONCE per frame, shared across both channels). For a mono
|
||||
// sample (channelCount()==1) both `l` and `r` receive the same value (dual-mono / centered).
|
||||
// Both outputs are already velocity- and envelope-scaled. Goes idle on the same conditions
|
||||
// as the mono path (envelope finished / sample exhausted with no loop) writing 0 to both.
|
||||
// Writes this frame's per-channel contribution into `l`/`r` and advances the read head +
|
||||
// envelope by exactly one frame (the envelope ticks once per frame, shared across both
|
||||
// channels). A mono sample writes the same value to both (dual-mono/centered). Goes idle
|
||||
// on the same conditions as the mono path, writing 0 to both.
|
||||
void renderFrameStereo(AudioSample& l, AudioSample& r);
|
||||
|
||||
private:
|
||||
// Shared read/advance for both render paths: computes the interpolated per-channel
|
||||
// value(s) at the current read head, ticks the amplitude + pitch envelopes once, applies
|
||||
// the pitch engine (Varispeed read-rate bias OR Preserve shift), advances the head, and
|
||||
// latches idle on exhaustion. `stereo` selects whether the second channel is read (and
|
||||
// returned in `outR`); when false `outR` is left untouched. Returns the channel-0 value.
|
||||
// the pitch engine, advances the head, and latches idle on exhaustion. `stereo` selects
|
||||
// whether the second channel is read (into `outR`). Returns the channel-0 value.
|
||||
AudioSample advanceFrame(bool stereo, AudioSample& outR);
|
||||
|
||||
// This frame's amplitude in [0,1] from the active envelope. GATE: the AHDSR ticks once per
|
||||
// output frame (independent of the read rate — envelope time is wall-clock). TRIGGER: the
|
||||
// fade shape is evaluated at the SOURCE offset (readPos - startFrame) so the fades anchor to
|
||||
// source frames and compose with either pitch engine. Sets amplitudeDone_ when the envelope
|
||||
// finishes (Gate: release complete; Trigger: play length reached) so advanceFrame frees the voice.
|
||||
// This frame's amplitude in [0,1] from the active envelope. Gate: AHDSR ticks once per
|
||||
// output frame (envelope time is wall-clock, independent of read rate). Trigger: fade
|
||||
// shape is evaluated at the source offset (readPos - startFrame) so fades anchor to
|
||||
// source frames regardless of pitch engine. Sets amplitudeDone_ on finish so
|
||||
// advanceFrame frees the voice.
|
||||
double tickAmplitude();
|
||||
|
||||
// True when the sustain loop applies to this voice: GATE mode with a valid, non-empty loop
|
||||
// inside the sample (S15 — Trigger one-shots never loop). The single source of truth for
|
||||
// the wrap rule shared by the output anchor (readPos_), the Preserve feed (feedPos_), and
|
||||
// the start()-time ring prime.
|
||||
// True when the sustain loop applies: Gate mode with a valid, non-empty loop inside the
|
||||
// sample (Trigger one-shots never loop). Single source of truth for the wrap rule shared
|
||||
// by the output anchor, the Preserve feed, and the start()-time ring prime.
|
||||
bool sustainLoopUsable() const;
|
||||
|
||||
bool active_ = false;
|
||||
@@ -379,13 +289,13 @@ private:
|
||||
int note_ = 0;
|
||||
double velocityGain_ = 1.0;
|
||||
double baseRatio_ = 1.0; // 2^((note-root)/12): the un-modulated repitch ratio
|
||||
double ratio_ = 1.0; // fractional SOURCE frames advanced per output frame (this frame)
|
||||
double ratio_ = 1.0; // fractional source frames advanced per output frame (this frame)
|
||||
double readPos_ = 0.0; // fractional frame index into the sample
|
||||
const SampleData* sample_ = nullptr;
|
||||
|
||||
// S15 play mode + amplitude envelopes. Gate uses env_ (AHDSR); Trigger uses trigEnv_. Only
|
||||
// one is active per voice (selected by playMode_ at start). playEnd_ is Trigger's source-frame
|
||||
// stop (the voice frees when readPos_ >= playEnd_, mirroring the run-off-end idle).
|
||||
// Gate uses env_ (AHDSR); Trigger uses trigEnv_ — only one active per voice (selected by
|
||||
// playMode_ at start). playEnd_ is Trigger's source-frame stop (frees when
|
||||
// readPos_ >= playEnd_).
|
||||
PlayMode playMode_ = PlayMode::Gate;
|
||||
AdsrEnvelope env_;
|
||||
TriggerEnvelope trigEnv_;
|
||||
@@ -393,20 +303,17 @@ private:
|
||||
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
|
||||
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
||||
|
||||
// S16 pitch engine + pitch envelope. pitchEngine_ selects Varispeed (ratio bias) vs Preserve
|
||||
// (source-rate read + shifter). shiftL_/shiftR_ transpose the Preserve output per channel
|
||||
// (one read head, per-channel shift — S7 compose). pitchEnv_ rides EITHER engine.
|
||||
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
|
||||
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
|
||||
//
|
||||
// GA2 onset fix: the shifter rings are PRIMED at start() with the first window of the
|
||||
// actual upcoming source (loop-unrolled, silence past the end) — output frame 0 is source
|
||||
// frame `start`, no ring-fill silence, and splices always land in real history. feedPos_
|
||||
// is the integer SOURCE frame the shifters are fed next; it runs exactly one window AHEAD
|
||||
// of readPos_ (the wall-clock output anchor) under the same sustain-loop wrap rule.
|
||||
// GA3 tail wind-down: once feedPos_ passes the last real frame (Gate: sample end;
|
||||
// Trigger: playEnd_) the shifters' writers are FROZEN — no padding enters the rings and
|
||||
// the splice machinery recycles the frozen real tail through the note end (see
|
||||
// advanceFrame). primeBuf_ is the presized scratch the prime stream is assembled into
|
||||
// (never touched outside start()).
|
||||
// The shifter rings are primed at start() with the first window of the actual upcoming
|
||||
// source (silence past the end) — output frame 0 is source frame `start`, no ring-fill
|
||||
// silence, and splices always land in real history. feedPos_ is the integer source frame
|
||||
// fed to the shifters next; it runs exactly one window ahead of readPos_ under the same
|
||||
// sustain-loop wrap rule. Once feedPos_ passes the last real frame (Gate: sample end;
|
||||
// Trigger: playEnd_), the shifters' writers freeze — no padding enters the rings and the
|
||||
// splice machinery recycles the frozen real tail through the note end (see advanceFrame).
|
||||
// primeBuf_ is the presized scratch the prime stream is assembled into.
|
||||
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
|
||||
PitchEnvelope pitchEnv_;
|
||||
PitchShifter shiftL_;
|
||||
@@ -414,27 +321,24 @@ private:
|
||||
std::int64_t feedPos_ = 0;
|
||||
std::vector<AudioSample> primeBuf_;
|
||||
|
||||
// Seeds the takeover compensation on the FIRST frame after a restart: the ramp is the
|
||||
// ACTUAL discontinuity — (pre-cut reference − the new voice's raw output this frame) —
|
||||
// applied ungated so the boundary frame reproduces the old level exactly. See the
|
||||
// takeover-declick block above kDeclickDecay.
|
||||
// Seeds the takeover compensation on the first frame after a restart: the ramp is the
|
||||
// actual discontinuity — (pre-cut reference - the new voice's raw output this frame) —
|
||||
// applied ungated so the boundary frame reproduces the old level exactly.
|
||||
void seedDeclick(double newOutL, double newOutR);
|
||||
|
||||
// Takeover declick state (see kDeclickDecay above). lastOut{L,R}_ track the voice's most
|
||||
// recent rendered output (post-gain, incl. any running declick). A takeover/steal start()
|
||||
// records them as declickRef{L,R}_ (the clamped pre-cut reference) and sets declickPending_;
|
||||
// the first frame rendered after the restart calls seedDeclick to arm the BOUNDED BLEND:
|
||||
// outₙ = outₙ*(1−w) + ref*w where w = declickWeight_ (ONE weight, deliberately shared
|
||||
// by both channels so L/R can never diverge — Q-W0 T1-09 removed the dead per-R copy)
|
||||
// starts at 1.0 and decays by
|
||||
// kDeclickDecay each frame. This is algebraically `outₙ + w*(ref − outₙ)`, so the
|
||||
// boundary frame (w=1) is exactly `ref` and every subsequent output is bounded by
|
||||
// max(|ref|, |outₙ|) — mid-ramp overshoot is impossible regardless of outₙ rising.
|
||||
// [Rev 1 stored the frozen difference (ref − x₀); when outₙ rose while that residue
|
||||
// was still large the sum could exceed full scale by up to ~+3.8 dB.]
|
||||
// lastOut is NOT zeroed by start() — a second same-block takeover (no frame rendered
|
||||
// between) must record the same pre-cut reference, not a phantom 0.
|
||||
// The whole declick state is cleared on a fresh (non-takeover) start.
|
||||
// lastOut{L,R}_ track the voice's most recent rendered output. A takeover/steal start()
|
||||
// records them as declickRef{L,R}_ and sets declickPending_; the first frame after the
|
||||
// restart calls seedDeclick to arm the bounded blend:
|
||||
// outₙ = outₙ*(1−w) + ref*w, w = declickWeight_ (one weight, shared by both channels so
|
||||
// L/R can never diverge), starting at 1.0 and decaying by kDeclickDecay each frame.
|
||||
// Algebraically outₙ + w*(ref − outₙ), so the boundary frame (w=1) is exactly `ref` and
|
||||
// every subsequent output is bounded by max(|ref|, |outₙ|) — mid-ramp overshoot is
|
||||
// impossible regardless of outₙ rising. (An earlier revision stored the frozen difference
|
||||
// (ref − x₀); when outₙ rose while that residue was still large, the sum could exceed
|
||||
// full scale by several dB.)
|
||||
// lastOut is not zeroed by start() — a second same-block takeover (no frame rendered
|
||||
// between) must record the same pre-cut reference, not a phantom 0. The whole declick
|
||||
// state is cleared on a fresh (non-takeover) start.
|
||||
bool declickPending_ = false;
|
||||
bool declickActive_ = false;
|
||||
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
|
||||
@@ -446,50 +350,40 @@ private:
|
||||
std::uint64_t startOrder_ = 0;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The polyphonic voice engine: a fixed pool of voices, note-on allocation with
|
||||
// bounded voice stealing, note-off routing, and block rendering (sum of voices).
|
||||
// The polyphonic voice engine: a fixed pool of voices, note-on allocation with bounded
|
||||
// voice stealing, note-off routing, and block rendering (sum of voices).
|
||||
//
|
||||
// VOICE-STEALING POLICY (deterministic, documented): when all voices are busy and a
|
||||
// new note-on arrives, steal in this priority order:
|
||||
// 1. the oldest voice already in RELEASE (finishing anyway — cheapest to cut),
|
||||
// Voice-stealing policy (deterministic, documented): when all voices are busy and a new
|
||||
// note-on arrives, steal in this priority order:
|
||||
// 1. the oldest voice already in release (finishing anyway — cheapest to cut),
|
||||
// 2. else the oldest voice overall (longest-held note gives way to the new one).
|
||||
// "Oldest" = smallest startOrder (assigned monotonically at note-on). This is the
|
||||
// standard hardware-sampler policy: prefer to sacrifice a dying tail, and failing
|
||||
// that, the note that has already had the most time.
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Oldest" = smallest startOrder (assigned monotonically at note-on) — the standard
|
||||
// hardware-sampler policy.
|
||||
|
||||
class VoiceEngine {
|
||||
public:
|
||||
// Builds an engine with `maxVoices` voices (the polyphony bound) playing from
|
||||
// `keymap`. The keymap must outlive the engine (the engine holds a reference — it
|
||||
// reads zones and sample data through it, never copies PCM). Every AHDSR field (A/H/D/S/R)
|
||||
// + play mode + pitch engine rides on each zone's SampleData::play (in FRAMES, resolved
|
||||
// from the stored seconds at keymap build); the engine holds no instrument-wide ADSR.
|
||||
// `preserveVoiceCap` (S16) bounds how many Preserve-engine voices may sound at once (the
|
||||
// Builds an engine with `maxVoices` voices playing from `keymap` (must outlive the
|
||||
// engine — held by reference, never copies PCM). Play params ride on each zone's
|
||||
// SampleData::play; the engine holds no instrument-wide ADSR.
|
||||
// `preserveVoiceCap` bounds how many Preserve-engine voices may sound at once (the
|
||||
// shifter is materially heavier than Varispeed) — a Preserve note-on beyond the cap is
|
||||
// dropped rather than glitching; 0 means "no separate Preserve cap" (bounded only by
|
||||
// maxVoices). `preserveWindowFrames` is the OLA window (in OUTPUT frames) every voice's
|
||||
// Preserve pitch shifters are PRE-SIZED to at construction (OFF the audio thread), so
|
||||
// note-on (which runs in process()) never allocates; 0 leaves them pass-through (a
|
||||
// Varispeed-only instrument pays no ring cost). The processor derives it from the host
|
||||
// sample rate (kPreserveWindowMs). Defaulted so existing callers (and the pure-core tests)
|
||||
// are unaffected.
|
||||
// dropped rather than glitching; 0 means no separate cap (bounded only by maxVoices).
|
||||
// `preserveWindowFrames` is the OLA window every voice's Preserve shifters are
|
||||
// pre-sized to at construction (off the audio thread), so note-on never allocates; 0
|
||||
// leaves them pass-through. The processor derives it from the host sample rate.
|
||||
//
|
||||
// `voiceMode` (Phase S): POLY is the pool-with-stealing engine above; MONO drives a single
|
||||
// voice (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger`
|
||||
// (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a same-sample
|
||||
// takeover without a re-attack). Both default to today's behavior (Poly / Retrigger). The
|
||||
// engine's config is immutable — a mode/count change rebuilds the engine off-thread through
|
||||
// the processor's drain-slot reload, so ringing tails survive the swap.
|
||||
// `voiceMode`: POLY is the pool-with-stealing engine above; MONO drives a single voice
|
||||
// (voices_[0]) with last-note priority over the held-note stack, per `monoTrigger`
|
||||
// (Retrigger restarts the envelopes on every takeover/fallback; Legato retunes a
|
||||
// same-sample takeover without a re-attack). The engine's config is immutable — a
|
||||
// mode/count change rebuilds the engine off-thread through the processor's drain-slot
|
||||
// reload, so ringing tails survive the swap.
|
||||
//
|
||||
// `takeoverDeclick` (Phase S GA fix): when TRUE, every RESTART of a SOUNDING voice —
|
||||
// the MONO Retrigger takeover, the retrigger fallback on note-off, the cross-sample
|
||||
// legato restart, and the POLY at-cap voice STEAL — seeds the per-voice declick ramp
|
||||
// (see kDeclickDecay) so the hard cut of the old tone does not click. start() self-gates
|
||||
// on the voice being active, so a fresh start (free voice) never ramps. Default FALSE
|
||||
// keeps the bare core byte-identical to the pre-fix engine (regression baseline); the
|
||||
// processor shell opts in — the same layering as the kDefaultPitchEngine product default.
|
||||
// `takeoverDeclick`: when true, every restart of a sounding voice (mono retrigger
|
||||
// takeover/fallback, cross-sample legato restart, poly at-cap steal) seeds the
|
||||
// per-voice declick ramp (see kDeclickDecay) so the hard cut doesn't click. start()
|
||||
// self-gates on the voice being active, so a fresh start never ramps. Default false
|
||||
// keeps the bare core byte-identical to the pre-fix engine; the processor shell opts in.
|
||||
VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
||||
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
|
||||
VoiceMode voiceMode = VoiceMode::Poly,
|
||||
@@ -507,42 +401,32 @@ public:
|
||||
// the older tail to ring — matches hardware behavior). No-op if none match.
|
||||
void noteOff(int note);
|
||||
|
||||
// CC 123 — MIDI All-Notes-Off: clears the MONO held stack and RELEASES every active voice
|
||||
// (Gate voices enter their AHDSR release tail; Trigger one-shots ignore release and play
|
||||
// through their bounded play length). This is the mono stack's ONLY reset path — a phantom
|
||||
// entry left by a lost note-off would otherwise be resurrected by the fallback and sustain
|
||||
// forever with no key held. RT-safe (no allocation, bounded by maxVoices).
|
||||
// CC 123 (All-Notes-Off): clears the mono held stack and releases every active voice
|
||||
// (Gate enters AHDSR release; Trigger ignores release and plays through). The mono
|
||||
// stack's only reset path — a phantom entry left by a lost note-off would otherwise be
|
||||
// resurrected by the fallback and sustain forever with no key held. RT-safe.
|
||||
void allNotesOff();
|
||||
|
||||
// CC 120 — MIDI All-Sounds-Off: hard-stops EVERY voice immediately (active_ = false, no
|
||||
// release ramp), clears the MONO held stack, and silences even Trigger one-shots that would
|
||||
// ignore a release. Use for panic; CC 123 for the softer "let gates release" behavior.
|
||||
// RT-safe (no allocation, bounded by maxVoices); callable from the audio thread.
|
||||
// CC 120 (All-Sounds-Off): hard-stops every voice immediately, clears the mono held
|
||||
// stack, silences even Trigger one-shots that would ignore a release. Panic; CC 123 is
|
||||
// the softer "let gates release." RT-safe, callable from the audio thread.
|
||||
void allSoundsOff();
|
||||
|
||||
// REAL-TIME render (S4): sums all active voices into the caller-provided buffer
|
||||
// `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes —
|
||||
// this never touches memory it does not own and NEVER allocates). This is the
|
||||
// audio-thread entry point: the VST3 process callback passes the host's own output
|
||||
// channel buffer, so no allocation, resize, or heap traffic happens under process.
|
||||
// Voices that finish mid-block go idle and stop contributing. `out` must point at
|
||||
// at least `frameCount` writable samples; a null `out` or zero count is a no-op.
|
||||
// Sums all active voices into the caller-provided buffer `out[0..frameCount)`, adding
|
||||
// to whatever is there — never allocates (the audio-thread entry point; the VST3
|
||||
// process callback passes the host's own output buffer). Voices that finish mid-block
|
||||
// go idle. `out` must point at least `frameCount` writable samples; null/zero is a no-op.
|
||||
void render(AudioSample* out, std::size_t frameCount);
|
||||
|
||||
// REAL-TIME stereo render (S7): sums all active voices per-channel into the caller's two
|
||||
// buffers `left`/`right` (each `frameCount` writable samples), ADDING to whatever is there
|
||||
// (the caller clears/mixes). Same RT discipline as the mono overload — no allocation, no
|
||||
// resize, no lock. A mono sample plays dual-mono (same value to both channels, centered);
|
||||
// a stereo sample plays its two channels. A null buffer or zero count is a no-op. The mono
|
||||
// and stereo render paths are independent output shapes over the SAME voice pool; the active
|
||||
// channel mode (mono vs stereo bus) picks which one the process callback drives per block.
|
||||
// Stereo overload: sums per-channel into `left`/`right`, same RT discipline. A mono
|
||||
// sample plays dual-mono (same value both channels); a stereo sample plays its two
|
||||
// channels. Mono and stereo render are independent output shapes over the same voice
|
||||
// pool — the active channel mode picks which one the process callback drives per block.
|
||||
void render(AudioSample* left, AudioSample* right, std::size_t frameCount);
|
||||
|
||||
// TEST / off-thread convenience: appends `frameCount` summed frames to `out`
|
||||
// (grows it — DO NOT call on the audio thread; it allocates). Delegates to the
|
||||
// real-time overload after sizing the buffer, so both paths share one mix loop.
|
||||
// Does not clear existing contents — appends, matching the pre-S4 contract the
|
||||
// unit tests rely on.
|
||||
// Test/off-thread convenience: appends `frameCount` summed frames to `out` (grows it —
|
||||
// do not call on the audio thread). Delegates to the real-time overload after sizing
|
||||
// the buffer. Does not clear existing contents — appends.
|
||||
void render(std::vector<AudioSample>& out, std::size_t frameCount);
|
||||
|
||||
// Count of currently active voices (for tests / diagnostics).
|
||||
@@ -557,49 +441,45 @@ private:
|
||||
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
|
||||
std::size_t allocateVoice();
|
||||
|
||||
// Count of active Preserve-engine voices (for the S16 Preserve cap). Rescanned per note-on
|
||||
// Count of active Preserve-engine voices (for the Preserve cap). Rescanned per note-on
|
||||
// (cheap: bounded by maxVoices) rather than maintained as a running tally.
|
||||
std::size_t activePreserveVoices() const;
|
||||
|
||||
// --- MONO mode (Phase S): last-note priority over a held-note stack ------------
|
||||
// The stack holds every currently-held, ZONE-RESOLVING note in press order (top = most
|
||||
// recent = the sounding note while the voice is gated). An out-of-zone note never joins
|
||||
// (it cannot sound, so it must not later take the voice back on a fallback). Re-pressing
|
||||
// a held note moves it to the top. Fixed-capacity (128 distinct MIDI notes) — no
|
||||
// allocation on the audio thread. Velocity is kept per held note so a RETRIGGER fallback
|
||||
// re-strikes the fallen-back-to note at ITS original velocity.
|
||||
// Mono mode: last-note priority over a held-note stack. The stack holds every
|
||||
// currently-held, zone-resolving note in press order (top = most recent = the sounding
|
||||
// note). An out-of-zone note never joins (it cannot sound, so it must not later take
|
||||
// the voice back on a fallback). Re-pressing a held note moves it to the top.
|
||||
// Fixed-capacity (128 distinct MIDI notes) — no allocation on the audio thread.
|
||||
// Velocity is kept per held note so a retrigger fallback re-strikes at its original
|
||||
// velocity.
|
||||
struct HeldNote { std::uint8_t note; std::uint8_t velocity; };
|
||||
|
||||
// Mono note-on: push to the stack and take the voice over (legato retune on a same-sample
|
||||
// takeover, else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone
|
||||
// or out-of-range (note outside [0,127] — rejected BEFORE the stack, which stores uint8).
|
||||
// The S16 Preserve cap is NOT applied in mono — a single voice runs at most one shifter,
|
||||
// inherently within any cap; applying it would wrongly drop a Preserve->Preserve takeover.
|
||||
// Push to the stack and take the voice over (legato retune on a same-sample takeover,
|
||||
// else a fresh start). Returns 0 (the mono voice) or kNoVoice for out-of-zone or
|
||||
// out-of-range (rejected before the stack, which stores uint8). The Preserve cap is
|
||||
// not applied in mono — a single voice runs at most one shifter, inherently within any
|
||||
// cap; applying it would wrongly drop a Preserve->Preserve takeover.
|
||||
std::size_t monoNoteOn(int note, int velocity);
|
||||
// Mono note-off: pop from the stack; if the released note was sounding, fall back to the
|
||||
// most-recent still-held note (retrigger or legato per monoTrigger_), else release.
|
||||
// Pop from the stack; if the released note was sounding, fall back to the most-recent
|
||||
// still-held note (retrigger or legato per monoTrigger_), else release.
|
||||
void monoNoteOff(int note);
|
||||
// Drops `note` from the held stack (order of the remaining notes preserved). No-op if absent.
|
||||
void removeHeld(int note);
|
||||
|
||||
std::vector<Voice> voices_;
|
||||
const Keymap& keymap_;
|
||||
std::size_t preserveVoiceCap_ = 0; // S16: max simultaneous Preserve voices (0 = no separate cap)
|
||||
std::size_t preserveVoiceCap_ = 0; // max simultaneous Preserve voices (0 = no separate cap)
|
||||
std::uint64_t nextStartOrder_ = 1; // monotonic; 0 reserved for "never started"
|
||||
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||
bool takeoverDeclick_ = false; // GA fix: declick every restart/steal of a sounding voice
|
||||
bool takeoverDeclick_ = false; // declick every restart/steal of a sounding voice
|
||||
std::array<HeldNote, 128> heldStack_{}; // mono held notes, press order; top = heldCount_-1
|
||||
std::size_t heldCount_ = 0;
|
||||
};
|
||||
|
||||
// NOTE (preview redesign): the Phase S PreviewCard — a dedicated preview voice isolated
|
||||
// from the MIDI pool — is RETIRED. The editor's preview trigger is now a synthetic note-on
|
||||
// at the loaded capture's root note through the SAME VoiceEngine host MIDI drives, so a
|
||||
// preview is a real voice: it counts against the voice count, can steal / be stolen, and
|
||||
// respects Poly/Mono + Retrigger/Legato (a deliberate reversal of the earlier isolation
|
||||
// decision). The FA1 unity-Varispeed demotion in Voice::start went with it — since the GA2
|
||||
// prime fix the shifter speaks on frame 0 at every ratio, so the demotion bought nothing
|
||||
// but a second code path.
|
||||
// The editor's preview trigger is a synthetic note-on at the loaded capture's root note
|
||||
// through the same VoiceEngine host MIDI drives, so preview is a real voice: it counts
|
||||
// against the voice count, can steal/be stolen, and respects Poly/Mono + Retrigger/Legato.
|
||||
// There is no dedicated preview voice isolated from the MIDI pool.
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -2,20 +2,19 @@
|
||||
|
||||
#include "core/instrument/engine/velocity_curve.h"
|
||||
|
||||
#include <algorithm> // std::max, std::min, std::abs, std::stable_sort
|
||||
#include <cmath> // std::fabs
|
||||
#include <utility> // std::move
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <utility>
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
namespace {
|
||||
|
||||
|
||||
double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); }
|
||||
double clampAmp(double a) { return std::clamp(a, kAmpMin, kAmpMax); }
|
||||
|
||||
// Pixel<->box maps (mirror of envelope_edit's timeToX/levelToY). X spans the width for [0,127]; Y
|
||||
// spans (height-1) rows for amp [0,1] with amp 1 at the TOP (y increases downward).
|
||||
// X spans the width for [0,127]; Y spans (height-1) rows for amp [0,1] with amp 1 at the TOP
|
||||
// (pixel y increases downward, so this axis is inverted relative to amp).
|
||||
double velPerPixel(const VelocityCurve::Box& box) {
|
||||
const int w = std::max(0, box.width);
|
||||
if (w <= 0) return 0.0;
|
||||
@@ -35,7 +34,6 @@ int velToX(const VelocityCurve::Box& box, double velocity) {
|
||||
int ampToY(const VelocityCurve::Box& box, double amp) {
|
||||
const int h = std::max(0, box.height);
|
||||
if (h <= 1) return box.top;
|
||||
// amp 1 at top (box.top), amp 0 at bottom (box.top + h - 1).
|
||||
const double frac = (clampAmp(amp) - kAmpMin) / (kAmpMax - kAmpMin);
|
||||
return box.top + static_cast<int>((1.0 - frac) * static_cast<double>(h - 1) + 0.5);
|
||||
}
|
||||
@@ -44,19 +42,19 @@ int ampToY(const VelocityCurve::Box& box, double amp) {
|
||||
|
||||
VelocityCurve VelocityCurve::flat() {
|
||||
VelocityCurve c;
|
||||
c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}}; // y = 1 everywhere (R10-F1 Option A)
|
||||
c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}};
|
||||
return c;
|
||||
}
|
||||
|
||||
VelocityCurve VelocityCurve::linear() {
|
||||
VelocityCurve c;
|
||||
c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}}; // y = velocity/127
|
||||
c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}};
|
||||
return c;
|
||||
}
|
||||
|
||||
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
|
||||
// Box-clamp every point, then stable-sort by velocity (X-order; stable so coincident-X points
|
||||
// keep their wire order). A stable sort keeps the eval well-defined for duplicate-X knots.
|
||||
// Stable sort so coincident-X points keep their wire order (eval stays well-defined for
|
||||
// duplicate-X knots).
|
||||
for (VelocityPoint& p : pts) {
|
||||
p.velocity = clampVelocity(p.velocity);
|
||||
p.amp = clampAmp(p.amp);
|
||||
@@ -65,18 +63,16 @@ VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
|
||||
[](const VelocityPoint& a, const VelocityPoint& b) {
|
||||
return a.velocity < b.velocity;
|
||||
});
|
||||
// Fewer than 2 usable points -> can't span [0,127] as a function; fall back to the flat default.
|
||||
if (pts.size() < 2) return flat();
|
||||
// Force endpoints present at velocity 0 and 127 (they must exist for eval to be total).
|
||||
if (pts.front().velocity > kVelMin) {
|
||||
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().amp});
|
||||
} else {
|
||||
pts.front().velocity = kVelMin; // snap a near-0 first point exactly onto the endpoint
|
||||
pts.front().velocity = kVelMin;
|
||||
}
|
||||
if (pts.back().velocity < kVelMax) {
|
||||
pts.push_back(VelocityPoint{kVelMax, pts.back().amp});
|
||||
} else {
|
||||
pts.back().velocity = kVelMax; // snap a near-127 last point exactly onto the endpoint
|
||||
pts.back().velocity = kVelMax;
|
||||
}
|
||||
VelocityCurve c;
|
||||
c.points_ = std::move(pts);
|
||||
@@ -85,19 +81,12 @@ VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
|
||||
|
||||
namespace {
|
||||
|
||||
// Fritsch–Carlson monotone-cubic tangent for one interior knot i, given the secant slopes of the
|
||||
// two adjacent segments (dPrev = secant into knot i, dNext = secant out of knot i). Returns the
|
||||
// limited tangent that keeps the cubic Hermite piece monotone and inside the data range.
|
||||
//
|
||||
// The rule: a tangent whose adjacent secants have opposite signs (or either is flat) is a local
|
||||
// extremum — pin the tangent to 0 so the curve does not overshoot past the knot. Otherwise use the
|
||||
// weighted-harmonic-mean tangent (Fritsch–Carlson eq. 4), which for COLLINEAR knots (dPrev==dNext)
|
||||
// reduces to that common secant — so collinear control points reproduce the straight line to within
|
||||
// floating-point rounding (~1e-15), preserving the Option-B / null-response contract for linear().
|
||||
// Fritsch-Carlson monotone-cubic tangent: a sign change (or flat) neighbour is a local extremum,
|
||||
// so the tangent pins to 0 to avoid overshoot; otherwise the weighted-harmonic-mean tangent,
|
||||
// which for collinear knots (dPrev==dNext) reduces exactly to the shared secant — this is what
|
||||
// makes the spline reproduce a straight line to ~1e-15 for linear()-style input.
|
||||
double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) {
|
||||
if (dPrev * dNext <= 0.0) return 0.0; // sign change or a flat neighbour -> local extremum
|
||||
// Weighted harmonic mean of the two secants (weights = the two segment widths). Collinear case:
|
||||
// dPrev==dNext==d makes this (w1+w2)*d / ((w1+w2)/... ) collapse to d exactly.
|
||||
if (dPrev * dNext <= 0.0) return 0.0;
|
||||
const double w1 = 2.0 * spanNext + spanPrev;
|
||||
const double w2 = spanNext + 2.0 * spanPrev;
|
||||
return (w1 + w2) / (w1 / dPrev + w2 / dNext);
|
||||
@@ -106,33 +95,23 @@ double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double
|
||||
} // namespace
|
||||
|
||||
double VelocityCurve::eval(double velocity) const {
|
||||
if (points_.empty()) return kAmpMax; // degenerate (shouldn't occur) -> flat unity
|
||||
if (points_.size() == 1) return clampAmp(points_[0].amp); // 1-point -> that point's amp
|
||||
if (points_.empty()) return kAmpMax;
|
||||
if (points_.size() == 1) return clampAmp(points_[0].amp);
|
||||
const double v = clampVelocity(velocity);
|
||||
// At or before the first point / at or after the last, read the endpoint amp (the endpoints are
|
||||
// at 0 and 127, so this only fires exactly at the ends for an in-range velocity).
|
||||
if (v <= points_.front().velocity) return clampAmp(points_.front().amp);
|
||||
if (v >= points_.back().velocity) return clampAmp(points_.back().amp);
|
||||
// Find the segment [points_[i], points_[i+1]] containing v (X-ordered, so a linear scan).
|
||||
for (std::size_t i = 0; i + 1 < points_.size(); ++i) {
|
||||
const VelocityPoint& a = points_[i];
|
||||
const VelocityPoint& b = points_[i + 1];
|
||||
if (v >= a.velocity && v <= b.velocity) {
|
||||
const double span = b.velocity - a.velocity;
|
||||
// Coincident-X neighbours (a step): jump straight to the later point's amp — the segment
|
||||
// has zero width so there is no interior to blend.
|
||||
// Coincident-X neighbours (a step): zero-width segment, no interior to blend.
|
||||
if (span <= 0.0) return clampAmp(b.amp);
|
||||
|
||||
// --- Monotone cubic Hermite (Fritsch–Carlson) interpolation on segment [a,b] ---------
|
||||
// Curved (spline) response, not straight lines. The interpolant provably stays within
|
||||
// [a.amp, b.amp] between the two knots (no bulge below 0 / above 1), and for collinear
|
||||
// control points its tangents reduce to the secant slope — so it reproduces the straight
|
||||
// line to within floating-point rounding (~1e-15), preserving linear()'s null-response
|
||||
// contract (y = velocity/127 to ~1e-15; the test tolerance of 1e-12 is appropriate).
|
||||
const double d = (b.amp - a.amp) / span; // secant of THIS segment
|
||||
// Monotone cubic Hermite (Fritsch-Carlson): provably stays within [a.amp, b.amp]
|
||||
// between the two knots (no overshoot), reproducing a straight line for collinear input.
|
||||
const double d = (b.amp - a.amp) / span;
|
||||
|
||||
// Tangent at a: 0 if a is the first knot (endpoint), else the FC-limited tangent using
|
||||
// the previous segment's secant. Same for the tangent at b (0 at the last knot).
|
||||
double mA = d;
|
||||
if (i > 0) {
|
||||
const VelocityPoint& prev = points_[i - 1];
|
||||
@@ -141,7 +120,7 @@ double VelocityCurve::eval(double velocity) const {
|
||||
const double dPrev = (a.amp - prev.amp) / spanPrev;
|
||||
mA = fritschCarlsonTangent(dPrev, d, spanPrev, span);
|
||||
} else {
|
||||
mA = 0.0; // coincident-X predecessor (a step at a) -> flat tangent
|
||||
mA = 0.0;
|
||||
}
|
||||
}
|
||||
double mB = d;
|
||||
@@ -152,13 +131,10 @@ double VelocityCurve::eval(double velocity) const {
|
||||
const double dNext = (next.amp - b.amp) / spanNext;
|
||||
mB = fritschCarlsonTangent(d, dNext, span, spanNext);
|
||||
} else {
|
||||
mB = 0.0; // coincident-X successor (a step at b) -> flat tangent
|
||||
mB = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
// Cubic Hermite basis on the normalized position t across [a,b]. For collinear knots
|
||||
// mA==mB==d, so h00*a + (h10*span)*d + h01*b + (h11*span)*d collapses to the straight
|
||||
// line to within floating-point rounding (~1e-15).
|
||||
const double t = (v - a.velocity) / span;
|
||||
const double t2 = t * t;
|
||||
const double t3 = t2 * t;
|
||||
@@ -175,8 +151,7 @@ double VelocityCurve::eval(double velocity) const {
|
||||
|
||||
std::size_t VelocityCurve::addPoint(double velocity, double amp) {
|
||||
const VelocityPoint p{clampVelocity(velocity), clampAmp(amp)};
|
||||
// Insert keeping X-order: first index whose velocity is STRICTLY greater than the new one, so a
|
||||
// duplicate-X point lands immediately after the existing one (a later move can separate them).
|
||||
// First index strictly greater, so a duplicate-X point lands immediately after the existing one.
|
||||
std::size_t i = 0;
|
||||
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
|
||||
points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p);
|
||||
@@ -191,11 +166,10 @@ VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, doubl
|
||||
double newAmp = clampAmp(amp);
|
||||
double newVel;
|
||||
if (isFirst) {
|
||||
newVel = kVelMin; // endpoint pinned in X at 0 — only amp moves
|
||||
newVel = kVelMin;
|
||||
} else if (isLast) {
|
||||
newVel = kVelMax; // endpoint pinned in X at 127 — only amp moves
|
||||
newVel = kVelMax;
|
||||
} else {
|
||||
// Interior point: clamp X strictly within its immediate neighbours so it can't cross them.
|
||||
const double lo = points_[index - 1].velocity;
|
||||
const double hi = points_[index + 1].velocity;
|
||||
newVel = std::clamp(clampVelocity(velocity), lo, hi);
|
||||
@@ -216,9 +190,7 @@ VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box, const Ve
|
||||
}
|
||||
|
||||
VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) {
|
||||
// The exact inverse of velToX/ampToY (within the one-pixel rounding quantum). Degenerate
|
||||
// dimensions collapse the same way the forward map does: velToX pins to box.left (velocity 0),
|
||||
// ampToY pins to box.top (amp 1).
|
||||
// Exact inverse of velToX/ampToY (within one pixel); degenerate dims collapse the same way.
|
||||
VelocityPoint p;
|
||||
const int w = std::max(0, box.width);
|
||||
const int h = std::max(0, box.height);
|
||||
|
||||
@@ -1,43 +1,13 @@
|
||||
// velocity_curve.h — PURE velocity->amp transfer curve (S-VIEW-9, r10). NO VST3, NO REAPER, NO
|
||||
// SWELL/LICE, NO vendor/ includes at the boundary. The mirror of envelope_edit / card_drag: the
|
||||
// eval + the clamp/order/inverse-map arithmetic live here, unit-tested outside the DAW; the future
|
||||
// editor shell (reasampler_editor.cpp, S-VIEW-10) draws the box + node handles and feeds each move's
|
||||
// pixel delta back through here, committing the result to the zone through the same off-audio-thread
|
||||
// path a slider edit uses.
|
||||
//
|
||||
// WHAT IT IS. A monotonic-in-x transfer function mapping MIDI velocity (X: 0..127) to an amp scalar
|
||||
// (Y: 0..1), authored as an ordered list of control points. eval(velocity) is called ONCE per
|
||||
// note-on in Voice::start() (never per frame) to set the voice's velocityGain_, replacing the fixed
|
||||
// linear velocity/127 map. The curve is a per-PerformanceZone performance characteristic (D-B) — a
|
||||
// sibling of the AHDSR envelope, pitch engine, and keyTrack scalar — so it varies per sound, stored
|
||||
// on PerformanceZone and resolved onto the KeyZone at keymap build (mirror of keyTrack).
|
||||
//
|
||||
// DEFAULT — flat y=1 (fork R10-F1 Option A, Daniel 2026-07-27). VelocityCurve::flat() is the seeded
|
||||
// default: EVERY velocity plays at unity amp. This is a DELIBERATE, Daniel-approved behavior change
|
||||
// vs. the shipped linear velocity/127 map — soft hits are now full level until a curve is drawn.
|
||||
// NOT bit-identical to the pre-r10 engine, by design; do not "preserve" the linear response.
|
||||
//
|
||||
// THE INVARIANT (mirror of envelope_edit's S-VIEW-F2). A drag/edit can NEVER produce a curve eval
|
||||
// couldn't handle:
|
||||
// * X-ORDERED — a point clamps between its predecessor's and successor's velocity, so control
|
||||
// points never cross in X. This is what makes eval a well-defined FUNCTION (one amp per
|
||||
// velocity): each X falls in exactly one [p_i, p_{i+1}] segment.
|
||||
// * BOX-CLAMPED — velocity clamps to [0,127], amp clamps to [0,1] (the drawn box).
|
||||
// Both endpoints (velocity 0 and 127) are always present so eval is total over [0,127]; delete
|
||||
// refuses to remove them, and the constructors seed them.
|
||||
// velocity_curve.h — velocity->amp transfer curve. eval(velocity) is called once per note-on
|
||||
// in Voice::start(), never per frame. Editor hit-test/inverse-map take an explicit pixel Box
|
||||
// rather than a Rect: this module sits below sampler_core in the link graph and must not gain
|
||||
// a transitive dependency on editor-layout types.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
// DELIBERATELY dependency-free at the boundary (no editor_geometry / Rect). This module sits BELOW
|
||||
// sampler_core in the link graph (KeyZone carries a VelocityCurve; Voice::start calls eval), and the
|
||||
// engine must not gain a transitive dependency on the editor's layout types. The editor hit-test /
|
||||
// inverse-map therefore takes an explicit pixel box (boxLeft/boxTop/boxWidth/boxHeight) rather than a
|
||||
// Rect — the future editor shell (S-VIEW-10) passes its box coords directly. Mirror of envelope_edit's
|
||||
// role, but one layer lower, so the coupling stays out of the engine core.
|
||||
|
||||
namespace reasampler::instrument::engine {
|
||||
|
||||
// The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into.
|
||||
@@ -46,81 +16,56 @@ inline constexpr double kVelMax = 127.0;
|
||||
inline constexpr double kAmpMin = 0.0;
|
||||
inline constexpr double kAmpMax = 1.0;
|
||||
|
||||
// One control point: a (velocity, amp) knot the curve passes through. Both fields are box-clamped
|
||||
// by the mutators; a raw-constructed point is NOT auto-clamped (the mutators own the invariant), so
|
||||
// build curves through the named constructors / addPoint rather than pushing raw points.
|
||||
// A raw-constructed point is NOT auto-clamped (the mutators own that invariant) — build curves
|
||||
// through the named constructors / addPoint rather than pushing raw points.
|
||||
struct VelocityPoint {
|
||||
double velocity = 0.0; // X, [0,127]
|
||||
double amp = 0.0; // Y, [0,1]
|
||||
};
|
||||
|
||||
// The pick radius (px) around a node's drawn point for the editor hit-test. Mirrors
|
||||
// envelope_edit::kNodeGrabRadius / waveform_view::kMarkerGrabWidth.
|
||||
// Pick radius (px) around a node's drawn point for the editor hit-test.
|
||||
inline constexpr int kCurveNodeGrabRadius = 6;
|
||||
|
||||
// A velocity->amp transfer curve: an X-ORDERED list of control points spanning [0,127], evaluated by
|
||||
// a MONOTONE cubic Hermite spline (Fritsch–Carlson slope limiting) through the knots — a genuine
|
||||
// curved response (Daniel 2026-07-27: "straight lines sound like shit"), not a polyline. Each
|
||||
// velocity still maps to exactly one amp: the interpolant is single-valued and provably stays within
|
||||
// each segment's amp range, so the curve never overshoots below 0 or above 1. For COLLINEAR knots the
|
||||
// Fritsch–Carlson tangents reduce to the secant slope, so the spline reproduces the straight line to
|
||||
// within floating-point rounding (~1e-15) — that preserves linear()'s null-response contract
|
||||
// (y = velocity/127 to ~1e-15; the 1e-12 test tolerance is deliberately conservative). The two endpoints
|
||||
// (velocity 0 and 127) are load-bearing: they keep eval total and are never deletable.
|
||||
// An X-ordered list of control points spanning [0,127], evaluated by a monotone cubic Hermite
|
||||
// spline (Fritsch-Carlson slope limiting) — a genuine curve, not a polyline, that provably never
|
||||
// overshoots a segment's amp range. For collinear knots the tangents reduce to the secant slope,
|
||||
// so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints (velocity
|
||||
// 0 and 127) are load-bearing: they keep eval total over the domain and are never deletable.
|
||||
class VelocityCurve {
|
||||
public:
|
||||
// R10-F1 default (Option A): flat y=1 — endpoints (0,1) and (127,1); every velocity -> unity.
|
||||
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the default — see
|
||||
// velocity_curve in the directory CLAUDE.md for why this isn't bit-identical to the
|
||||
// pre-existing linear() response.
|
||||
static VelocityCurve flat();
|
||||
// The classic linear ramp y = velocity/127 — endpoints (0,0) and (127,1). Retained for tests
|
||||
// and as the Option-B seed; NOT the default (see R10-F1).
|
||||
static VelocityCurve linear();
|
||||
|
||||
// Rebuild a curve from a deserialized point list, REPAIRING the invariant defensively (the
|
||||
// deserialization seam, sample_map's zones-payload v7). Each point is box-clamped; the list is
|
||||
// stable-sorted by velocity (X-ordered); endpoints at velocity 0 and 127 are forced present
|
||||
// (an absent endpoint is synthesized at the nearest interior amp, or unity for an empty list).
|
||||
// A list with fewer than 2 usable points falls back to flat(). Never trusts the wire blindly —
|
||||
// a corrupt/truncated blob yields a well-formed curve, never an invariant-violating one.
|
||||
// Rebuilds from a deserialized point list, repairing the invariant defensively: box-clamps
|
||||
// each point, stable-sorts by velocity, forces both endpoints present (synthesized if
|
||||
// missing), falls back to flat() if fewer than 2 usable points remain. A corrupt/truncated
|
||||
// blob yields a well-formed curve, never an invariant-violating one.
|
||||
static VelocityCurve fromPoints(std::vector<VelocityPoint> pts);
|
||||
|
||||
// The control points, X-ordered, first at velocity 0 and last at velocity 127 (invariant).
|
||||
const std::vector<VelocityPoint>& points() const { return points_; }
|
||||
std::size_t size() const { return points_.size(); }
|
||||
|
||||
// Evaluate the curve at `velocity` -> amp in [0,1]. Velocity is box-clamped to [0,127] first,
|
||||
// so an out-of-range note (shouldn't occur) reads the nearest endpoint. Between two adjacent
|
||||
// points the amp follows a MONOTONE cubic Hermite spline (Fritsch–Carlson slope limiting) — a
|
||||
// true curve that provably stays within the two knots' amp range (no overshoot below 0 / above
|
||||
// 1) and reproduces the straight line to within floating-point rounding (~1e-15) for collinear
|
||||
// knots. Single-valued / monotonic in X.
|
||||
// Degenerate cases (shouldn't occur post-construction): an EMPTY curve returns kAmpMax (flat
|
||||
// unity); a ONE-point curve returns that point's amp.
|
||||
// Degenerate cases (shouldn't occur post-construction): empty curve returns kAmpMax; a
|
||||
// one-point curve returns that point's amp.
|
||||
double eval(double velocity) const;
|
||||
|
||||
// --- Editing (for the S-VIEW-10 editor UI) --------------------------------------------------
|
||||
// Insert a new control point, box-clamped, keeping the list X-ordered by velocity. Returns the
|
||||
// index of the inserted point. A new point at a velocity that duplicates an existing one is
|
||||
// inserted immediately AFTER it (so a subsequent move can separate them); the endpoints are not
|
||||
// special-cased on insert (a point at exactly 0 or 127 inserts adjacent to that endpoint).
|
||||
// Inserted at a velocity duplicating an existing point lands immediately after it, so a
|
||||
// subsequent move can separate them. Returns the inserted index.
|
||||
std::size_t addPoint(double velocity, double amp);
|
||||
|
||||
// Move point `index` to (velocity, amp), box-clamped AND X-clamped between its immediate
|
||||
// neighbours so it cannot cross them (monotonic-X grammar). The two ENDPOINTS are pinned in X
|
||||
// (index 0 stays at velocity 0, the last stays at 127) — only their AMP moves; their velocity
|
||||
// argument is ignored. An out-of-range index is a no-op. Returns the (possibly clamped)
|
||||
// resulting point.
|
||||
// Box-clamped and X-clamped between immediate neighbours (monotonic-X grammar). The two
|
||||
// endpoints are pinned in X (only their amp moves); out-of-range index is a no-op.
|
||||
VelocityPoint movePoint(std::size_t index, double velocity, double amp);
|
||||
|
||||
// Delete point `index`. The two endpoints (index 0 and the last) are NOT deletable — a request
|
||||
// to remove either, or an out-of-range index, is a no-op returning false. Returns true iff a
|
||||
// point was removed.
|
||||
// Endpoints (index 0 and last) are not deletable; that or an out-of-range index is a no-op
|
||||
// returning false.
|
||||
bool deletePoint(std::size_t index);
|
||||
|
||||
// --- Editor hit-test + inverse map (mirror of envelope_edit) --------------------------------
|
||||
// The drawn box, in pixels: origin (boxLeft, boxTop), `boxWidth` px wide, `boxHeight` px tall.
|
||||
// X = velocity across the width (0 at boxLeft, 127 at boxLeft+boxWidth); Y = amp UP the height
|
||||
// (amp 1 at boxTop, amp 0 at boxTop+boxHeight-1). Passed explicitly (not a Rect) so this module
|
||||
// stays free of editor-layout types — see the header preamble.
|
||||
// The drawn box, in pixels: X = velocity across the width, Y = amp UP the height (amp 1 at
|
||||
// top). Passed explicitly rather than a Rect — see header preamble.
|
||||
struct Box {
|
||||
int left = 0;
|
||||
int top = 0;
|
||||
@@ -128,43 +73,32 @@ public:
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
// Which control point a grab at (x,y) lands on, given the drawn `box`. Returns the index of the
|
||||
// first point within the pick radius in BOTH axes, or -1 for a miss. First-match in point order
|
||||
// for determinism (mirror of nodeAtPoint).
|
||||
// Index of the first point within the pick radius on both axes, or -1 for a miss. First-match
|
||||
// in point order for determinism.
|
||||
int pointAtPixel(const Box& box, int x, int y) const;
|
||||
|
||||
// A node's drawn pixel position (S-VIEW-10). The ONE point->pixel mapping — the same mapping
|
||||
// pointAtPixel hit-tests against — exposed so the editor shell draws the trace + node handles
|
||||
// at exactly the coordinates the hit-test expects (draw and grab can never drift).
|
||||
// The one point->pixel mapping, exposed so drawing and hit-testing can never drift apart.
|
||||
struct CurvePixel {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
};
|
||||
static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p);
|
||||
|
||||
// The absolute pixel -> (velocity, amp) inverse (S-VIEW-10): where an empty-space click lands
|
||||
// as a NEW control point, box-clamped. The exact inverse of pixelFromPoint's mapping (within
|
||||
// the one-pixel quantum), so an added point appears under the cursor. Degenerate box: a
|
||||
// zero-width box reads velocity 0; a height <= 1 box reads amp 1 (the top row), mirroring
|
||||
// pixelFromPoint's degenerate collapse.
|
||||
// Exact inverse of pixelFromPoint (within the one-pixel quantum) — where an empty-space click
|
||||
// lands as a new point. Degenerate box: zero-width reads velocity 0; height <= 1 reads amp 1.
|
||||
static VelocityPoint pointFromPixel(const Box& box, int x, int y);
|
||||
|
||||
// Resolve a drag of point `index` by a pixel delta since grab, given the curve AS OF GRAB TIME
|
||||
// (`grabCurve` — the shell snapshots it on mouse-down so the delta is absolute) and the box.
|
||||
// Maps the pixel delta to a (velocity, amp) delta over the box, then applies movePoint's clamp
|
||||
// (box + neighbour X + endpoint X-pin). A zero-width/height box or out-of-range index returns
|
||||
// `grabCurve` unchanged. Pure — mirror of resolveNodeDrag.
|
||||
// `grabCurve` is the curve as of mouse-down (shell snapshots it so the delta is absolute).
|
||||
// Maps the pixel delta to velocity/amp over the box, then applies movePoint's clamp. Zero
|
||||
// width/height box or out-of-range index returns grabCurve unchanged.
|
||||
static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
|
||||
const Box& box, int dxPixels, int dyPixels);
|
||||
|
||||
// Equality (for tests + round-trip assertions): same point count + each point equal within a
|
||||
// tight epsilon.
|
||||
bool equals(const VelocityCurve& other, double eps = 1e-9) const;
|
||||
|
||||
private:
|
||||
// Points are always X-ordered with an endpoint at 0 and 127. Constructed only through the named
|
||||
// constructors + deserialize (see sample_map), which establish that invariant; the mutators
|
||||
// preserve it.
|
||||
// Always X-ordered with an endpoint at 0 and 127; constructors + deserialize establish the
|
||||
// invariant, mutators preserve it.
|
||||
std::vector<VelocityPoint> points_;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,122 +1,88 @@
|
||||
#pragma once
|
||||
// zone_params.h — the per-zone play-parameter VALUE STRUCTS + per-instance mode enums the
|
||||
// sampler engine, the sample_map resolution layer, the ComponentState codec, and the editor
|
||||
// all share (Q-W2v header split, T4-14/T4-17). Split out of sampler_core.h so a UI or codec
|
||||
// TU that reads a param struct no longer recompiles when a Voice/VoiceEngine member changes.
|
||||
// PURE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes — standard library + peaks only.
|
||||
// The per-frame EVALUATOR classes (AdsrEnvelope / TriggerEnvelope / PitchEnvelope) and the
|
||||
// engine (Keymap / Voice / VoiceEngine) stay in sampler_core.h.
|
||||
// zone_params.h — per-zone play-parameter value structs + per-instance mode enums shared by
|
||||
// the engine, sample_map, the ComponentState codec, and the editor. Split out of sampler_core.h
|
||||
// so a UI/codec TU reading a param struct doesn't recompile when a Voice/VoiceEngine member
|
||||
// changes. The per-frame evaluator classes (AdsrEnvelope/TriggerEnvelope/PitchEnvelope) and the
|
||||
// engine (Keymap/Voice/VoiceEngine) stay in sampler_core.h.
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // AudioSample (float)
|
||||
#include "core/audio/peaks.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Q-W1 interim: the flat `reasampler` namespace is the engine family's home until its own
|
||||
// re-namespace lands; the deps live in their sub-namespace homes.
|
||||
using audio::AudioSample;
|
||||
|
||||
// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7
|
||||
// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders
|
||||
// per-channel. A PERFORMANCE choice the instrument owns (component state), never written
|
||||
// to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain
|
||||
// value so the shell (bus negotiation, state) and the engine share one spelling; the core
|
||||
// itself never branches on it — the mode only picks which render overload the shell drives.
|
||||
// Decode-side downmix policy (see root CLAUDE.md — the output bus itself is permanently
|
||||
// stereo; this only picks mono-downmix vs dual-mono at decode). Never written to the bank.
|
||||
enum class ChannelMode { Mono, Stereo };
|
||||
|
||||
// The instrument's per-instance VOICE MODE (Phase S voice redesign). POLY is today's
|
||||
// polyphonic engine (fixed pool + bounded stealing); MONO is a single voice with LAST-NOTE
|
||||
// priority over a held-note stack (classic mono synth: a new note takes the voice over; the
|
||||
// release of the top note falls back to the most-recent still-held note). A PERFORMANCE
|
||||
// choice the instrument owns (component state), never a bank fact. Default Poly preserves
|
||||
// current behavior.
|
||||
// POLY is the fixed-pool engine with bounded stealing; MONO is a single voice with last-note
|
||||
// priority over a held-note stack (a new note takes over; releasing the top note falls back to
|
||||
// the most-recent still-held one). Never a bank fact. Default Poly.
|
||||
enum class VoiceMode { Poly, Mono };
|
||||
|
||||
// How a MONO takeover treats the envelopes (Phase S — Daniel: explicitly toggleable).
|
||||
// RETRIGGER restarts the amplitude (and pitch) envelope on every new mono note. LEGATO keeps
|
||||
// the envelope running when a note is taken over while another is held — pitch moves without
|
||||
// a re-attack (and the fallback on top-note release glides back the same way). Legato applies
|
||||
// only to a SAME-SAMPLE takeover: crossing into a zone playing a different sample restarts
|
||||
// the voice (one read head cannot glide between two PCM streams; a re-attack on a sample
|
||||
// change is the deterministic, documented fallback). Meaningless in Poly. Default Retrigger.
|
||||
// How a MONO takeover treats the envelopes. RETRIGGER restarts amp/pitch envelopes on every new
|
||||
// mono note. LEGATO keeps the envelope running across a takeover (pitch moves without a
|
||||
// re-attack) but only for a SAME-SAMPLE takeover — one read head can't glide between two PCM
|
||||
// streams, so crossing into a different sample always restarts the voice. Meaningless in Poly.
|
||||
enum class MonoTrigger { Retrigger, Legato };
|
||||
|
||||
// The user-parameterized polyphony bound (Phase S): a per-instance persisted voice count.
|
||||
// One spelling shared by the engine, the component-state (de)serializer, and the editor's
|
||||
// control so the range can never drift apart. Default 16 == the pre-Phase-S fixed pool.
|
||||
// Shared range so the engine, the component-state codec, and the editor control can't drift.
|
||||
inline constexpr int kMinVoiceCount = 1;
|
||||
inline constexpr int kMaxVoiceCount = 32;
|
||||
inline constexpr int kDefaultVoiceCount = 16;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because
|
||||
// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching
|
||||
// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower
|
||||
// with the rest of the engine machinery; only the value structs need to precede SampleData.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack
|
||||
// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below.
|
||||
// AHDSR amplitude envelope. holdFrames == 0 is exactly the pre-hold-stage ADSR (back-compat).
|
||||
struct AdsrParams {
|
||||
std::int64_t attackFrames = 0;
|
||||
std::int64_t holdFrames = 0; // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR
|
||||
std::int64_t holdFrames = 0;
|
||||
std::int64_t decayFrames = 0;
|
||||
double sustainLevel = 1.0; // 0..1
|
||||
std::int64_t releaseFrames = 0;
|
||||
};
|
||||
|
||||
// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's
|
||||
// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop,
|
||||
// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone
|
||||
// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before.
|
||||
// GATE = classic held note (AHDSR + sustain loop + note-off release). TRIGGER = one-shot:
|
||||
// note-off-immune, no sustain loop, plays a % of sample length shaped by fade-in/out. Both
|
||||
// honor the start point. Per-zone; default Gate so an instrument with no params set plays
|
||||
// exactly as before.
|
||||
enum class PlayMode { Gate, Trigger };
|
||||
|
||||
// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span
|
||||
// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)),
|
||||
// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over
|
||||
// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play
|
||||
// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger.
|
||||
// Playback covers [startFrame, playEnd), playEnd = startFrame +
|
||||
// round(lengthFraction*(frames - startFrame)). Amplitude ramps 0->1 over fadeInFrames at the
|
||||
// head and 1->0 over fadeOutFrames anchored to playEnd; unity between. Fades clamp so
|
||||
// fadeIn + fadeOut <= play length. The voice frees when the head reaches playEnd.
|
||||
struct TriggerParams {
|
||||
double lengthFraction = 1.0; // (0,1] of the post-start span to play
|
||||
std::int64_t fadeInFrames = 0; // 0->1 ramp at the head
|
||||
std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd
|
||||
std::int64_t fadeInFrames = 0;
|
||||
std::int64_t fadeOutFrames = 0;
|
||||
};
|
||||
|
||||
// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default
|
||||
// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool)
|
||||
// so a third curve can join without a signature change.
|
||||
// EQUAL_POWER (constant-power sin/cos) is the click-free default for Trigger's ramps; LINEAR is
|
||||
// the build-time residual.
|
||||
enum class FadeCurve { EqualPower, Linear };
|
||||
|
||||
// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted.
|
||||
inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
|
||||
|
||||
// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration
|
||||
// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances
|
||||
// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length).
|
||||
// VARISPEED: readPos_ += ratio_, pitch and duration coupled (an octave up plays half as long).
|
||||
// PRESERVE: the read advances at the source rate while a PitchShifter transposes the output
|
||||
// (an octave up keeps its length).
|
||||
enum class PitchEngine { Varispeed, Preserve };
|
||||
|
||||
// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching"
|
||||
// directive). ONE constant to flip if Varispeed should be the default instead. This is the
|
||||
// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's
|
||||
// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core
|
||||
// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16
|
||||
// engine" holds for the core's own regression tests (an octave up still halves duration in the
|
||||
// bare engine); the Preserve product default is layered on above at (de)serialization.
|
||||
// Product default is Preserve, but applied at the state boundary (sample_map deserialize /
|
||||
// editor zone-creation) for new/absent zones, NOT here: ZonePlayParams.pitchEngine itself
|
||||
// defaults to Varispeed so "no params == the bare engine" holds for the core's own regression
|
||||
// tests (an octave up still halves duration with no params set).
|
||||
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
|
||||
|
||||
// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds
|
||||
// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger =
|
||||
// smoother on big transpositions. Onset latency is ZERO: start() primes the ring with the first
|
||||
// window of real source, so output frame 0 IS source frame 0 regardless of window size (GA2 fix).
|
||||
// One knob, resolved at voice allocation.
|
||||
// OLA window for the Preserve PitchShifter, in ms at the voice's sample rate; larger = smoother
|
||||
// on big transpositions. Onset latency is zero — start() primes the ring with the first window
|
||||
// of real source, so output frame 0 is source frame 0 regardless of window size.
|
||||
inline constexpr double kPreserveWindowMs = 50.0;
|
||||
|
||||
// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always
|
||||
// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to
|
||||
// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack
|
||||
// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-).
|
||||
// AD pitch-modulation envelope, off by default (enabled=false -> offset always 0 -> bit-identical
|
||||
// to the un-modulated engine). At note-on the offset rises to peakSemitones over attackFrames,
|
||||
// then falls to 0 over decayFrames; a zero attack gives a pure percussive pitch drop.
|
||||
struct PitchEnvParams {
|
||||
bool enabled = false;
|
||||
std::int64_t attackFrames = 0;
|
||||
@@ -124,69 +90,53 @@ struct PitchEnvParams {
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
};
|
||||
|
||||
// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData
|
||||
// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16
|
||||
// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope
|
||||
// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the
|
||||
// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one
|
||||
// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine.
|
||||
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
|
||||
// Varispeed, pitch envelope off) — core regression tests rely on this; the Preserve product
|
||||
// default is layered on at (de)serialization, see kDefaultPitchEngine.
|
||||
struct ZonePlayParams {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrParams adsr; // Gate: the AHDSR envelope
|
||||
TriggerParams trigger; // Trigger: %-length + fades
|
||||
AdsrParams adsr;
|
||||
TriggerParams trigger;
|
||||
PitchEngine pitchEngine = PitchEngine::Varispeed;
|
||||
PitchEnvParams pitchEnv; // AD pitch modulation, off by default
|
||||
PitchEnvParams pitchEnv;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that
|
||||
// govern playback. The shell decodes the on-disk WAV and fills this; the core
|
||||
// never touches a file.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample data the core plays: plain decoded PCM + the bank intrinsics that govern playback.
|
||||
// The shell decodes the on-disk WAV and fills this; the core never touches a file.
|
||||
|
||||
// A loop over [start, end) frames, half-open. A zero-length loop (start == end)
|
||||
// is the "no sustain loop" marker — a held note past the sample end goes silent
|
||||
// rather than looping a zero span. absent-loop is modeled by leaving hasLoop false.
|
||||
// [start, end) frames, half-open. A zero-length loop (start == end) is the "no sustain loop"
|
||||
// marker — a held note past the sample end goes silent rather than looping a zero span.
|
||||
struct SampleLoop {
|
||||
bool hasLoop = false;
|
||||
std::int64_t start = 0; // first looped frame (inclusive)
|
||||
std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end
|
||||
std::int64_t start = 0;
|
||||
std::int64_t end = 0;
|
||||
};
|
||||
|
||||
// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is
|
||||
// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample).
|
||||
// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise
|
||||
// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both
|
||||
// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical
|
||||
// across channels; only the sampled value differs. `rootNote` is the MIDI note the file was
|
||||
// recorded at (S2 intrinsic) — the pitch that plays back at unity ratio.
|
||||
// Deinterleaved per-channel: `frames` is channel 0 (always present), `framesR` is channel 1
|
||||
// (present only for a stereo sample). Stereo iff `framesR` is non-empty and the same length as
|
||||
// `frames`; a mismatched length is treated as absent (mono) rather than half-playing. Both
|
||||
// channels share `readPos_`/`rootNote`/`loop`, so repitch/loop stay per-frame identical across
|
||||
// channels. `rootNote` is the MIDI note the file was recorded at — plays at unity ratio there.
|
||||
struct SampleData {
|
||||
std::vector<AudioSample> frames; // channel 0 PCM (mono, or L of a stereo sample)
|
||||
std::vector<AudioSample> framesR; // channel 1 PCM (R); EMPTY for a mono sample
|
||||
int sampleRate = 0; // frames per second (for reference; ratio is
|
||||
// note-relative, so rate cancels for repitch).
|
||||
// 0 is explicitly invalid — every consumer must
|
||||
// receive a real rate before use.
|
||||
int rootNote = 60; // MIDI note recorded at (plays at unity here)
|
||||
SampleLoop loop; // sustain loop, if any
|
||||
// Initial read position (frame offset) a voice starts playback at — frame 0 by
|
||||
// default, so an unset start point is exactly the pre-S11 behavior. S11 makes this
|
||||
// an instrument-side per-zone override (the "start point" marker); S15 builds on it
|
||||
// (both play modes carry a modifiable start). Clamped into [0, frames) at note-on:
|
||||
// a start >= the sample length is a no-op (voice starts at 0), never out of bounds.
|
||||
std::vector<AudioSample> frames;
|
||||
std::vector<AudioSample> framesR; // empty for a mono sample
|
||||
int sampleRate = 0; // ratio math is note-relative, so rate cancels for
|
||||
// repitch; still, 0 is invalid — every consumer must
|
||||
// receive a real rate before use.
|
||||
int rootNote = 60;
|
||||
SampleLoop loop;
|
||||
|
||||
// Frame offset a voice starts playback at; frame 0 default is the pre-existing behavior.
|
||||
// Clamped into [0, frames) at note-on — a start >= sample length is a no-op (starts at 0).
|
||||
std::int64_t startFrame = 0;
|
||||
|
||||
// S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch
|
||||
// envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is
|
||||
// Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData.
|
||||
ZonePlayParams play;
|
||||
|
||||
// 2 iff a matching-length second channel exists; else 1. A framesR of a different
|
||||
// length than frames is treated as absent (mono) — a malformed pair never half-plays.
|
||||
// A framesR of a different length than frames is treated as absent — a malformed pair
|
||||
// never half-plays.
|
||||
int channelCount() const {
|
||||
return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
|
||||
@@ -10,20 +10,14 @@
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
std::int64_t parseBankGeneration(const std::string& raw) {
|
||||
// Whole-string, non-negative decimal parse WITHOUT exceptions or locale
|
||||
// surprises — the shared core/wire accumulate (Q-W1, T2-01b). A leading
|
||||
// '+' / '-', any non-digit, an empty string, or overflow past int64 max all
|
||||
// reject to the absent default (0); the guarded accumulate means a
|
||||
// pathologically long digit run can never wrap into a bogus small value.
|
||||
// Whole-string, non-negative decimal parse, no exceptions/locale surprises (core/wire's
|
||||
// guarded accumulate). Leading sign, non-digit, empty, or int64 overflow -> absent (0).
|
||||
std::int64_t value = 0;
|
||||
if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent;
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string formatBankGeneration(std::int64_t generation) {
|
||||
// Non-negative decimal; a negative (should never be produced by the writer) formats as
|
||||
// its std::to_string form and would parse back to 0, so the writer's monotonic counter
|
||||
// stays in the >= 0 domain by construction.
|
||||
return std::to_string(generation);
|
||||
}
|
||||
|
||||
@@ -37,23 +31,20 @@ AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& re
|
||||
AssignConsumeDecision d;
|
||||
d.consumedGeneration = lastConsumed; // default: nothing changes
|
||||
|
||||
// Rule 1: no request, or not newer than what we already consumed -> nothing new.
|
||||
// Rule 1: no request, or not newer than what we already consumed.
|
||||
if (!request) return d;
|
||||
if (request->generation <= lastConsumed) return d;
|
||||
|
||||
// Rule 2: a new request, but this instance is not the target -> do not act, do NOT
|
||||
// advance the marker (stay eligible if focus later lands here). No thundering herd.
|
||||
// Rule 2: new but not our target -> don't advance the marker, stay eligible.
|
||||
if (!isFocusedTarget) return d;
|
||||
|
||||
// The request is new AND we are the target: it will be consumed-as-seen either way, so
|
||||
// advance the marker to its generation so it is never re-evaluated.
|
||||
// New and our target: consumed-as-seen either way.
|
||||
d.consumedGeneration = request->generation;
|
||||
|
||||
// Rule 3: unresolvable (bankId, sampleId) -> DROP silently (reader requirement): marker
|
||||
// advanced above, but no selection change.
|
||||
// Rule 3: unresolvable -> drop silently, marker already advanced above.
|
||||
if (!resolves) return d;
|
||||
|
||||
// Rule 4: new, target, resolvable -> apply the selection.
|
||||
// Rule 4: new, target, resolvable -> apply.
|
||||
d.apply = true;
|
||||
d.bankId = request->bankId;
|
||||
d.sampleId = request->sampleId;
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
#pragma once
|
||||
// bank_sync — PURE decision logic for the S9 bank-generation change-detection and the
|
||||
// S8 instrument-side assignment-request consume. NO VST3, NO REAPER, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the mirror of
|
||||
// sample_map / bridge_marshal splitting the fiddly, testable arithmetic out of a
|
||||
// host-facing shell.
|
||||
//
|
||||
// WHY IT EXISTS (S9/S8 reader seams). The instrument polls two "reasampler" ext-state
|
||||
// keys off the audio thread: the S9 bank-generation counter (has the bank changed?) and
|
||||
// the S8 assignment request (should I switch to a just-ingested sample?). The RAW string
|
||||
// read crosses the bridge in the shell; every DECISION after — parse the generation
|
||||
// stamp, decide whether it differs from what we last saw, decide whether a decoded
|
||||
// assignment request is NEW-and-resolvable-and-worth-applying — is pure and lives here.
|
||||
//
|
||||
// The processor shell owns the cadence (a UI-thread timer, NEVER process) and the side
|
||||
// effects (reloadInstrument, setSelectedSampleId); this module owns only the yes/no maths so
|
||||
// the reader's rules are provable without a host. assignment_request.h owns the WIRE format
|
||||
// (encode/decode); this module owns the CONSUME decision layered over a decoded request.
|
||||
// bank_sync — decision logic for bank-generation change-detection and the instrument-side
|
||||
// assignment-request consume. The instrument polls two "reasampler" ext-state keys off the
|
||||
// audio thread: the bank-generation counter (has the bank changed?) and the assignment
|
||||
// request (should I switch to a just-ingested sample?). The shell reads the raw strings and
|
||||
// owns cadence (a UI-thread timer, never `process`) + side effects (reloadInstrument,
|
||||
// setSelectedSampleId); this module owns only the yes/no decisions, so they're provable
|
||||
// without a host. assignment_request.h owns the wire format; this owns the consume decision.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
@@ -27,41 +17,26 @@ namespace reasampler::instrument::map {
|
||||
|
||||
using wire::AssignmentRequest;
|
||||
|
||||
// The S9 bank-generation "generation 0 = never stamped" default. A project saved before
|
||||
// S9 shipped carries no bank_generation key; the bridge read yields an absent/empty value
|
||||
// which parses to this, and the first real bump (>= 1) then reads as a change. Matches the
|
||||
// writer's monotonic-from-1 counter (the extension bumps to 1 on the first mutation).
|
||||
// Default for a project with no bank_generation key yet (pre-existing project); the first
|
||||
// real bump (>= 1) then reads as a change against this.
|
||||
inline constexpr std::int64_t kBankGenerationAbsent = 0;
|
||||
|
||||
// Parse the raw bank-generation ext-state value the bridge read. The writer stamps a
|
||||
// non-negative decimal integer (formatBankGeneration). Absent / empty / malformed / negative
|
||||
// / overflowing all yield kBankGenerationAbsent (0) — the reader treats any unreadable stamp
|
||||
// as "generation 0", so a pre-S9 or corrupt value is a clean default, never a crash and never
|
||||
// a spurious reload storm (0 vs a previously-seen 0 is no change). Whole-string parse: trailing
|
||||
// garbage after the digits rejects the value (returns 0), so a torn/partial write is ignored
|
||||
// until the next clean poll (the read tolerates staleness by design — it reloads on the NEXT
|
||||
// poll once the value is clean).
|
||||
// Absent / empty / malformed / negative / overflowing all yield kBankGenerationAbsent (0),
|
||||
// never a crash or spurious reload. Whole-string parse: trailing garbage rejects the value,
|
||||
// so a torn/partial write is ignored until the next clean poll.
|
||||
std::int64_t parseBankGeneration(const std::string& raw);
|
||||
|
||||
// Format a bank-generation counter for the ext-state stamp. The inverse of
|
||||
// parseBankGeneration for a non-negative value: a plain decimal, no sign, no padding, so
|
||||
// the stamp is byte-stable across writes of the same value.
|
||||
// Inverse of parseBankGeneration: plain decimal, no sign, no padding — byte-stable across
|
||||
// writes of the same value.
|
||||
std::string formatBankGeneration(std::int64_t generation);
|
||||
|
||||
// Has the bank generation changed since the reader last saw `seen`? True when `current`
|
||||
// differs from `seen` — the reader then triggers a reload. Any difference counts (not just
|
||||
// an increase): the writer is monotonic, but a project switch or reload can legitimately
|
||||
// lower the value, and the reader should re-read the bank in that case too. `seen` starts at
|
||||
// kBankGenerationAbsent so the first non-zero generation reads as a change (the pre-S9 /
|
||||
// first-bump refresh the spec requires).
|
||||
// True when `current` differs from `seen` (not just increases — a project switch/reload can
|
||||
// legitimately lower the value, and the reader should still re-read the bank).
|
||||
bool bankGenerationChanged(std::int64_t seen, std::int64_t current);
|
||||
|
||||
// The verdict of the S8 assignment-request consume decision (below). A pure value the
|
||||
// processor shell acts on: apply the selection (or not) and advance the consumed marker
|
||||
// (or not). Distinct booleans because the two are NOT the same event — a request may be
|
||||
// consumed-as-seen (marker advances) without being applied (it named an unresolvable
|
||||
// sample and was DROPPED per the reader requirement), so the shell must not re-evaluate it
|
||||
// every poll.
|
||||
// Verdict of the assignment-request consume decision below. apply and consumedGeneration
|
||||
// advancing are NOT the same event — a request naming an unresolvable sample is dropped
|
||||
// (consumed-as-seen) without applying, so the shell doesn't re-evaluate it every poll.
|
||||
struct AssignConsumeDecision {
|
||||
bool apply = false; // set this instance's selection to (bankId, sampleId) + reload
|
||||
std::string bankId; // the request's bank (valid only when apply)
|
||||
@@ -69,37 +44,15 @@ struct AssignConsumeDecision {
|
||||
std::int64_t consumedGeneration = 0; // the marker to persist (== lastConsumed when nothing new)
|
||||
};
|
||||
|
||||
// Decide whether to CONSUME a decoded assignment request (S8 instrument-side reader).
|
||||
// `lastConsumed` persists across reopen so a request already applied and manually changed
|
||||
// away from is not reapplied. `resolves` is whether (bankId, sampleId) exists in the live
|
||||
// bank right now. `isFocusedTarget` gates thundering-herd (only the focused-editor instance
|
||||
// applies; others neither apply nor advance their marker, staying eligible if focus moves).
|
||||
//
|
||||
// `request` — the decoded assignment request (nullopt when the assign_request key
|
||||
// is absent / malformed — nothing pending).
|
||||
// `lastConsumed` — the generation this instance last consumed (persisted in component
|
||||
// state so a re-open does not re-apply a request the user already got,
|
||||
// then manually changed away from). Defaults to 0 for a fresh instance.
|
||||
// `resolves` — whether the request's (bankId, sampleId) resolves to an existing bank
|
||||
// sample RIGHT NOW (the shell computed this against the live bank blob).
|
||||
// `isFocusedTarget` — whether THIS instance is the assignment target under the shell's
|
||||
// thundering-herd policy (e.g. only the focused-editor instance applies).
|
||||
// The shell passes true when this instance should act; false suppresses
|
||||
// consumption entirely so a non-target instance neither applies nor
|
||||
// advances its marker (it stays eligible if it later becomes the target).
|
||||
//
|
||||
// RULES (all pure, order matters):
|
||||
// 1. No request, or an OLDER/equal generation (<= lastConsumed): nothing new — do not
|
||||
// apply, marker unchanged. (Covers the re-open case: the persisted marker == the
|
||||
// request's generation, so it is not re-applied.)
|
||||
// 2. A NEW request (generation > lastConsumed) but NOT this instance's target: do not
|
||||
// apply and do NOT advance the marker — a non-target instance must stay able to consume
|
||||
// the request if focus later lands on it. (No thundering herd: only the target acts.)
|
||||
// 3. A NEW request, this instance IS the target, but the (bankId, sampleId) does NOT
|
||||
// resolve: DROP it silently (assignment_request.h reader requirement) — do not apply,
|
||||
// but DO advance the marker to the request's generation so a stale/unresolvable request
|
||||
// is consumed-as-seen and never re-evaluated (no error state, no selection change).
|
||||
// 4. A NEW request, target, and resolvable: APPLY (selection <- (bankId, sampleId)) and
|
||||
// advance the marker to the request's generation.
|
||||
//
|
||||
// The shell then: if apply, setSelectedSampleId + reloadInstrument; always persist
|
||||
// consumedGeneration into component state when it advanced.
|
||||
// Rules, in order: (1) no request or generation <= lastConsumed -> no-op. (2) new but not
|
||||
// the target -> no-op, marker unchanged (stays eligible later). (3) new, target, but doesn't
|
||||
// resolve -> drop silently, marker still advances (consumed-as-seen, never re-evaluated).
|
||||
// (4) new, target, resolves -> apply + advance marker.
|
||||
AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request,
|
||||
std::int64_t lastConsumed, bool resolves,
|
||||
bool isFocusedTarget);
|
||||
|
||||
@@ -6,9 +6,8 @@ namespace reasampler::instrument::map {
|
||||
|
||||
std::optional<std::string> decodeGetProjExtState(int apiReturn,
|
||||
const std::string& buffer) {
|
||||
// REAPER returns the length of the stored value; 0 means the key is absent. Guard
|
||||
// both the return AND the buffer: a caller that reused a dirty buffer must not
|
||||
// surface stale bytes as a value when the API reported nothing.
|
||||
// 0 return means absent; guard the buffer too so a reused dirty buffer can't
|
||||
// surface stale bytes as a value.
|
||||
if (apiReturn <= 0 || buffer.empty()) return std::nullopt;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
// bridge_marshal.h — PURE marshalling helper for the REAPER VST-host bridge read.
|
||||
// NO VST3, NO REAPER types at the boundary.
|
||||
//
|
||||
// The bridge shell (reaper_bridge.cpp) resolves REAPER API functions by name over the
|
||||
// host callback and invokes them; the one fiddly-and-easy-to-get-wrong part around
|
||||
// GetProjExtState — interpreting its int return against the buffer it filled — is pure
|
||||
// and unit-tested here. Mirror of capture_paths / wav_codec splitting the arithmetic out
|
||||
// of a REAPER-facing shell.
|
||||
//
|
||||
// The S1 spike ALSO carried a string-scan JSON reader (extractJsonStringField) as a
|
||||
// stand-in until the instrument could parse the bank properly. S4 retired it: the
|
||||
// instrument now parses the "reasampler" bank blob through the SHARED bank_book /
|
||||
// bank_model JSON path (sample_map.cpp), so there is no second JSON parser. This module
|
||||
// is back to its one honest job — the API-return decode.
|
||||
// bridge_marshal — pure GetProjExtState result decode for the REAPER VST-host bridge read.
|
||||
//
|
||||
// Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
|
||||
// int GetProjExtState (ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz);
|
||||
// -- returns the length written (0 when the key is absent).
|
||||
// int GetProjExtState(ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz)
|
||||
// returns the length written (0 when the key is absent).
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -26,18 +13,10 @@
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// Interpret a GetProjExtState result: the int return value (bytes the API reports for
|
||||
// the key) and the buffer it filled. Returns the value only when the API reported a
|
||||
// non-empty result AND the buffer is non-empty — REAPER writes 0 and leaves the buffer
|
||||
// untouched for an absent key, and we must not treat stale buffer contents as a hit.
|
||||
//
|
||||
// `apiReturn` is GetProjExtState's return; `buffer` is the NUL-terminated string it
|
||||
// wrote (already truncated to the C string by the caller).
|
||||
// Value only when the API reported non-empty AND the buffer is non-empty — REAPER
|
||||
// writes 0 and leaves the buffer untouched for an absent key, so stale buffer
|
||||
// contents must never read as a hit.
|
||||
std::optional<std::string> decodeGetProjExtState(int apiReturn,
|
||||
const std::string& buffer);
|
||||
|
||||
// The GetProjExtState GROW-LOOP retry policy (T2-04) lived here through Q-W5; it
|
||||
// was rehomed to core/wire/ext_state_read.h in Q-W6 (its consumers are 2:1
|
||||
// extension-side, so it belongs on the neutral wire seam, not the instrument map).
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// component_state_io — the ComponentState envelope + zones-payload binary codec. See
|
||||
// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7)
|
||||
// and the why-a-separate-module note (Q-W2v, T4-13 ≡ T2-07). PURE: standard library +
|
||||
// the pure sample_map value types + core/wire's LE byte codec (T4-20) + velocity_curve
|
||||
// + master_gain. Every wire format is FROZEN — byte-identical to the pre-split writer.
|
||||
// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7).
|
||||
// Every wire format is FROZEN — byte-identical across revisions.
|
||||
|
||||
#include "core/instrument/map/component_state_io.h"
|
||||
|
||||
@@ -28,13 +26,11 @@ namespace {
|
||||
// Signed 64-bit values ride the wire as their two's-complement unsigned image.
|
||||
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
|
||||
|
||||
// Append the zones payload — the shared body of the performance blob and the component blob,
|
||||
// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion
|
||||
// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail
|
||||
// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes
|
||||
// the zone count so any reader can detect the record shape independently of the envelope version
|
||||
// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip
|
||||
// through EITHER envelope with no envelope bump.
|
||||
// Append the zones payload — the shared body of the performance blob and the component
|
||||
// blob, so both write zones identically. Always emits the CURRENT payload version (marker +
|
||||
// version + extended records: loop/start tail + full play-params tail in SECONDS); the
|
||||
// marker precedes the zone count so any reader can detect record shape independent of the
|
||||
// envelope version (see sample_map.h).
|
||||
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
|
||||
putLE(out, kZonesFormatMarker);
|
||||
putLE(out, kZonesPayloadVersion);
|
||||
@@ -49,7 +45,7 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
|
||||
putLE(out,
|
||||
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride)));
|
||||
}
|
||||
// S11 extension: loop override (hasLoop flag + start/end), then start point.
|
||||
// loop override (hasLoop flag + start/end), then start point.
|
||||
out.push_back(z.loopOverride ? 1 : 0);
|
||||
if (z.loopOverride) {
|
||||
out.push_back(z.loopOverride->hasLoop ? 1 : 0);
|
||||
@@ -59,9 +55,9 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
|
||||
out.push_back(z.startPoint ? 1 : 0);
|
||||
if (z.startPoint) putLE(out, asU64(*z.startPoint));
|
||||
|
||||
// S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine).
|
||||
// Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames /
|
||||
// fraction. Order matches the header's v5 record spec.
|
||||
// Play params (PAYLOAD v5): always present. Wall-clock times are SECONDS (doubles);
|
||||
// trigger %-length + fades stay source frames/fraction. Order matches the header's
|
||||
// v5 record spec.
|
||||
const ZonePlaySeconds& pp = z.play;
|
||||
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
|
||||
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
||||
@@ -78,10 +74,10 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
|
||||
putLE(out, doubleToBits(pp.adsr.decaySeconds));
|
||||
putLE(out, doubleToBits(pp.adsr.sustainLevel));
|
||||
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||
// PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar (1.0 = 100% ET).
|
||||
// PAYLOAD v6: the per-zone key-tracking scalar (1.0 = 100% ET).
|
||||
putLE(out, doubleToBits(z.keyTrack));
|
||||
// PAYLOAD v7 (S-VIEW-9): the per-zone velocity->amp transfer curve, appended last. 4-byte LE
|
||||
// control-point count, then per point velocity + amp as IEEE-754 doubles (endpoints included).
|
||||
// PAYLOAD v7: the per-zone velocity->amp transfer curve, appended last. 4-byte LE
|
||||
// control-point count, then per point velocity + amp as doubles (endpoints included).
|
||||
const std::vector<VelocityPoint>& pts = z.velocityCurve.points();
|
||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& p : pts) {
|
||||
@@ -91,30 +87,30 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
|
||||
}
|
||||
}
|
||||
|
||||
// Read a zones payload from `r` into `map`. Shared by the performance parse and the component
|
||||
// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the
|
||||
// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail —
|
||||
// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read
|
||||
// keeps the zones that parsed cleanly and drops the rest.
|
||||
// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock frame
|
||||
// counts (holdFrames, pitchEnv A/D) to the seconds domain at the read boundary: seconds = frames /
|
||||
// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed.
|
||||
// Read a zones payload from `r` into `map`. Shared by the performance parse and the
|
||||
// component parse. Detects the format marker: present -> PAYLOAD v2+ (extended records with
|
||||
// the loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (no tail — clean
|
||||
// back-compat lift, overrides default absent). A truncated mid-zone read keeps the zones
|
||||
// that parsed cleanly and drops the rest.
|
||||
// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock
|
||||
// frame counts (holdFrames, pitchEnv A/D) to seconds at the read boundary: seconds = frames
|
||||
// / projectRate. Must be > 0 (callers guard). v5+ blobs carry seconds directly; no rate needed.
|
||||
void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
||||
bool extended = false; // v2+: the S11 loop/start tail is present
|
||||
bool extended = false; // v2+: the loop/start tail is present
|
||||
std::uint32_t pv = 0; // payload version (0 = v1, no marker)
|
||||
if (r.peekU32() == kZonesFormatMarker) {
|
||||
r.u32(); // consume the marker
|
||||
pv = r.u32(); // payload version
|
||||
extended = (pv >= 2); // v2+ carries the loop/start tail
|
||||
}
|
||||
const bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames
|
||||
const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in 44.1k frames
|
||||
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
|
||||
const bool keyTrackTail = (pv >= 6); // v6+ (S-VIEW-6): per-zone keyTrack scalar
|
||||
const bool curveTail = (pv >= 7); // v7+ (S-VIEW-9): per-zone velocity->amp curve, appended last
|
||||
const bool keyTrackTail = (pv >= 6); // v6+: per-zone keyTrack scalar
|
||||
const bool curveTail = (pv >= 7); // v7+: per-zone velocity->amp curve, appended last
|
||||
const std::uint32_t count = r.u32();
|
||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||
// z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A
|
||||
// v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1).
|
||||
// z.play defaults to the product defaults (Gate + Preserve + tier-0 AHDSR seconds).
|
||||
// A v1/v2 payload (no play tail) lifts every zone to those defaults.
|
||||
PerformanceZone z;
|
||||
const std::uint32_t idLen = r.u32();
|
||||
z.sampleId = r.str(idLen);
|
||||
@@ -135,10 +131,10 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
||||
if (hasStart) z.startPoint = r.i64();
|
||||
}
|
||||
if (legacyV3Play) {
|
||||
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D)
|
||||
// were written as frames -> divide by the project sample rate (threaded in as `projectRate`)
|
||||
// to reach the seconds domain. Trigger %-length + fades are source-timeline, read as-is.
|
||||
// A/D/S/R are ABSENT in v3 -> leave the seconds defaults on z.play.adsr.
|
||||
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv
|
||||
// A/D) were written as frames -> divide by `projectRate` to reach seconds.
|
||||
// Trigger %-length + fades are source-timeline, read as-is. A/D/S/R are ABSENT
|
||||
// in v3 -> leave the seconds defaults on z.play.adsr.
|
||||
assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift");
|
||||
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||
@@ -169,21 +165,20 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
||||
z.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||
z.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||
}
|
||||
// PAYLOAD v6 (S-VIEW-6): the key-tracking scalar, appended after the v5 play tail. A pre-v6
|
||||
// payload (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an
|
||||
// already-saved instance repitches BIT-IDENTICALLY to the pre-S-VIEW-6 engine.
|
||||
// PAYLOAD v6: key-tracking scalar, appended after the v5 play tail. A pre-v6 payload
|
||||
// (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an
|
||||
// already-saved instance repitches BIT-IDENTICALLY.
|
||||
if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64());
|
||||
// PAYLOAD v7 (S-VIEW-9): the velocity->amp transfer curve, appended after the v6 keyTrack. A
|
||||
// pre-v7 payload (no field) leaves the PerformanceZone default (VelocityCurve::flat() — R10-F1
|
||||
// Option A, flat y=1), the deliberate NON-back-compat behavior change for already-saved zones.
|
||||
// fromPoints repairs the X-order/endpoint invariant defensively; a truncated read (r.ok flips
|
||||
// false mid-curve) leaves the flat default and the mid-zone break below drops the rest.
|
||||
// PAYLOAD v7: velocity->amp transfer curve, appended after the v6 keyTrack. A pre-v7
|
||||
// payload (no field) leaves the PerformanceZone default (VelocityCurve::flat(),
|
||||
// Daniel-approved), the deliberate NON-back-compat behavior change for already-saved
|
||||
// zones. fromPoints repairs the X-order/endpoint invariant defensively; a truncated
|
||||
// read leaves the flat default and the mid-zone break below drops the rest.
|
||||
if (curveTail) {
|
||||
const std::uint32_t ptCount = r.u32();
|
||||
std::vector<VelocityPoint> pts;
|
||||
// Bound the reserve to what the blob can actually hold (16 bytes/point) so a corrupt huge
|
||||
// count can't trigger a giant allocation before the bounded reads fail — the loop still
|
||||
// stops on r.ok, this only caps the speculative reserve.
|
||||
// Bound the reserve to what the blob can hold (16 bytes/point) so a corrupt huge
|
||||
// count can't trigger a giant allocation before the bounded reads fail.
|
||||
const std::size_t remaining = r.bytes.size() > r.pos ? r.bytes.size() - r.pos : 0;
|
||||
pts.reserve(std::min(static_cast<std::size_t>(ptCount), remaining / 16));
|
||||
for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) {
|
||||
@@ -211,15 +206,14 @@ std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
|
||||
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate) {
|
||||
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present.
|
||||
// For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3
|
||||
// blob is encountered with an invalid rate — the calller guarantees a real rate before use.
|
||||
// projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
|
||||
// v5+. The assert inside readZonesPayload fires if a v3 blob has an invalid rate.
|
||||
PerformanceMap map;
|
||||
ByteReader r(bytes);
|
||||
const std::uint32_t version = r.u32();
|
||||
if (!r.ok) return map; // no version tag -> empty
|
||||
|
||||
// BACK-COMPAT: a v1 blob is the S4 single-selection format (version 1 + id bytes,
|
||||
// BACK-COMPAT: a v1 blob is the original single-selection format (version 1 + id bytes,
|
||||
// no length prefix). Lift it to one full-keyboard zone playing that id.
|
||||
if (version == kSelectionStateVersion) {
|
||||
const std::string id = deserializeSelection(bytes);
|
||||
@@ -238,33 +232,30 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
return map;
|
||||
}
|
||||
|
||||
// --- Combined component state (v3, S10) --------------------------------------
|
||||
// --- Combined component state --------------------------------------
|
||||
|
||||
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
std::vector<std::uint8_t> out;
|
||||
putLE(out, kComponentStateVersion);
|
||||
// v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body.
|
||||
// v4 addition: channel mode (0 mono/1 stereo) precedes the v3 body.
|
||||
out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0);
|
||||
// v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE
|
||||
// two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that
|
||||
// stops at the mode byte is a strict prefix (see the v4 lift below).
|
||||
// v5 addition: last-consumed assignment generation, 8-byte LE two's-complement, follows
|
||||
// the mode byte so a v4 reader stopping there is a strict prefix (see the v4 lift below).
|
||||
putLE(out, asU64(state.lastConsumedAssignGeneration));
|
||||
// v6 envelope addition (S-VIEW-4): the preview-trigger velocity, 1 byte (MIDI 1..127). Follows
|
||||
// the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift).
|
||||
// v6 addition: preview-trigger velocity, 1 byte (MIDI 1..127) — a v5 blob is a strict
|
||||
// prefix up to this byte (see the v5 lift).
|
||||
out.push_back(state.previewVelocity);
|
||||
// v7 envelope addition (Phase S voice system): voice count (1..32), voice mode (0 = Poly,
|
||||
// 1 = Mono), mono trigger (0 = Retrigger, 1 = Legato) — one byte each, following the
|
||||
// velocity byte so a v6 blob is a strict prefix up to here (see the v6 lift).
|
||||
// v7 addition: voice count (1..32), voice mode (0 Poly/1 Mono), mono trigger
|
||||
// (0 Retrigger/1 Legato) — one byte each, a v6 blob is a strict prefix up to here.
|
||||
const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount
|
||||
: state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount
|
||||
: state.voiceCount;
|
||||
out.push_back(static_cast<std::uint8_t>(vc));
|
||||
out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0);
|
||||
out.push_back(state.monoTrigger == MonoTrigger::Legato ? 1 : 0);
|
||||
// v8 envelope addition (FB1 master gain): the post-mixer LINEAR gain as an IEEE-754 double
|
||||
// (bit-cast to u64 LE), following the voice bytes so a v7 blob is a strict prefix up to
|
||||
// here (see the v7 lift). The WRITER never emits an out-of-range value: non-finite or
|
||||
// negative falls back to unity; above the +24 dB cap clamps to the cap.
|
||||
// v8 addition: post-mixer LINEAR gain as a double (bit-cast to u64 LE) — a v7 blob is a
|
||||
// strict prefix up to here. The WRITER never emits out-of-range: non-finite/negative
|
||||
// falls back to unity; above the +24 dB cap clamps to the cap.
|
||||
{
|
||||
double g = state.masterGainLinear;
|
||||
const double maxLin = masterGainMaxLinear();
|
||||
@@ -272,16 +263,14 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
if (g > maxLin) g = maxLin;
|
||||
putLE(out, doubleToBits(g));
|
||||
}
|
||||
// v9 envelope addition (GA channel-mode auto-default): the channel-mode-EXPLICIT flag,
|
||||
// 1 byte, following the gain double so a v8 blob is a strict prefix up to here (see the
|
||||
// v8 lift). 0 = implicit (the shell may auto-default the mode from the loaded capture's
|
||||
// channel count); 1 = the user deliberately toggled the mode (never fought).
|
||||
// v9 addition: channel-mode-EXPLICIT flag, 1 byte — a v8 blob is a strict prefix up to
|
||||
// here. 0 = implicit (shell may auto-default from the loaded capture's channel count);
|
||||
// 1 = user deliberately toggled the mode (never fought).
|
||||
out.push_back(state.channelModeExplicit ? 1 : 0);
|
||||
// v10 envelope addition (pS self-contained playback): the instance-owned sample-refs
|
||||
// table, following the explicit flag so a v9 blob is a strict prefix up to here (see
|
||||
// the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per
|
||||
// entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always
|
||||
// written), channelCount, displayName (length-prefixed; display-only).
|
||||
// v10 addition: the instance-owned sample-refs table — a v9 blob is a strict prefix up
|
||||
// to here. Wire shape per kSelectionZonesRefsV10Version: entry count, then per entry id
|
||||
// + path (length-prefixed), rootNote, loop (hasLoop + start/end, always written),
|
||||
// channelCount, displayName (length-prefixed; display-only).
|
||||
putLE(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
|
||||
for (const SampleRefEntry& e : state.sampleRefs) {
|
||||
putLE(out, static_cast<std::uint32_t>(e.sampleId.size()));
|
||||
@@ -312,17 +301,17 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
|
||||
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate) {
|
||||
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present.
|
||||
// For v5 and later blobs it is unused. See readZonesPayload for the guard.
|
||||
// projectRate is only consumed by readZonesPayload for a LEGACY v3 payload; unused for
|
||||
// v5+. See readZonesPayload for the guard.
|
||||
ComponentState out;
|
||||
ByteReader r(bytes);
|
||||
const std::uint32_t version = r.u32();
|
||||
if (!r.ok) return out; // no version tag -> empty (the S10 silent empty state)
|
||||
if (!r.ok) return out; // no version tag -> empty (the silent empty state)
|
||||
|
||||
// BACK-COMPAT: an older blob predates the v3 {selection, zones} split.
|
||||
// * v1 (S4 single-selection: version 1 + id-to-end): restore {id, one full-keyboard
|
||||
// zone} so the old pick survives as BOTH the selection and a one-zone map.
|
||||
// * v2 (S5 zones-only): restore {"", zones} — that instance had zones but no separate
|
||||
// * v1 (original single-selection: version 1 + id-to-end): restore {id, one
|
||||
// full-keyboard zone} so the old pick survives as BOTH the selection and a one-zone map.
|
||||
// * v2 (zones-only): restore {"", zones} — that instance had zones but no separate
|
||||
// single-capture selection.
|
||||
if (version == kSelectionStateVersion) {
|
||||
out.selectionId = deserializeSelection(bytes);
|
||||
@@ -337,20 +326,20 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
}
|
||||
if (version == kPerformanceStateVersion) {
|
||||
readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag
|
||||
return out; // channelMode stays Mono (pre-S7)
|
||||
return out; // channelMode stays Mono
|
||||
}
|
||||
// BACK-COMPAT: a v3 blob (pre-S7 {selection, zones}, no channel mode) restores as MONO —
|
||||
// the id length + id + zones body starts right after the version tag (no mode byte).
|
||||
// BACK-COMPAT: a v3 blob ({selection, zones}, no channel mode) restores as MONO — the id
|
||||
// length + id + zones body starts right after the version tag (no mode byte).
|
||||
if (version == kSelectionZonesV3Version) {
|
||||
const std::uint32_t idLen = r.u32();
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9)
|
||||
return out; // channelMode stays Mono, marker stays 0
|
||||
}
|
||||
// BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker):
|
||||
// mode byte, then the id + zones body — no 8-byte marker. lastConsumedAssignGeneration
|
||||
// defaults to 0, so a first assign still applies for a pre-marker instance.
|
||||
// BACK-COMPAT: a v4 blob ({mode, selection, zones}, no consumed marker): mode byte, then
|
||||
// the id + zones body — no 8-byte marker. lastConsumedAssignGeneration defaults to 0, so
|
||||
// a first assign still applies for a pre-marker instance.
|
||||
if (version == kSelectionZonesModeV4Version) {
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
@@ -359,12 +348,12 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
return out; // marker stays 0 (pre-S8/S9 reader)
|
||||
return out; // marker stays 0
|
||||
}
|
||||
// BACK-COMPAT: a v5 blob (pre-S-VIEW-4 {mode, marker, selection, zones}, no preview-velocity
|
||||
// byte): mode byte, then the 8-byte marker, then the id + zones body — no velocity byte.
|
||||
// previewVelocity defaults to kPreviewVelocityDefault (set at construction), so an already-saved
|
||||
// pre-S-VIEW-4 instance restores at the mid default.
|
||||
// BACK-COMPAT: a v5 blob ({mode, marker, selection, zones}, no preview-velocity byte):
|
||||
// mode byte, then the 8-byte marker, then the id + zones body — no velocity byte.
|
||||
// previewVelocity defaults to kPreviewVelocityDefault (construction default), so an
|
||||
// already-saved instance restores at the mid default.
|
||||
if (version == kSelectionZonesModeMarkerV5Version) {
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
@@ -375,7 +364,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
out.selectionId = r.str(idLen);
|
||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||
readZonesPayload(r, out.map, projectRate);
|
||||
return out; // previewVelocity stays at the mid default (pre-S-VIEW-4)
|
||||
return out; // previewVelocity stays at the mid default
|
||||
}
|
||||
if (version != kComponentStateVersion &&
|
||||
version != kSelectionZonesRefsV10Version &&
|
||||
@@ -386,10 +375,9 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
return out; // unknown -> empty
|
||||
}
|
||||
|
||||
// v6..v10 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker,
|
||||
// then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated
|
||||
// as mono (conservative default) rather than rejected — a corrupt mode never silences the
|
||||
// instance.
|
||||
// v6..v10 shared prefix: channel-mode byte, 8-byte consumed-assignment marker, 1-byte
|
||||
// preview velocity, precede the v3 body. A non-{0,1} mode byte treats as mono
|
||||
// (conservative default) rather than rejected — a corrupt mode never silences the instance.
|
||||
const std::uint8_t modeByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
@@ -402,8 +390,8 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
out.previewVelocity = (previewVel >= 1 && previewVel <= 127)
|
||||
? previewVel
|
||||
: kPreviewVelocityDefault;
|
||||
// v7+ (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the
|
||||
// construction defaults {16, Poly, Retrigger} hold, reproducing pre-Phase-S behavior.
|
||||
// v7+: the three voice-system bytes. A v6 blob skips them — the construction defaults
|
||||
// {16, Poly, Retrigger} hold, reproducing pre-voice-system behavior.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) {
|
||||
const std::uint8_t vc = r.u8();
|
||||
const std::uint8_t vm = r.u8();
|
||||
@@ -417,9 +405,9 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly;
|
||||
out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
|
||||
}
|
||||
// v8+ (FB1): the master-gain LINEAR double. A v7 blob (pre-FB1) skips it — the construction
|
||||
// default (unity) holds, reproducing pre-FB1 output exactly. A non-finite, negative, or
|
||||
// above-cap value (a corrupt blob) falls back to unity rather than silencing/blasting.
|
||||
// v8+: the master-gain LINEAR double. A v7 blob skips it — the construction default
|
||||
// (unity) holds. A non-finite, negative, or above-cap value falls back to unity rather
|
||||
// than silencing/blasting.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) {
|
||||
const double g = bitsToDouble(r.u64());
|
||||
if (!r.ok) return out; // truncated inside the gain double — out already carries
|
||||
@@ -429,18 +417,18 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
? g
|
||||
: 1.0;
|
||||
}
|
||||
// v9 (GA): the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction
|
||||
// default (false = implicit) holds, so an already-saved instance's mode is treated as the
|
||||
// un-touched default and the shell may auto-default it from the loaded capture.
|
||||
// v9: the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction
|
||||
// default (false = implicit) holds, so an already-saved instance's mode is treated as
|
||||
// the untouched default and the shell may auto-default it from the loaded capture.
|
||||
if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) {
|
||||
const std::uint8_t explicitByte = r.u8();
|
||||
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
|
||||
out.channelModeExplicit = (explicitByte == 1);
|
||||
}
|
||||
// v10 (pS self-contained playback): the sample-refs table. A v9-or-older blob skips it —
|
||||
// the EMPTY-table default holds, and the shell lifts the refs once via the bridge-resolve
|
||||
// path (then re-saves self-contained). A truncated mid-entry read keeps the entries that
|
||||
// parsed cleanly and drops the rest (the selection/zones behind it are unreadable anyway).
|
||||
// v10: the sample-refs table. A v9-or-older blob skips it — the EMPTY-table default
|
||||
// holds, and the shell lifts the refs once via the bridge-resolve path (then re-saves
|
||||
// self-contained). A truncated mid-entry read keeps the entries that parsed cleanly and
|
||||
// drops the rest (the selection/zones behind it are unreadable anyway).
|
||||
if (version >= kSelectionZonesRefsV10Version) {
|
||||
const std::uint32_t refCount = r.u32();
|
||||
for (std::uint32_t i = 0; i < refCount && r.ok; ++i) {
|
||||
@@ -449,11 +437,10 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
e.sampleId = r.str(refIdLen);
|
||||
const std::uint32_t pathLen = r.u32();
|
||||
e.ref.relativePath = r.str(pathLen);
|
||||
// Range fallbacks (the refs table is the ONLY copy on the play path, so a
|
||||
// corrupt field must degrade to the field's default, never poison playback —
|
||||
// the previewVelocity/voiceCount posture): an out-of-MIDI-range root falls back
|
||||
// to the middle-C default distill() uses; a negative channel count falls back
|
||||
// to 0 = unknown (the GA auto-default then skips it).
|
||||
// Range fallbacks: the refs table is the ONLY copy on the play path, so a
|
||||
// corrupt field must degrade to the field's default, never poison playback. An
|
||||
// out-of-MIDI-range root falls back to the middle-C default distill() uses; a
|
||||
// negative channel count falls back to 0 = unknown (auto-default then skips it).
|
||||
const std::int32_t root = r.i32();
|
||||
e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60;
|
||||
e.ref.loop.hasLoop = (r.u8() != 0);
|
||||
@@ -468,8 +455,8 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
}
|
||||
if (!r.ok) return out;
|
||||
}
|
||||
// v11 (pS-usage): the minted instance guid. A v10-or-older blob skips it — the
|
||||
// EMPTY default holds and the processor mints a fresh identity on first publish.
|
||||
// v11: the minted instance guid. A v10-or-older blob skips it — the EMPTY default
|
||||
// holds and the processor mints a fresh identity on first publish.
|
||||
if (version >= kSelectionZonesRefsIdentityV11Version) {
|
||||
const std::uint32_t guidLen = r.u32();
|
||||
out.instanceGuid = r.str(guidLen);
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
#pragma once
|
||||
// component_state_io — the ComponentState ENVELOPE + zones-payload binary codec for the
|
||||
// ReaSampler 9000 instrument (Q-W2v split out of sample_map, T4-13 ≡ T2-07). PURE: NO
|
||||
// VST3, NO REAPER, NO SWELL, NO vendor/ includes — the same boundary sample_map keeps.
|
||||
// ReaSampler 9000 instrument. Split out of sample_map so both artifacts can share it: the
|
||||
// instrument's processor reads/writes it at setState/getState, and the extension's
|
||||
// instrument-drop path serializes the identical bytes into a transient .vstpreset, so the
|
||||
// payload and the instrument's reader can never drift — without the extension having to
|
||||
// link the whole voice engine (sampler_core + pitch_shift) just to serialize one preset
|
||||
// blob. Its own links are velocity_curve + master_gain (wire value validation), never the
|
||||
// engine.
|
||||
//
|
||||
// WHY A SEPARATE MODULE. The codec grows on EVERY ComponentState envelope bump (v6→v11
|
||||
// in one quarter), and it is deliberately shared across BOTH artifacts: the instrument's
|
||||
// processor reads/writes it at setState/getState, and the EXTENSION's instrument-drop
|
||||
// path (core/wire/instrument_drop) serializes the same bytes into a transient .vstpreset
|
||||
// so the payload and the instrument's reader can never drift. Housing it inside
|
||||
// sample_map made the extension link the whole voice engine (sampler_core + pitch_shift)
|
||||
// to serialize one preset blob; split out, both artifacts link the codec and only the
|
||||
// VST links the engine. The codec's own links are velocity_curve + master_gain (wire
|
||||
// value validation) — never the engine.
|
||||
//
|
||||
// EVERY wire format below is FROZEN (byte-identical to the pre-split writer); the full
|
||||
// version ladders (envelope v1..v11, zones payload v1..v7) are preserved exactly.
|
||||
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, zones
|
||||
// payload v1..v7) must be preserved exactly.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
@@ -26,108 +21,86 @@ namespace reasampler::instrument::map {
|
||||
|
||||
// --- Performance-map instance state (VST3 setState/getState) -----------------
|
||||
//
|
||||
// The performance map is the instrument's OWN state (D-B), serialized to the VST3
|
||||
// component-state IBStream — NOT written to the "reasampler" bank ext-state (the
|
||||
// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of
|
||||
// truncation/wrong-version by design (bounded reads, never throws across the host).
|
||||
// The performance map is the instrument's OWN state, serialized to the VST3 component-state
|
||||
// IBStream — never written to the "reasampler" bank ext-state. Versioned binary, tolerant
|
||||
// of truncation/wrong-version (bounded reads, never throws across the host).
|
||||
//
|
||||
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
|
||||
// ZONES PAYLOAD.
|
||||
//
|
||||
// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones
|
||||
// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides)
|
||||
// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState
|
||||
// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the
|
||||
// key composition property: the zone-record extension is versioned inside the map blob, not on
|
||||
// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not
|
||||
// collide on a single version number.
|
||||
// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone:
|
||||
// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote,
|
||||
// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride).
|
||||
// A payload starting with a small u32 (the zone count) is v1 — there is no marker.
|
||||
// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone
|
||||
// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended
|
||||
// to each zone record after rootOverride:
|
||||
// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start,
|
||||
// 8-byte LE loop.end (both two's-complement int64);
|
||||
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64).
|
||||
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads
|
||||
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope.
|
||||
// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload
|
||||
// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint
|
||||
// tail (the S15/S16 per-zone play params — always present, NOT flag-gated):
|
||||
// 1 byte playMode (0 = Gate, 1 = Trigger);
|
||||
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal;
|
||||
// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE);
|
||||
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
|
||||
// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve);
|
||||
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom);
|
||||
// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double.
|
||||
// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve +
|
||||
// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved
|
||||
// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest.
|
||||
// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS
|
||||
// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds
|
||||
// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed
|
||||
// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames.
|
||||
// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060).
|
||||
// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5),
|
||||
// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full
|
||||
// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles):
|
||||
// 1 byte playMode (0 = Gate, 1 = Trigger);
|
||||
// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double);
|
||||
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
|
||||
// 1 byte pitchEngine; 1 byte pitchEnv.enabled;
|
||||
// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double);
|
||||
// 8-byte LE pitchEnv.peakSemitones (double);
|
||||
// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double);
|
||||
// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double).
|
||||
// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4
|
||||
// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader
|
||||
// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds
|
||||
// to frames at the LIVE sample rate; no rate is baked into storage or the program.
|
||||
// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is
|
||||
// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved
|
||||
// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes
|
||||
// to an EMPTY map.
|
||||
// ZONES-PAYLOAD FORMAT VERSIONING is self-describing and envelope-independent: the payload
|
||||
// carries its OWN version, so the per-zone record can grow without bumping the envelope
|
||||
// version. Zone-record extensions and envelope-field additions stay on independent axes
|
||||
// that can never collide on one version number.
|
||||
// * v1 (original, no marker): 4-byte LE zone count, then per zone: 4-byte LE id length +
|
||||
// id bytes, 4-byte LE lowNote, 4-byte LE highNote, 1 byte hasRootOverride, 4-byte LE
|
||||
// rootOverride (iff hasRootOverride). A payload starting with a small u32 (zone count)
|
||||
// is v1.
|
||||
// * v2: 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone count can
|
||||
// equal) + 4-byte LE payload version (== 2), then the v1 body PLUS, per zone record
|
||||
// after rootOverride: 1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE
|
||||
// loop.start + loop.end (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint
|
||||
// (int64). The marker lets the reader detect record shape independent of the envelope.
|
||||
// * v3 (LEGACY — exists in Daniel's beta projects): marker + version (== 3), v2 body PLUS
|
||||
// a per-zone play-params tail (always present): 1 byte playMode (0 Gate/1 Trigger);
|
||||
// 8-byte LE adsr.holdFrames (int64, FRAMES at 44.1k nominal); 8-byte LE
|
||||
// trigger.lengthFraction (double); 8-byte LE trigger.fadeInFrames + fadeOutFrames
|
||||
// (int64); 1 byte pitchEngine (0 Varispeed/1 Preserve); 1 byte pitchEnv.enabled; 8-byte
|
||||
// LE pitchEnv.attackFrames + decayFrames (int64, FRAMES 44.1k nom); 8-byte LE
|
||||
// peakSemitones (double). A v1/v2 payload (no v3 tail) lifts each zone to the product
|
||||
// defaults (Gate + Preserve, no fades, pitch env disabled) — deliberate for
|
||||
// already-saved instruments. A truncated mid-v3-tail record keeps the zones that parsed.
|
||||
// LEGACY-READ CONVERSION: the v3 wall-clock frame counts (hold, pitchEnv A/D) were
|
||||
// always written as nominal frames at a baked-in rate; convert to seconds by dividing by
|
||||
// the PROJECT sample rate threaded into the v3 lift path at read time (a parameter, no
|
||||
// baked constant). Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R
|
||||
// absent in v3 -> tier-0 seconds defaults (0.003/0/1.0/0.060).
|
||||
// * v5 (CURRENT WRITE FORMAT): marker + version (== 5), v2 body PLUS, per zone record, the
|
||||
// full play params with WALL-CLOCK TIMES AS SECONDS (rate-free doubles): 1 byte
|
||||
// playMode; 8-byte LE adsr.holdSeconds; 8-byte LE trigger.lengthFraction; 8-byte LE
|
||||
// trigger.fadeInFrames + fadeOutFrames (int64, unchanged — source-timeline facts); 1
|
||||
// byte pitchEngine; 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackSeconds +
|
||||
// decaySeconds + peakSemitones; 8-byte LE adsr.attackSeconds + decaySeconds +
|
||||
// sustainLevel + releaseSeconds. v4 (a branch-only frames-tail) was never shipped and is
|
||||
// intentionally not read. Keymap builders resolve stored seconds to frames at the LIVE
|
||||
// sample rate; no rate is baked into storage or the program.
|
||||
// BACK-COMPAT: a v1 ENVELOPE blob (the original single-selection format: version tag 1 + id
|
||||
// bytes) lifts to a single full-keyboard zone playing that id (no override). A
|
||||
// truncated/unknown/empty blob deserializes to an EMPTY map.
|
||||
//
|
||||
// These two functions serialize the ZONES only. Since S10 the instrument's full component
|
||||
// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState
|
||||
// below, the v3 format the processor actually reads/writes. serializePerformance/
|
||||
// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift.
|
||||
// These two functions serialize the ZONES only; the instrument's full component state is
|
||||
// {single-capture selection id, zones} — see ComponentState / serializeComponentState below.
|
||||
|
||||
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||
|
||||
// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9).
|
||||
// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 —
|
||||
// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock
|
||||
// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides
|
||||
// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker +
|
||||
// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts)
|
||||
// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The
|
||||
// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice,
|
||||
// always tiny) can never collide with.
|
||||
// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the
|
||||
// full v5 play-params tail:
|
||||
// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET).
|
||||
// A v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone
|
||||
// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the
|
||||
// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
|
||||
// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
|
||||
// transfer curve appended to each zone record after the v6 keyTrack field:
|
||||
// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp
|
||||
// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2.
|
||||
// A v1–v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1
|
||||
// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change:
|
||||
// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A
|
||||
// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed.
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve
|
||||
// The zones-payload format version and its detection marker. serializePerformance and
|
||||
// serializeComponentState both emit the CURRENT payload version (v7: marker + version +
|
||||
// records with the loop/start tail, the full play-params tail in SECONDS, the v6 keyTrack
|
||||
// scalar, and the v7 velocity->amp curve) so overrides round-trip through EITHER envelope.
|
||||
// Readers accept v1 (no marker), v2 (marker + version 2, no play tail), and v3 (legacy play
|
||||
// tail, wall-clock frame counts) for back-compat, lifting missing fields to defaults. v4 was
|
||||
// never shipped and is not read. The marker is a high sentinel no legitimate zone count
|
||||
// (bounded by 128 MIDI zones, always tiny) can ever collide with.
|
||||
// * PAYLOAD v6: identical to v5, PLUS one field appended to each zone record after the
|
||||
// full v5 play-params tail: 8-byte LE keyTrack (double) — the per-zone key-tracking
|
||||
// scalar (1.0 = 100% ET). A v1-v5 payload (no keyTrack) lifts every zone to keyTrack =
|
||||
// 1.0, so already-saved instances are BIT-IDENTICAL — the default reproduces the prior
|
||||
// repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
|
||||
// * PAYLOAD v7 (CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
|
||||
// transfer curve appended after the v6 keyTrack field: 4-byte LE control-point count N,
|
||||
// then per point 8-byte LE velocity + 8-byte LE amp (doubles). The two endpoints
|
||||
// (velocity 0 and 127) are always included, so N >= 2. A v1-v6 payload (no
|
||||
// velocity-curve field) lifts every zone to VelocityCurve::flat() (Daniel-approved).
|
||||
// This is a DELIBERATE NON-back-compat behavior change: an already-saved zone's soft
|
||||
// hits play LOUDER than under the old linear velocity/127. A truncated mid-curve record
|
||||
// leaves the zone's flat default and keeps the zones that parsed.
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // + per-zone velocity->amp curve
|
||||
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
||||
|
||||
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are
|
||||
// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a
|
||||
// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap build
|
||||
// already receives, so the seconds domain is consistent across both paths. No constant is baked in.
|
||||
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts
|
||||
// convert to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a
|
||||
// parameter (frames / projectRate = seconds) — the same rate keymap build already receives,
|
||||
// so the seconds domain is consistent across both paths. No constant is baked in.
|
||||
|
||||
// The performance map serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
|
||||
@@ -139,152 +112,145 @@ std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate);
|
||||
|
||||
// --- Combined component state (VST3 setState/getState, v3 — S10) -------------
|
||||
// --- Combined component state (VST3 setState/getState, v3+) -------------
|
||||
//
|
||||
// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that
|
||||
// The single-capture SELECTION and the opt-in ZONES are distinct concepts that
|
||||
// BOTH persist: the default face is one picked capture (the selection id), and zones are a
|
||||
// demoted opt-in overlay (the performance map). The component state carries both so a saved
|
||||
// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an
|
||||
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
|
||||
// state), never auto-playing sample #1.
|
||||
// project restores an instance's pick AND its zones — and an instance with NO pick and NO
|
||||
// zones restores EMPTY (silence + the "pick a capture" empty state), never auto-playing
|
||||
// sample #1.
|
||||
//
|
||||
// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono,
|
||||
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a
|
||||
// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system
|
||||
// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono
|
||||
// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754
|
||||
// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte
|
||||
// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the
|
||||
// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the
|
||||
// instance-owned path + intrinsics + display name per referenced sample; wire shape at
|
||||
// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE
|
||||
// length + guid bytes; the minted per-instance identity the usage publisher keys its
|
||||
// "rsusage_<guid>" ext-state record under, see sample_usage.h), then a 4-byte LE
|
||||
// selection-id length + id bytes, then the CURRENT zones payload (identical to
|
||||
// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block).
|
||||
// The instance guid is the ONLY envelope-v11 addition over v10, as the refs table was the
|
||||
// only v10 addition over v9 — the envelope grows a field,
|
||||
// the zones payload is untouched (a PARALLEL track owns zone-record extension under its own
|
||||
// versioning; the two version numbers are independent axes — do NOT bump the zones-payload
|
||||
// version for an envelope field). An out-of-range voice byte or a non-finite/out-of-range
|
||||
// master-gain double (a corrupt blob) falls back to the field's default rather than silencing
|
||||
// the instance (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to
|
||||
// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity =
|
||||
// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity
|
||||
// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the
|
||||
// un-touched default, so the GA auto-default may follow the loaded capture; a user who HAD
|
||||
// deliberately chosen a mode re-toggles once and the choice persists explicit from then on —
|
||||
// and an EMPTY sample-refs table, which the shell lifts once via the bridge-resolve path —
|
||||
// and an EMPTY instance guid (pre-pS-usage), which the shell re-mints on first publish):
|
||||
// Format (envelope v11): 4-byte LE version tag (== 11); 1-byte channel-mode field (0
|
||||
// mono/1 stereo); 8-byte LE last-consumed-assignment generation; 1-byte preview-trigger
|
||||
// velocity (MIDI 1..127); three voice-system bytes (1-byte voice count 1..32, 1-byte voice
|
||||
// mode 0 Poly/1 Mono, 1-byte mono trigger 0 Retrigger/1 Legato); 8-byte LE master-gain
|
||||
// LINEAR value (double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB);
|
||||
// 1-byte channel-mode-EXPLICIT flag (0 implicit/auto-default, 1 = user deliberately
|
||||
// toggled — see ComponentState::channelModeExplicit); the SAMPLE-REFS table (instance-owned
|
||||
// path + intrinsics + display name per referenced sample; wire shape at
|
||||
// kSelectionZonesRefsV10Version below); the INSTANCE GUID (4-byte LE length + guid bytes —
|
||||
// the minted per-instance identity the usage publisher keys its "rsusage_<guid>" ext-state
|
||||
// record under, see sample_usage.h); 4-byte LE selection-id length + id bytes; then the
|
||||
// CURRENT zones payload (identical to serializePerformance's body — its own self-describing
|
||||
// version). The instance guid is the only v11 addition over v10, as the refs table was the
|
||||
// only v10 addition over v9 — the envelope grows a field, the zones payload is untouched (a
|
||||
// PARALLEL track owns zone-record extension under its own versioning — the two version
|
||||
// numbers are independent axes; do NOT bump the zones-payload version for an envelope
|
||||
// field). An out-of-range voice byte or a non-finite/out-of-range master-gain double (a
|
||||
// corrupt blob) falls back to the field's default rather than silencing the instance.
|
||||
// BACK-COMPAT on read (every older blob lifts to channelMode = MONO,
|
||||
// lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, voice
|
||||
// defaults {16 voices, Poly, Retrigger}, unity master gain, channelModeExplicit = FALSE — a
|
||||
// pre-v9 mode byte is treated as the untouched default so the auto-default may follow the
|
||||
// loaded capture, and a user who HAD deliberately chosen a mode re-toggles once and the
|
||||
// choice persists explicit from then on — and an EMPTY sample-refs table, which the shell
|
||||
// lifts once via the bridge-resolve path — and an EMPTY instance guid, which the shell
|
||||
// re-mints on first publish):
|
||||
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct.
|
||||
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish): pre-pS-usage.
|
||||
// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift).
|
||||
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode).
|
||||
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain).
|
||||
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults).
|
||||
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity).
|
||||
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker).
|
||||
// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode.
|
||||
// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection.
|
||||
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift.
|
||||
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state).
|
||||
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish).
|
||||
// * v9 blob -> the v10 fields minus sampleRefs (empty table — bridge-resolve lift).
|
||||
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: implicit mode.
|
||||
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: unity master gain.
|
||||
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: voice defaults.
|
||||
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: no velocity byte.
|
||||
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: no marker.
|
||||
// * v3 blob -> {mono, 0, mid, selectionId, zones}: no channel mode.
|
||||
// * v2 blob -> {mono, 0, mid, "", zones}: zones but no separate selection.
|
||||
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: single-selection lift.
|
||||
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the silent empty state).
|
||||
//
|
||||
// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is
|
||||
// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user
|
||||
// already got and then manually changed away from: on re-open the instance re-reads the pending
|
||||
// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see
|
||||
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign
|
||||
// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the
|
||||
// bank — the extension owns the assign_request key; the instrument only tracks what it consumed.
|
||||
// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no
|
||||
// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default.
|
||||
// WHY THE MARKER PERSISTS. The last-consumed assignment generation stops a re-opened
|
||||
// instance re-applying a stale assign_request the user already got and then manually
|
||||
// changed away from: on re-open the instance re-reads the pending request, and only a
|
||||
// generation STRICTLY GREATER than this stored marker re-applies (see
|
||||
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first
|
||||
// assign (generation >= 1) still applies. It is the instrument's own state, never written
|
||||
// to the bank — the extension owns the assign_request key; the instrument only tracks what
|
||||
// it consumed. The preview-trigger velocity default is a mid MIDI velocity: an older blob
|
||||
// with no velocity byte lifts to this, audible-but-not-hot.
|
||||
inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
|
||||
|
||||
struct ComponentState {
|
||||
std::string selectionId; // the single-capture pick; "" = no pick
|
||||
PerformanceMap map; // the opt-in zones; empty = no zones
|
||||
ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E)
|
||||
// GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle).
|
||||
// While false (implicit), the shell auto-defaults the mode from the loaded capture's
|
||||
// channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the
|
||||
// user's choice is never fought. Pre-v9 blobs lift to false (implicit).
|
||||
ChannelMode channelMode = ChannelMode::Mono; // decode mode; default mono
|
||||
// Whether channelMode was DELIBERATELY set by the user (the editor toggle). While
|
||||
// false (implicit), the shell auto-defaults the mode from the loaded capture's channel
|
||||
// count on reload (stereo capture -> Stereo, mono -> Mono); once true, the user's
|
||||
// choice is never fought. Pre-v9 blobs lift to false (implicit).
|
||||
bool channelModeExplicit = false;
|
||||
std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed
|
||||
// S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling
|
||||
// of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's
|
||||
// chosen strike velocity across saves. Defaults to kPreviewVelocityDefault.
|
||||
std::int64_t lastConsumedAssignGeneration = 0; // last assign_request generation consumed
|
||||
// Preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling of
|
||||
// channelMode, NOT per-zone), persisted so the Sample-view preview button retains the
|
||||
// user's chosen strike velocity across saves.
|
||||
std::uint8_t previewVelocity = kPreviewVelocityDefault;
|
||||
// Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
|
||||
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an
|
||||
// older blob lifting to these plays byte-identically.
|
||||
// Voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
|
||||
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-voice-system behavior
|
||||
// exactly, so an older blob lifting to these plays byte-identically.
|
||||
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
|
||||
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
|
||||
// FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity;
|
||||
// up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output
|
||||
// trim applied by process() AFTER the voice sum (engine + drain + preview) — never per
|
||||
// voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically,
|
||||
// so an older blob lifting to 1.0 plays exactly as it did.
|
||||
// Post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; up to
|
||||
// ~15.849 = +24 dB — master_gain owns the dB taper). PER-INSTANCE output trim applied
|
||||
// by process() AFTER the voice sum — never per voice, never a keymap fact. Default
|
||||
// unity reproduces pre-master-gain output byte-identically.
|
||||
double masterGainLinear = 1.0;
|
||||
// pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics
|
||||
// for every bank sample this instance plays (see the SampleRefs block above). setState
|
||||
// decodes straight from these; NO bridge/extension read is required for playback. A
|
||||
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
|
||||
// path once (then re-saves self-contained).
|
||||
// Self-contained playback: the instance-OWNED sample refs — path + intrinsics for every
|
||||
// bank sample this instance plays (see the SampleRefs block above). setState decodes
|
||||
// straight from these; NO bridge/extension read is required for playback. A pre-v10
|
||||
// blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve path
|
||||
// once (then re-saves self-contained).
|
||||
SampleRefs sampleRefs;
|
||||
// pS-usage (v11): the minted per-instance identity the usage publisher keys its
|
||||
// "rsusage_<guid>" ext-state record under (see sample_usage.h — the prune-protection
|
||||
// seam). Persisted so the key is stable across sessions (records do not proliferate
|
||||
// per reopen). Empty = never published (a fresh or pre-v11 instance); the processor
|
||||
// mints one on first publish, and RE-mints when the publish plan detects this state
|
||||
// was cloned onto another track (FX copy / track duplication — planUsagePublish).
|
||||
// The minted per-instance identity the usage publisher keys its "rsusage_<guid>"
|
||||
// ext-state record under (see sample_usage.h — the prune-protection seam). Persisted so
|
||||
// the key is stable across sessions. Empty = never published (a fresh or pre-v11
|
||||
// instance); the processor mints one on first publish, and RE-mints when the publish
|
||||
// plan detects this state was cloned onto another track (FX copy / track duplication).
|
||||
std::string instanceGuid;
|
||||
};
|
||||
|
||||
inline constexpr std::uint32_t kComponentStateVersion = 11;
|
||||
|
||||
// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed
|
||||
// after the refs table). Mirrors the v10/v9/… series so the version branches in
|
||||
// deserializeComponentState stay self-describing.
|
||||
// v10 + the minted instance guid, length-prefixed after the refs table. Mirrors the
|
||||
// v10/v9/… series so the version branches in deserializeComponentState stay self-describing.
|
||||
inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
|
||||
|
||||
// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table).
|
||||
// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection
|
||||
// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE
|
||||
// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop,
|
||||
// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of
|
||||
// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length +
|
||||
// displayName bytes (display-only; the editor label's extension-absent fallback).
|
||||
// v9 + the instance-owned sample-refs table. Wire shape of the refs block (inserted after
|
||||
// the v9 explicit flag, before the selection id): 4-byte LE entry count, then per entry:
|
||||
// 4-byte LE id length + id bytes, 4-byte LE path length + path bytes, 4-byte LE rootNote
|
||||
// (two's-complement), 1 byte loop.hasLoop, 8-byte LE loop.start + loop.end (int64, written
|
||||
// regardless of hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName
|
||||
// length + bytes (display-only; the editor label's extension-absent fallback).
|
||||
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
|
||||
|
||||
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode
|
||||
// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode.
|
||||
// Everything through the master gain, no channel-mode explicit flag. Retained so
|
||||
// deserializeComponentState can lift a v8 blob to implicit mode.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8;
|
||||
|
||||
// The GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the
|
||||
// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing.
|
||||
// v8 + the channel-mode-EXPLICIT flag. Mirrors the v8/v7/v6/… series so the v9-branch check
|
||||
// in deserializeComponentState is self-describing.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9;
|
||||
|
||||
// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker +
|
||||
// preview velocity + voice system, no master gain). Retained so deserializeComponentState can
|
||||
// lift a v7 blob to unity master gain.
|
||||
// Selection + zones + channel mode + consumed marker + preview velocity + voice system, no
|
||||
// master gain. Retained so deserializeComponentState can lift a v7 blob to unity master gain.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7;
|
||||
|
||||
// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker +
|
||||
// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a
|
||||
// v6 blob to the voice defaults {16, Poly, Retrigger}.
|
||||
// Selection + zones + channel mode + consumed marker + preview velocity, no voice-system
|
||||
// fields. Retained so deserializeComponentState can lift a v6 blob to the voice defaults
|
||||
// {16, Poly, Retrigger}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6;
|
||||
|
||||
// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no
|
||||
// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity.
|
||||
// Selection + zones + channel mode + consumed marker, no preview velocity. Retained so
|
||||
// deserializeComponentState can lift a v5 blob to a mid velocity.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5;
|
||||
|
||||
// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed
|
||||
// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
|
||||
// Selection + zones + channel mode, no consumed marker. Retained so
|
||||
// deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
|
||||
|
||||
// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named
|
||||
// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}.
|
||||
// Selection + zones, no channel mode. Retained so deserializeComponentState can lift a v3
|
||||
// blob to {mono, selection, zones}.
|
||||
inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
|
||||
|
||||
// The full instance state serialized to bytes for IBStream (getState).
|
||||
@@ -300,18 +266,16 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||
|
||||
// --- Instance state (VST3 setState/getState) --------------------------------
|
||||
//
|
||||
// The instrument's OWN state is which bank sample it plays (D-B: the selection is a
|
||||
// performance choice, held by the instrument, never written back to the bank). It is a
|
||||
// single string id. serialize/deserialize keep the on-the-wire form explicit and
|
||||
// versioned so a future Tier can extend it without breaking already-saved instances.
|
||||
// The instrument's OWN state is which bank sample it plays (a performance choice, held by
|
||||
// the instrument, never written back to the bank) — a single string id. serialize/
|
||||
// deserialize keep the on-the-wire form explicit and versioned so it can be extended
|
||||
// without breaking already-saved instances.
|
||||
//
|
||||
// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No
|
||||
// length prefix is needed — the id runs to the end of the stream (the host tells us the
|
||||
// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob
|
||||
// by returning "" (no selection — under the S10 policy reversal an empty selection is
|
||||
// SILENCE + the "pick a capture" empty state, not the bank's first sample), never
|
||||
// throwing across the host boundary. Retained for the v1→v3 back-compat lift in
|
||||
// deserializeComponentState; the processor's live state is the v3 ComponentState above.
|
||||
// Format (v1): 4-byte LE version tag (== 1) followed by the id bytes — no length prefix
|
||||
// needed, the id runs to end of stream. deserializeSelection tolerates a truncated/wrong-
|
||||
// version/empty blob by returning "" (no selection is SILENCE + the "pick a capture" empty
|
||||
// state, not the bank's first sample), never throwing across the host boundary. Retained
|
||||
// for the v1->v3 back-compat lift in deserializeComponentState.
|
||||
|
||||
inline constexpr std::uint32_t kSelectionStateVersion = 1;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// note_entry.cpp — see note_entry.h. PURE text->MIDI-note parse for the S12 numeric entry.
|
||||
// note_entry.cpp — see note_entry.h.
|
||||
|
||||
#include "core/instrument/map/note_entry.h"
|
||||
|
||||
@@ -40,8 +40,8 @@ int letterSemitone(char up) {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive). MIDI 0 == C-1, 60 == C4
|
||||
// (the DAW convention the editor's noteLabel uses). Returns nullopt if it is not a note name.
|
||||
// Parse a note name like "C4", "F#3", "Bb-1" (case-insensitive, DAW convention:
|
||||
// MIDI 0 == C-1, 60 == C4). Returns nullopt if it is not a note name.
|
||||
std::optional<int> parseNoteName(const std::string& s) {
|
||||
if (s.empty()) return std::nullopt;
|
||||
std::size_t i = 0;
|
||||
@@ -49,10 +49,8 @@ std::optional<int> parseNoteName(const std::string& s) {
|
||||
if (base < 0) return std::nullopt; // not a letter -> not a note name
|
||||
++i;
|
||||
int semitone = base;
|
||||
// Optional accidental(s): # / b (or 's'/'f' are NOT accepted — keep it to the two glyphs).
|
||||
// Optional accidental(s): # / b only (not 's'/'f').
|
||||
while (i < s.size() && (s[i] == '#' || s[i] == 'b' || s[i] == 'B')) {
|
||||
// A trailing 'b'/'B' could be a flat OR the start of nothing; here after a letter it is
|
||||
// an accidental. '#' raises, 'b'/'B' lowers.
|
||||
if (s[i] == '#') ++semitone;
|
||||
else --semitone;
|
||||
++i;
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
// note_entry.h — PURE parse + clamp for the S12 direct numeric entry of a zone's
|
||||
// low/high/root MIDI note. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The
|
||||
// mirror of the other pure editor helpers: the fiddly text->note parse lives here, unit-
|
||||
// tested outside the DAW, while the editor shell hosts the text field (a SWELL edit control
|
||||
// or a LICE text-entry idiom) and feeds the committed string here on Enter.
|
||||
// note_entry — parse + clamp for direct numeric/note-name entry of a zone's low/high/root
|
||||
// MIDI note (a drag on the keyboard strip can't hit a precise note reliably).
|
||||
//
|
||||
// WHY IT EXISTS (S12). Low/high/root are draggable on the keyboard strip, but a drag can't
|
||||
// hit a precise note reliably. This adds a typed field: the user clicks the field, types a
|
||||
// value, and presses Enter; the shell hands the raw string here to parse into a clamped MIDI
|
||||
// note [0,127] and commits via the same off-thread reload as every other edit.
|
||||
//
|
||||
// ACCEPTED FORMS (both, so a musician OR a MIDI-number user is served):
|
||||
// * a plain decimal integer ("60", " 127 ", "+5") — the raw MIDI note number; and
|
||||
// * a note name ("C4", "f#3", "Bb-1") — parsed to its MIDI number under the DAW's C4==60
|
||||
// convention (MIDI 0 == C-1, matching REAPER + the editor's noteLabel).
|
||||
// A value out of [0,127] CLAMPS to the range (a typed 200 becomes 127) rather than
|
||||
// rejecting — the least-surprising behavior for a nudge field. Unparseable input returns
|
||||
// nullopt (the shell keeps the old value + may flash the field).
|
||||
// Accepts a plain decimal integer ("60", "+5") or a note name ("C4", "f#3", "Bb-1", DAW
|
||||
// convention: MIDI 0 == C-1, 60 == C4). Out-of-range CLAMPS to [0,127] rather than
|
||||
// rejecting; unparseable input returns nullopt (shell keeps the old value).
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -24,10 +12,7 @@
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// Parse a typed low/high/root field into a clamped MIDI note [0,127]. Accepts a decimal
|
||||
// integer OR a note name (see the header notes). Leading/trailing ASCII whitespace is
|
||||
// ignored. An in-range parse returns the note; an out-of-range numeric or note value clamps
|
||||
// into [0,127]; empty or unparseable input returns nullopt (no change). Pure — no host types.
|
||||
// Leading/trailing whitespace ignored. Empty or unparseable input returns nullopt.
|
||||
std::optional<int> parseNoteEntry(const std::string& text);
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// sample_map — pure implementation (the RESOLUTION half; the ComponentState codec
|
||||
// lives in component_state_io.cpp since Q-W2v). See sample_map.h. NO VST3 / REAPER /
|
||||
// SWELL / vendor includes; standard library + the pure bank_book / wav_codec / sampler_core.
|
||||
// sample_map — pure implementation (the resolution half; the ComponentState codec lives
|
||||
// in component_state_io.cpp). See sample_map.h.
|
||||
|
||||
#include "core/instrument/map/sample_map.h"
|
||||
|
||||
@@ -12,9 +11,8 @@ namespace reasampler::instrument::map {
|
||||
|
||||
namespace {
|
||||
|
||||
// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank
|
||||
// stores loop points as an optional LoopPoints (both-or-neither); the core wants a
|
||||
// SampleLoop with an explicit hasLoop. Absent -> no loop.
|
||||
// The bank stores loop points as an optional LoopPoints (both-or-neither); the core wants
|
||||
// a SampleLoop with an explicit hasLoop. Absent -> no loop.
|
||||
SampleLoop loopFromSample(const Sample& s) {
|
||||
SampleLoop out;
|
||||
if (s.loop) {
|
||||
@@ -25,41 +23,33 @@ SampleLoop loopFromSample(const Sample& s) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C
|
||||
// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on
|
||||
// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4).
|
||||
// rootNote defaults to middle C (60) when the bank left the intrinsic empty — an
|
||||
// un-rooted sample plays unity at C4 rather than failing to play.
|
||||
SelectedSample distill(const Sample& s) {
|
||||
SelectedSample out;
|
||||
out.relativePath = s.relativePath;
|
||||
out.rootNote = s.rootNote ? *s.rootNote : 60;
|
||||
out.loop = loopFromSample(s);
|
||||
out.channelCount = s.channelCount; // capture intrinsic; 0 = unknown (older entry)
|
||||
out.channelCount = s.channelCount; // 0 = unknown (older entry)
|
||||
return out;
|
||||
}
|
||||
|
||||
// The ONE override-beats-intrinsic fold shared by the bank-side resolvePerformance and the
|
||||
// refs-side resolvePerformanceFromRefs (pS): a zone's authored fields + the sample's
|
||||
// intrinsics (already distilled — rootNote carries the middle-C default) -> ResolvedZone.
|
||||
// Shared so the two resolution paths cannot drift.
|
||||
// The ONE override-beats-intrinsic fold shared by resolvePerformance and
|
||||
// resolvePerformanceFromRefs, so the two resolution paths cannot drift.
|
||||
ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) {
|
||||
ResolvedZone rz;
|
||||
rz.relativePath = ref.relativePath;
|
||||
rz.lowNote = z.lowNote;
|
||||
rz.highNote = z.highNote;
|
||||
// Effective root: override beats intrinsic (distill already defaulted an empty
|
||||
// intrinsic to middle C).
|
||||
rz.rootNote = z.rootOverride ? *z.rootOverride : ref.rootNote;
|
||||
// S-VIEW-6/S-VIEW-9: key tracking + the velocity->amp curve are instrument state —
|
||||
// carried straight through and applied at play time.
|
||||
// Key tracking + velocity curve are instrument state — carried straight through.
|
||||
rz.keyTrack = z.keyTrack;
|
||||
rz.velocityCurve = z.velocityCurve;
|
||||
// Effective loop / start (S11): the per-zone override wins over the intrinsic; absent
|
||||
// -> the intrinsic (loop) / frame 0 (start). The bank is never mutated (D-B).
|
||||
// Per-zone override wins over the intrinsic; absent -> intrinsic (loop) / frame 0
|
||||
// (start). The bank is never mutated.
|
||||
rz.loop = z.loopOverride ? *z.loopOverride : ref.loop;
|
||||
rz.startFrame = z.startPoint ? *z.startPoint : 0;
|
||||
// S15/S16 per-zone play params (SECONDS) carry through unchanged; buildZonedKeymap
|
||||
// resolves them to frames.
|
||||
rz.play = z.play;
|
||||
rz.play = z.play; // SECONDS; buildZonedKeymap resolves to frames
|
||||
return rz;
|
||||
}
|
||||
|
||||
@@ -67,22 +57,20 @@ ResolvedZone foldZone(const PerformanceZone& z, const SelectedSample& ref) {
|
||||
|
||||
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
||||
const std::string& sampleId) {
|
||||
// POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short-
|
||||
// circuit before parsing — no stored id resolves to nothing to play by design.
|
||||
// An empty selection is SILENCE, not the first sample — by design.
|
||||
if (sampleId.empty()) return std::nullopt;
|
||||
if (banksJson.empty()) return std::nullopt;
|
||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||
if (!book) return std::nullopt; // malformed -> nothing to play (never throw)
|
||||
|
||||
// Search every bank (pool first, then named — banks() is ordinal order) for the
|
||||
// stored id. A sample lives in exactly one bank, so first hit wins.
|
||||
// Search every bank (ordinal order) for the stored id; a sample lives in exactly
|
||||
// one bank, so first hit wins.
|
||||
for (const Bank& b : book->banks()) {
|
||||
if (const Sample* s = b.index.query(sampleId)) {
|
||||
return distill(*s);
|
||||
}
|
||||
}
|
||||
// A stale stored id (no longer resolves) is SILENCE, not a substituted first sample:
|
||||
// the editor reflects the missing pick with its empty state rather than masking it.
|
||||
// A stale stored id is SILENCE too — the editor's empty state, not a substitution.
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
@@ -92,7 +80,7 @@ ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplici
|
||||
return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||
}
|
||||
|
||||
// --- Instance-owned sample references (pS self-contained playback) -------------
|
||||
// --- Instance-owned sample references (self-contained playback) -------------
|
||||
|
||||
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId) {
|
||||
if (sampleId.empty()) return nullptr;
|
||||
@@ -133,7 +121,7 @@ void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
|
||||
for (SampleRefEntry& e : refs) {
|
||||
if (e.sampleId == id) {
|
||||
e.ref = distilled;
|
||||
e.displayName = found->displayName; // rename sync rides the same refresh
|
||||
e.displayName = found->displayName; // rename sync
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
@@ -233,22 +221,18 @@ DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
|
||||
out.sampleRate = sampleRate;
|
||||
if (mode == ChannelMode::Mono) {
|
||||
// MONO mode: the existing downmix policy (average all source channels), one channel out.
|
||||
out.monoFrames = downmixToMono(interleaved, sourceChannels);
|
||||
return out; // framesR stays empty
|
||||
}
|
||||
// STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0
|
||||
// duplicated when the source is mono (dual-mono, centered). extractChannel clamps the
|
||||
// out-of-range channel request to the last channel, so a mono source yields L == R.
|
||||
// extractChannel clamps out-of-range, so a mono source yields L == R (dual-mono).
|
||||
out.monoFrames = extractChannel(interleaved, sourceChannels, 0);
|
||||
out.framesR = extractChannel(interleaved, sourceChannels, 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
||||
// seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R,
|
||||
// pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry
|
||||
// through untouched — they are already source frames / fractions. Non-time fields pass as-is.
|
||||
// seconds -> frames at the LIVE rate; source-timeline quantities (trigger %-length +
|
||||
// fades) carry through untouched, already frames/fractions.
|
||||
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
|
||||
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
||||
const auto secToFrames = [sr](double sec) {
|
||||
@@ -304,8 +288,7 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
if (!book) return out; // malformed -> nothing (never throw)
|
||||
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
// Look the id up across every bank (pool + named) — a sample lives in exactly
|
||||
// one bank, so first hit wins.
|
||||
// A sample lives in exactly one bank, so first hit wins.
|
||||
const Sample* found = nullptr;
|
||||
for (const Bank& b : book->banks()) {
|
||||
if (const Sample* s = b.index.query(z.sampleId)) {
|
||||
@@ -314,12 +297,11 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
// STALE-ID POLICY: drop the zone cleanly, report the id (editor can prune).
|
||||
out.droppedSampleIds.push_back(z.sampleId);
|
||||
out.droppedSampleIds.push_back(z.sampleId); // stale: drop, report
|
||||
continue;
|
||||
}
|
||||
// Distill the bank Sample to the same intrinsics shape the refs table carries, then
|
||||
// run the SHARED fold — so the bank path and the refs path resolve identically.
|
||||
// Distill to the same intrinsics shape the refs table carries, then run the SHARED
|
||||
// fold — so the bank path and refs path resolve identically.
|
||||
out.zones.push_back(foldZone(z, distill(*found)));
|
||||
}
|
||||
return out;
|
||||
@@ -332,8 +314,7 @@ ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
|
||||
if (const SelectedSample* r = findRef(refs, z.sampleId)) {
|
||||
out.zones.push_back(foldZone(z, *r));
|
||||
} else {
|
||||
// No ref for this id (never copied, or a pre-v10 blob not yet lifted): drop the
|
||||
// zone cleanly + report — the same shape as the bank path's stale-id policy.
|
||||
// No ref for this id: drop + report, same shape as the bank path's stale-id policy.
|
||||
out.droppedSampleIds.push_back(z.sampleId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
#pragma once
|
||||
// sample_map — PURE mapping logic for the S4 Tier-0 instrument: turn the live
|
||||
// "reasampler" bank ext-state + a decoded WAV into the plain data the sampler core
|
||||
// plays, and (de)serialize the instance's selected-sample choice for VST3 component
|
||||
// state. NO VST3, NO REAPER, NO SWELL, NO vendor/ includes at the boundary — the
|
||||
// mirror of capture_paths / wav_codec / bridge_marshal splitting the fiddly, testable
|
||||
// arithmetic out of a host-facing shell.
|
||||
//
|
||||
// WHY IT EXISTS (S4 seams). The instrument reads the bank over the live-state seam
|
||||
// (the "banks" ext-state blob) and the audio over the file seam (the on-disk WAV).
|
||||
// Both of those raw inputs cross the bridge/file boundary in the shell; everything
|
||||
// after — parse the bank with the SHARED bank_model/bank_book JSON path (NOT a second
|
||||
// parser; the S1 spike's string-scan reader is retired), pick the selected sample,
|
||||
// downmix its decoded PCM to the core's mono contract, and build the Tier-0 chromatic
|
||||
// Keymap — is pure and unit-tested here.
|
||||
//
|
||||
// It links bank_book (the shared BankBook::deserialize) and wav_codec (the shared
|
||||
// 32-bit-float WAV parse — no third WAV reader) and sampler_core (the Keymap /
|
||||
// SampleData it produces). All three are pure; this stays pure.
|
||||
// sample_map — turns the live "reasampler" bank ext-state + a decoded WAV into the plain
|
||||
// data the sampler core plays, and (de)serializes the instance's zone/selection state.
|
||||
// The bank is read over the live-state seam, audio over the file seam; both raw inputs
|
||||
// cross the bridge/file boundary in the shell, everything after (bank parse via the shared
|
||||
// bank_book JSON path, sample pick, mono downmix, keymap build) is pure and unit-tested
|
||||
// here. Links bank_book, wav_codec, and sampler_core (all pure).
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
@@ -29,77 +17,56 @@
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// Cross-subsystem deps by their real namespace homes (Q-W2v: sample_map now lives in
|
||||
// instrument::map; the engine family stays in flat `reasampler` until its own wave).
|
||||
using audio::AudioSample;
|
||||
using instrument::engine::VelocityCurve;
|
||||
using instrument::engine::VelocityPoint;
|
||||
|
||||
// The bank sample this instance is bound to, distilled from the live "banks" blob:
|
||||
// the project-relative WAV path the file seam must resolve+decode, plus the S2 bank
|
||||
// intrinsics the core repitches / loops by. A pure value — no host, no PCM yet.
|
||||
// The bank sample this instance is bound to, distilled from the live "banks" blob: the
|
||||
// project-relative WAV path the file seam resolves+decodes, plus the bank intrinsics the
|
||||
// core repitches/loops by. A pure value — no host, no PCM yet.
|
||||
struct SelectedSample {
|
||||
std::string relativePath; // project-relative; the shell resolves it (M4 convention)
|
||||
int rootNote = 60; // S2 intrinsic; defaults to middle C when the bank left it empty
|
||||
SampleLoop loop; // S2 intrinsic; hasLoop=false when the bank left it empty
|
||||
int channelCount = 0; // bank intrinsic (capture channel count); 0 = unknown (older
|
||||
// bank entries) — the GA channel-mode auto-default skips it
|
||||
std::string relativePath; // project-relative; the shell resolves it
|
||||
int rootNote = 60; // defaults to middle C when the bank left it empty
|
||||
SampleLoop loop; // hasLoop=false when the bank left it empty
|
||||
int channelCount = 0; // capture channel count; 0 = unknown (older bank entries) —
|
||||
// the GA channel-mode auto-default skips it
|
||||
};
|
||||
|
||||
// Resolve the bound sample from the live bank blob. `banksJson` is the raw "banks"
|
||||
// ext-state value the bridge read (may be empty / malformed — an unsaved or pre-bank
|
||||
// project). `sampleId` is this instance's stored selection.
|
||||
// `banksJson` is the raw "banks" ext-state value the bridge read (may be empty/malformed —
|
||||
// an unsaved or pre-bank project); `sampleId` is this instance's stored selection.
|
||||
//
|
||||
// Precedence, all pure:
|
||||
// * empty / malformed banksJson -> nullopt (nothing to play)
|
||||
// * sampleId empty -> nullopt (NO selection -> silence)
|
||||
// * sampleId names a sample in ANY bank -> that sample (searched pool + named)
|
||||
// * sampleId set but not found (stale) -> nullopt (the sample was deleted/moved;
|
||||
// the editor returns to the empty state)
|
||||
//
|
||||
// POLICY REVERSAL (S10, 2026-07-26 — supersedes the S4 first-sample fallback). A fresh
|
||||
// instance with no stored selection resolves to nullopt (SILENCE), NOT the bank's first
|
||||
// sample: the metric is time-to-first-note via an explicit pick, and a mystery auto-play
|
||||
// of sample #1 was the anti-pattern. A stale stored id (no longer resolves) ALSO returns
|
||||
// nullopt rather than silently substituting a different sample — the editor reflects the
|
||||
// missing selection with its "pick a capture" empty state instead of masking it.
|
||||
// Precedence: empty/malformed banksJson -> nullopt. Empty sampleId -> nullopt (no selection
|
||||
// is SILENCE, not the bank's first sample — deliberate: the metric is time-to-first-note via
|
||||
// an explicit pick, and mystery auto-play of sample #1 was the anti-pattern). sampleId found
|
||||
// in any bank -> that sample. sampleId set but not found (stale) -> nullopt, same as no
|
||||
// selection — the editor shows its "pick a capture" empty state rather than masking it.
|
||||
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
||||
const std::string& sampleId);
|
||||
|
||||
// GA auto-default rule (pure, tested): given the capture's requested channel count, the
|
||||
// instance's current mode, and whether the user has explicitly toggled the mode, return
|
||||
// the mode to apply. Explicit choice is never overridden. An unknown channelCount (0)
|
||||
// leaves the current mode unchanged. Used by reloadInstrument in the single-capture path.
|
||||
// * isExplicit == true -> current (user's choice stands)
|
||||
// * channelCount == 0 -> current (unknown, skip)
|
||||
// * channelCount >= 2 -> Stereo
|
||||
// * channelCount == 1 -> Mono
|
||||
// Auto-default rule: given the capture's channel count, current mode, and whether the user
|
||||
// explicitly toggled it, return the mode to apply. Explicit choice is never overridden;
|
||||
// channelCount == 0 (unknown) leaves the current mode; >= 2 -> Stereo; == 1 -> Mono.
|
||||
ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit);
|
||||
|
||||
// --- Instance-owned sample references (pS self-contained playback) -------------
|
||||
// --- Instance-owned sample references (self-contained playback) -------------
|
||||
//
|
||||
// THE ARCHITECTURE CORRECTION: the instrument must never go silent because the extension's
|
||||
// ext-state has not parsed yet (or the extension is absent). So the instance persists, in
|
||||
// its OWN component state, a small table of everything it needs to PLAY each referenced
|
||||
// bank sample: the project-relative WAV path + the decode intrinsics (root note, loop,
|
||||
// channel count) — exactly a SelectedSample, keyed by the bank sample id. On load the
|
||||
// shell decodes straight from these refs; the bank blob is a BROWSER SOURCE that also
|
||||
// refreshes this table opportunistically when readable (recapture/root edits stay live),
|
||||
// never a runtime lifeline.
|
||||
// The instrument must never go silent just because the extension's ext-state hasn't parsed
|
||||
// yet (or the extension is absent). So the instance persists, in its OWN component state, a
|
||||
// table of everything needed to PLAY each referenced bank sample: path + decode intrinsics
|
||||
// (root, loop, channel count), keyed by bank sample id. The shell decodes straight from
|
||||
// these refs; the bank blob is a browser source that refreshes the table opportunistically
|
||||
// when readable, never a runtime lifeline.
|
||||
//
|
||||
// POLICY (follows from ownership): a sample deleted from the BANK no longer silences an
|
||||
// instance that carries its ref — the instance keeps playing while the FILE exists (normal
|
||||
// sampler behavior; prune deleting the file yields the defined no-play). This deliberately
|
||||
// supersedes the S10 stale-id-silence rule, which was an artifact of bank-side resolution.
|
||||
struct PerformanceMap; // defined below (Tier 1); referencedSampleIds spans both tiers
|
||||
// Consequence: a sample deleted from the bank no longer silences an instance that carries
|
||||
// its ref — it keeps playing while the file exists (normal sampler behavior; prune deleting
|
||||
// the file yields the defined no-play).
|
||||
struct PerformanceMap; // defined below; referencedSampleIds spans both selection + zones
|
||||
|
||||
struct SampleRefEntry {
|
||||
std::string sampleId; // the bank sample id this ref was copied from (the seam key)
|
||||
SelectedSample ref; // path + intrinsics, sufficient to decode + play without a bank
|
||||
// The sample's bank display name at copy time — DISPLAY ONLY (the editor's label falls
|
||||
// back to it when the bank snapshot is unavailable, mirroring the waveform/loop ref
|
||||
// fallback); never consulted by resolution. Empty for a table written before the field
|
||||
// existed in-session (it back-fills on the next bank refresh).
|
||||
// Bank display name at copy time — DISPLAY ONLY (editor label fallback when the bank
|
||||
// snapshot is unavailable); never consulted by resolution.
|
||||
std::string displayName;
|
||||
};
|
||||
using SampleRefs = std::vector<SampleRefEntry>;
|
||||
@@ -112,43 +79,32 @@ const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleI
|
||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
|
||||
const PerformanceMap& map);
|
||||
|
||||
// Upsert a ref for each id in `ids` that resolves in the live bank blob (the same
|
||||
// distillation selectSample performs), copying the bank display name alongside the decode
|
||||
// intrinsics. A miss leaves any existing entry untouched — the instance owns its copy; a
|
||||
// bank deletion never strips a ref. Empty/malformed blob -> no-op.
|
||||
// Upsert a ref for each id in `ids` that resolves in the live bank blob, copying the display
|
||||
// name alongside the decode intrinsics. A miss leaves any existing entry untouched — the
|
||||
// instance owns its copy; a bank deletion never strips a ref. Empty/malformed blob -> no-op.
|
||||
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
|
||||
const std::vector<std::string>& ids);
|
||||
|
||||
// The pre-v10 LEGACY-LIFT terminating decision (pure, so the no-churn rule is provable
|
||||
// without a host): can a refs lift MAKE PROGRESS against this bank blob for the ids the
|
||||
// instance references?
|
||||
// * Retry — the blob is absent/empty/unparseable: not readable YET, keep retrying (the
|
||||
// project's ext-state may simply not have parsed).
|
||||
// * Lift — the blob parses and at least one id resolves: a lift copies a ref in (the
|
||||
// refs table then goes non-empty and the lift never re-fires).
|
||||
// * Stale — the blob parses and NO id resolves (an empty `ids` included): the ids are
|
||||
// PROVABLY stale — the bank is readable and does not know them — so there is nothing
|
||||
// to lift, ever. The shell latches this and stops retrying (no per-tick churn).
|
||||
// Legacy-lift terminating decision: can a refs lift make progress against this bank blob
|
||||
// for the ids the instance references?
|
||||
// * Retry — blob absent/empty/unparseable: not readable yet, keep retrying.
|
||||
// * Lift — blob parses and at least one id resolves: copy a ref in (never re-fires once
|
||||
// the refs table is non-empty).
|
||||
// * Stale — blob parses and no id resolves: provably stale, nothing to lift, ever — the
|
||||
// shell latches this and stops retrying (no per-tick churn).
|
||||
enum class LegacyLiftDecision { Retry, Lift, Stale };
|
||||
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
|
||||
const std::vector<std::string>& ids);
|
||||
|
||||
// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table tracks
|
||||
// exactly what the instance currently plays, so it cannot grow with browsing history).
|
||||
// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table cannot
|
||||
// grow with browsing history).
|
||||
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids);
|
||||
|
||||
// One entry in the capture browser's card list: the stable id + display name plus the S2
|
||||
// intrinsics + bank the browser draws as a card (peak thumbnail + name + root/key badge,
|
||||
// filterable by bank). Peaks are NOT here — they are computed shell-side from the decoded
|
||||
// PCM (the `Sample` metadata carries no envelope; see reasampler_editor's thumbnail cache,
|
||||
// the mirror of bank_panel::thumbnailFor). This carries only what the bank blob already
|
||||
// holds: the metadata the card badge + bank filter need. Pure projection over the shared
|
||||
// parse — the UI never parses JSON itself.
|
||||
//
|
||||
// - rootNote: the S2 rootNote intrinsic when the bank set it (nullopt otherwise — the
|
||||
// badge shows "root: —" / no root, never a guessed value).
|
||||
// - key: the optional human musical key label ("F#m"), when the bank set it.
|
||||
// - bankId: the id of the bank this sample lives in (the bank filter matches on it).
|
||||
// One entry in the capture browser's card list: stable id + display name + intrinsics +
|
||||
// bank, for a card (peak thumbnail + name + root/key badge, filterable by bank). Peaks are
|
||||
// NOT here — computed shell-side from the decoded PCM (reasampler_editor's thumbnail
|
||||
// cache). rootNote is nullopt when the bank left it empty (badge shows no root, never a
|
||||
// guessed value). Pure projection over the shared parse — the UI never parses JSON itself.
|
||||
struct SampleChoice {
|
||||
std::string id;
|
||||
std::string displayName;
|
||||
@@ -167,35 +123,27 @@ struct BankChoice {
|
||||
};
|
||||
std::vector<BankChoice> listBanks(const std::string& banksJson);
|
||||
|
||||
// Downmix interleaved float frames (the shape wav_codec's extractFloatFrames yields:
|
||||
// [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per
|
||||
// frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0,
|
||||
// documented + surfaced): the S3 core is mono-per-sample by design; bank WAVs preserve
|
||||
// their source channel count, so a stereo (or N-channel) capture is folded to a single
|
||||
// mono stream here by an equal-weight average. Averaging (not "take L", not summing) is
|
||||
// the least-surprising, no-clip default — a centered mono source stays unity, and a
|
||||
// hard-panned source is attenuated rather than silenced or doubled. Empty / zero-stride
|
||||
// in -> empty out. Pure.
|
||||
// Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to the core's MONO contract
|
||||
// by AVERAGING channels per frame (`channelCount` is the interleave stride, >= 1) — not
|
||||
// "take L", not summing: a centered mono source stays unity, a hard-panned source is
|
||||
// attenuated rather than silenced or doubled. Empty/zero-stride in -> empty out. Pure.
|
||||
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount);
|
||||
|
||||
// Deinterleave one channel (`which`, 0-based) out of interleaved frames. `channelCount` is
|
||||
// the interleave stride (>= 1); `which` is clamped to a valid channel (a request past the
|
||||
// source's last channel reads the last channel, so a mono source asked for channel 1 yields
|
||||
// channel 0 again — the dual-mono building block). Empty / zero-stride in -> empty out. Pure.
|
||||
// Deinterleave one channel (`which`, 0-based). `which` clamps to a valid channel (a request
|
||||
// past the last channel reads the last channel, so a mono source asked for channel 1 yields
|
||||
// channel 0 — the dual-mono building block). Empty/zero-stride in -> empty out. Pure.
|
||||
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount, int which);
|
||||
|
||||
// --- Stored (wall-clock SECONDS) per-zone play params -------------------------
|
||||
//
|
||||
// DOMAIN SPLIT (S12 remediation — Daniel's ruling: no hardcoded sample rate in the program).
|
||||
// The instrument stores and edits WALL-CLOCK performance times as SECONDS, rate-free; the
|
||||
// engine (sampler_core's ZonePlayParams, on SampleData) receives FRAMES resolved from the
|
||||
// LIVE sample rate at keymap build. AHDSR (A/H/D/S/R) and the AD pitch envelope (attack/decay)
|
||||
// are wall-clock — the voice advances them once per OUTPUT frame — so they live here in seconds.
|
||||
// Quantities anchored to the source file's timeline (start point, loop points, Trigger %-length
|
||||
// and its fades — the fades anchor to the source-frame read offset, PLAN.md §S15) stay in source
|
||||
// frames / fractions and are carried through unchanged (TriggerParams is reused verbatim).
|
||||
// Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
|
||||
// instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
|
||||
// SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
|
||||
// keymap build. Quantities anchored to the source file's timeline (start point, loop
|
||||
// points, Trigger %-length + fades) stay in source frames/fractions, carried through
|
||||
// unchanged (TriggerParams reused verbatim).
|
||||
//
|
||||
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
|
||||
struct AdsrSeconds {
|
||||
@@ -214,61 +162,51 @@ struct PitchEnvSeconds {
|
||||
double peakSemitones = 0.0; // signed depth at the peak
|
||||
};
|
||||
|
||||
// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities in
|
||||
// frames/fractions (TriggerParams). This is the instrument-owned (D-B), serialized, editor-facing
|
||||
// representation — distinct from sampler_core's engine-facing ZonePlayParams (frames). The keymap
|
||||
// builders resolve this to a frame-domain ZonePlayParams against the live sample rate.
|
||||
// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities
|
||||
// in frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing —
|
||||
// distinct from sampler_core's engine-facing ZonePlayParams (frames).
|
||||
struct ZonePlaySeconds {
|
||||
PlayMode playMode = PlayMode::Gate;
|
||||
AdsrSeconds adsr; // Gate: AHDSR (seconds)
|
||||
TriggerParams trigger; // Trigger: %-length + fades (source frames)
|
||||
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve (S16-F1)
|
||||
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
|
||||
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
|
||||
};
|
||||
|
||||
// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live
|
||||
// sample rate (frames = round(seconds * rate)). Source-timeline fields (trigger, engine, mode,
|
||||
// peak, enabled) carry through unchanged. `sampleRate` must be > 0 (the caller guards this).
|
||||
// sample rate (frames = round(seconds * rate)). Source-timeline fields carry through
|
||||
// unchanged. `sampleRate` must be > 0 (the caller guards this).
|
||||
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate);
|
||||
|
||||
// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole
|
||||
// keyboard, repitched from `rootNote`, looped per `loop`. The single-sample degenerate case
|
||||
// (Keymap::singleSampleChromatic) with the S2 intrinsics threaded in. `frames` is channel 0
|
||||
// (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono sample (the default),
|
||||
// which yields a mono SampleData byte-identical to the pre-S7 build. A `framesR` whose length
|
||||
// mismatches `frames` is dropped (SampleData::channelCount() falls back to mono), so a bad
|
||||
// pair never half-plays. `sampleRate` is the WAV's rate.
|
||||
// `play` carries the S15/S16 per-zone play params (SECONDS) for the single-capture path; it
|
||||
// defaults to the PRODUCT defaults (Gate + tier-0 AHDSR seconds + Preserve engine, S16-F1) so a
|
||||
// picked single capture plays under the same default engine as a zone would. This function
|
||||
// resolves the wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
|
||||
// keyboard, repitched from `rootNote`, looped per `loop` (Keymap::singleSampleChromatic).
|
||||
// `frames` is channel 0 (mono, or L); `framesR` is channel 1 (R) — pass EMPTY for a mono
|
||||
// sample. A `framesR` whose length mismatches `frames` is dropped (falls back to mono), so a
|
||||
// bad pair never half-plays. `sampleRate` is the WAV's rate. `play` carries the per-zone play
|
||||
// params (SECONDS); defaults to the product defaults (Gate + tier-0 AHDSR + Preserve) so a
|
||||
// picked single capture plays under the same default engine as a zone would. Resolves the
|
||||
// wall-clock seconds to frames against `sampleRate` before stamping the SampleData.
|
||||
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
||||
int rootNote, const SampleLoop& loop,
|
||||
std::vector<AudioSample> framesR = {},
|
||||
const ZonePlaySeconds& play = ZonePlaySeconds{});
|
||||
|
||||
// --- Performance map (Tier 1, D-B: the instrument's OWN state) ---------------
|
||||
// --- Performance map (the instrument's OWN state) ---------------
|
||||
//
|
||||
// The performance map is the keymap the user authors IN the instrument: several bank
|
||||
// samples zoned across the keyboard, each with a key range and a root note. It is a
|
||||
// PERFORMANCE CHOICE (D-B), so it lives in the instrument (VST3 component state), never
|
||||
// written back to the bank. Root note per zone is SEEDED from the S2 bank intrinsic but
|
||||
// OVERRIDABLE here — the override lives on the zone, never on `Sample`.
|
||||
//
|
||||
// Pure value type: it names bank samples by id (the stable seam key) and holds no PCM.
|
||||
// The shell resolves each id's WAV over the file seam and decodes it; the pure zone-build
|
||||
// stitches the decoded frames + this map into a sampler_core Keymap.
|
||||
// samples zoned across the keyboard, each with a key range and a root note. A performance
|
||||
// choice, so it lives in the instrument (VST3 component state), never written back to the
|
||||
// bank. Pure value type: names bank samples by id (the stable seam key), holds no PCM — the
|
||||
// shell resolves+decodes each id's WAV, and the pure zone-build stitches the decoded frames
|
||||
// + this map into a sampler_core Keymap.
|
||||
|
||||
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range,
|
||||
// with an optional root-note override. rootOverride absent -> repitch from the bank
|
||||
// sample's own S2 rootNote intrinsic (or middle C when the bank left it empty).
|
||||
//
|
||||
// S11 loop/start overrides (instrument-owned, D-B — mirror of rootOverride): the sustain
|
||||
// loop and the initial read position are FACTS about the file (S2 bank intrinsics), but the
|
||||
// instrument may override them per zone WITHOUT writing back to the bank. loopOverride wins
|
||||
// over the bank's S2 loop intrinsic when set; startPoint sets the voice's initial read frame
|
||||
// (absent -> frame 0). Both are seeded from the bank intrinsic in the editor and stored here;
|
||||
// resolvePerformance folds override-beats-intrinsic into the effective ResolvedZone.
|
||||
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range.
|
||||
// rootOverride absent -> repitch from the bank sample's own rootNote intrinsic (or middle C
|
||||
// when empty). loopOverride/startPoint mirror rootOverride: the sustain loop and initial
|
||||
// read position are facts about the file, but the instrument may override them per zone
|
||||
// without writing back to the bank (loopOverride wins when set; startPoint sets the voice's
|
||||
// initial read frame, absent -> 0). resolvePerformance folds override-beats-intrinsic into
|
||||
// the effective ResolvedZone.
|
||||
struct PerformanceZone {
|
||||
std::string sampleId; // bank sample id this zone plays
|
||||
int lowNote = 0; // inclusive
|
||||
@@ -277,146 +215,125 @@ struct PerformanceZone {
|
||||
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
|
||||
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
|
||||
|
||||
// S-VIEW-6 key-tracking scalar (instrument-owned, D-B — mirror of rootOverride): how far
|
||||
// playback pitch tracks the keyboard around the root. 1.0 (100%) is standard 12-tone-ET (the
|
||||
// DEFAULT; a pre-S-VIEW-6 blob with no keyTrack tail lifts to exactly 1.0, so already-saved
|
||||
// instances are bit-identical); 0.0 = no tracking (every key plays root pitch); 2.0 = double.
|
||||
// NOT flag-gated — always present in the CURRENT payload (v6). Carried through to KeyZone by
|
||||
// resolvePerformance and applied in keyTrackedRatio inside BOTH repitch engines.
|
||||
// Key-tracking scalar: how far playback pitch tracks the keyboard around the root. 1.0
|
||||
// (100%, standard 12-tone-ET) is the default — a blob predating this field lifts to
|
||||
// exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every
|
||||
// key plays root pitch); 2.0 = double. Applied in keyTrackedRatio inside both repitch
|
||||
// engines.
|
||||
double keyTrack = 1.0;
|
||||
|
||||
// S-VIEW-9 velocity->amp transfer curve (instrument-owned, D-B — mirror of keyTrack): maps the
|
||||
// note-on MIDI velocity (0..127) to the voice's amp gain, replacing the fixed linear velocity/127.
|
||||
// A per-sound performance characteristic, so it varies PER ZONE. DEFAULT = flat y=1 (R10-F1
|
||||
// Option A, Daniel-approved): every velocity plays at unity. This is a DELIBERATE, non-back-compat
|
||||
// behavior change — a pre-S-VIEW-9 blob (no velocityCurve field) lifts to flat y=1, so an
|
||||
// already-saved zone's soft hits play LOUDER than under the old linear map. Intended; do NOT
|
||||
// preserve the linear response. Carried to KeyZone by resolvePerformance, eval'd in Voice::start.
|
||||
// Sequenced on the zones-payload axis AFTER keyTrack (payload v6 -> v7).
|
||||
// Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain,
|
||||
// replacing the old fixed linear velocity/127. Per-zone. Default = flat y=1 (Daniel-
|
||||
// approved): every velocity plays at unity. DELIBERATE non-back-compat behavior change —
|
||||
// a blob predating this field lifts to flat y=1, so an already-saved zone's soft hits
|
||||
// play LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd
|
||||
// in Voice::start.
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||
|
||||
// S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch
|
||||
// engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the
|
||||
// loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build
|
||||
// resolves them to frames at the live sample rate. Defaults to the PRODUCT defaults for a NEW
|
||||
// zone: Gate play mode, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades,
|
||||
// PRESERVE pitch engine (S16-F1), pitch env off. An older zone-payload blob (no S15/S16 tail)
|
||||
// lifts to exactly these defaults on read (see the PAYLOAD versioning).
|
||||
// Per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine +
|
||||
// AD pitch envelope). Instrument-owned, never a bank fact. Wall-clock times stored in
|
||||
// SECONDS (rate-free); keymap build resolves to frames at the live sample rate. Defaults
|
||||
// for a NEW zone: Gate, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no
|
||||
// fades, Preserve pitch engine, pitch env off. An older zone blob lacking this tail lifts
|
||||
// to exactly these defaults on read.
|
||||
ZonePlaySeconds play;
|
||||
};
|
||||
|
||||
// The instrument's performance map: an ordered list of zones. Order is authoritative for
|
||||
// overlap resolution (OVERLAP POLICY: first zone in order wins, mirroring the S3 core's
|
||||
// first-match Keymap::resolve — overlaps are neither rejected nor clamped, the earlier
|
||||
// zone simply takes the contested keys; documented, deterministic).
|
||||
// overlap resolution — first zone in order wins (mirrors the core's first-match
|
||||
// Keymap::resolve); overlaps are neither rejected nor clamped, deterministic by construction.
|
||||
struct PerformanceMap {
|
||||
std::vector<PerformanceZone> zones;
|
||||
|
||||
bool empty() const { return zones.empty(); }
|
||||
};
|
||||
|
||||
// Single-capture ("Sample face") zone-lifecycle reconcile — the zone-bleed fix (issue 3a).
|
||||
// Single-capture ("Sample face") zone-lifecycle reconcile — the zone-bleed fix.
|
||||
//
|
||||
// The Sample face materializes ONE full-range [0,127] zone for the loaded sample on first
|
||||
// control edit (ensureSampleZone). Loading a different sample used to change only the
|
||||
// selection id, leaving the previous sample's full-range zone in the map — and since zone
|
||||
// resolution is FIRST-MATCH in order, that stale zone shadowed every later one forever: the
|
||||
// engine kept playing the old sample while the editor drew the new one's zone (matched by
|
||||
// sampleId, order-blind). This function is called at every selection-change site so the zone
|
||||
// the editor draws is the zone the engine plays.
|
||||
// control edit. Loading a different sample used to change only the selection id, leaving
|
||||
// the previous sample's full-range zone in the map — and since zone resolution is
|
||||
// first-match in order, that stale zone shadowed every later one forever: the engine kept
|
||||
// playing the old sample while the editor drew the new one's zone. This function is called
|
||||
// at every selection-change site so the zone the editor draws is the zone the engine plays.
|
||||
//
|
||||
// Rules (pure, order-preserving where it matters):
|
||||
// * empty `selectedId` or empty map -> untouched, false.
|
||||
// * ANY zone with an authored key range (not the full [0,127]) -> the map is Zone-view
|
||||
// authorship; first-match order is load-bearing there — untouched, false. The Sample
|
||||
// face never creates a narrow zone, so a narrow zone proves deliberate multi-zone intent.
|
||||
// * else (every zone full-range — the map is purely Sample-face-shaped): keep only the
|
||||
// first zone bound to `selectedId` (the selection's own params are not reset); drop
|
||||
// the rest. A selection with no zone yet empties the map (the shell then plays the
|
||||
// selection via the Tier-0 fast path with product defaults).
|
||||
// Rules (order-preserving where it matters):
|
||||
// * empty `selectedId` or empty map -> untouched, false.
|
||||
// * ANY zone with an authored key range (not full [0,127]) -> Zone-view authorship,
|
||||
// first-match order is load-bearing there — untouched, false (the Sample face never
|
||||
// creates a narrow zone, so a narrow zone proves deliberate multi-zone intent).
|
||||
// * else (every zone full-range) -> keep only the first zone bound to `selectedId`
|
||||
// (params preserved); drop the rest. A selection with no zone yet empties the map.
|
||||
// Returns true iff the map changed (the caller republishes + reloads on true).
|
||||
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId);
|
||||
|
||||
// One resolved zone ready for the shell to decode + the pure build to stitch: the bank
|
||||
// sample's project-relative WAV path (file seam), the EFFECTIVE root note (override beats
|
||||
// bank intrinsic beats middle-C default), the loop intrinsic, and the key range. Distinct
|
||||
// from PerformanceZone (which names an id) — this is the id resolved against the live bank.
|
||||
// One resolved zone ready for the shell to decode + the pure build to stitch: project-
|
||||
// relative WAV path (file seam), effective root note (override beats bank intrinsic beats
|
||||
// middle-C default), loop intrinsic, key range. Distinct from PerformanceZone (which names
|
||||
// an id) — this is the id resolved against the live bank.
|
||||
struct ResolvedZone {
|
||||
std::string relativePath; // project-relative; the shell resolves + decodes it
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
||||
double keyTrack = 1.0; // S-VIEW-6 key-tracking scalar, carried from PerformanceZone (1.0 = 100% ET)
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat(); // S-VIEW-9 velocity->amp curve, carried from PerformanceZone
|
||||
SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11)
|
||||
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11)
|
||||
double keyTrack = 1.0; // carried from PerformanceZone (1.0 = 100% ET)
|
||||
VelocityCurve velocityCurve = VelocityCurve::flat(); // carried from PerformanceZone
|
||||
SampleLoop loop; // effective: loopOverride, else bank intrinsic
|
||||
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0
|
||||
ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
|
||||
};
|
||||
|
||||
// The result of resolving a performance map against the live bank blob. `zones` are the
|
||||
// zones whose sampleId still resolves to a bank sample, IN MAP ORDER (so overlap-order is
|
||||
// preserved). `droppedSampleIds` are the ids that no longer resolve (STALE-ID POLICY: a
|
||||
// zone naming a deleted/moved-out sample is DROPPED cleanly — not an error, not silence
|
||||
// for the whole map — and its id is reported here so the editor can flag/prune it).
|
||||
// `zones` are the zones whose sampleId still resolves, IN MAP ORDER (overlap-order
|
||||
// preserved). `droppedSampleIds`: a zone naming a deleted/moved-out sample is dropped
|
||||
// cleanly — not an error, not silence for the whole map — and reported here so the editor
|
||||
// can flag/prune it.
|
||||
struct ResolvedPerformance {
|
||||
std::vector<ResolvedZone> zones;
|
||||
std::vector<std::string> droppedSampleIds;
|
||||
};
|
||||
|
||||
// Resolve a performance map against the live "banks" ext-state blob. Pure: shared
|
||||
// bank_book parse, no host, no PCM. Each zone's sampleId is looked up across every bank
|
||||
// (pool + named); a hit yields a ResolvedZone with the effective root note (rootOverride,
|
||||
// else the sample's S2 rootNote, else 60) and the sample's loop intrinsic; a miss appends
|
||||
// the id to droppedSampleIds. Empty/malformed blob or empty map -> empty result.
|
||||
// Resolve a performance map against the live "banks" ext-state blob. Each zone's sampleId
|
||||
// is looked up across every bank; a hit yields a ResolvedZone with the effective root note
|
||||
// and loop intrinsic; a miss appends to droppedSampleIds. Empty/malformed blob or empty map
|
||||
// -> empty result.
|
||||
//
|
||||
// NOT the live load path since pS: reloadInstrument resolves via resolvePerformanceFromRefs
|
||||
// (the instance-owned refs). This bank-side resolver is retained as the TESTED REFERENCE
|
||||
// the refs path is verified against (testResolveFromRefsMatchesBankResolve) — both share
|
||||
// foldZone, so the drift test is what keeps the shared fold honest.
|
||||
// NOT the live load path — reloadInstrument resolves via resolvePerformanceFromRefs (the
|
||||
// instance-owned refs). Retained as the TESTED REFERENCE the refs path is verified against
|
||||
// (both share foldZone, so the drift test keeps the shared fold honest).
|
||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
const PerformanceMap& map);
|
||||
|
||||
// Resolve a performance map against the INSTANCE-OWNED refs table (pS self-contained
|
||||
// playback) — the bank-free mirror of resolvePerformance, sharing the same override-
|
||||
// beats-intrinsic fold, so the two paths cannot drift. A zone whose sampleId has no ref
|
||||
// is dropped + reported (same stale-id shape as the bank path). Pure.
|
||||
// The bank-free mirror of resolvePerformance, against the INSTANCE-OWNED refs table —
|
||||
// shares the same override-beats-intrinsic fold, so the two paths cannot drift. A zone
|
||||
// whose sampleId has no ref is dropped + reported (same stale-id shape as the bank path).
|
||||
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
|
||||
const PerformanceMap& map);
|
||||
|
||||
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` is the
|
||||
// downmixed frames + sample rate for `zones[i]` (same length + order as `zones`). One
|
||||
// SampleData per zone (Tier 1: one sample per key-region; a sample used by two zones is
|
||||
// decoded twice — acceptable at this tier, the shell may dedup by path later). Zone order
|
||||
// is preserved so first-match overlap resolution matches the map's authored order. A zone
|
||||
// whose decoded frames are empty is SKIPPED (an unreadable WAV drops the zone, not the
|
||||
// map). Empty zones in -> empty Keymap (silence).
|
||||
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` matches
|
||||
// `zones[i]` in length + order. One SampleData per zone (a sample used by two zones is
|
||||
// decoded twice — acceptable here, the shell may dedup by path later). Zone order preserved
|
||||
// so first-match overlap resolution matches authored order. A zone whose decoded frames are
|
||||
// empty is SKIPPED (an unreadable WAV drops the zone, not the map).
|
||||
struct DecodedZonePcm {
|
||||
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
|
||||
int sampleRate = 0; // 0 is explicitly invalid; every consumer must
|
||||
// receive the WAV's real rate before use.
|
||||
int sampleRate = 0; // 0 is explicitly invalid
|
||||
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
|
||||
};
|
||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
const std::vector<DecodedZonePcm>& decoded);
|
||||
|
||||
// Apply the S7 cross-mode channel policy (D-E) to freshly-decoded interleaved PCM, yielding
|
||||
// the 1- or 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's
|
||||
// float frames (stride = `sourceChannels`); `mode` is the instance's channel mode.
|
||||
// * MONO mode -> downmix to one channel (the existing policy: average all source
|
||||
// channels). framesR EMPTY. A mono or stereo source both collapse.
|
||||
// * STEREO mode, mono src -> DUAL-MONO: channel 0 duplicated into channel 1 (centered).
|
||||
// * STEREO mode, stereo src -> channels 0 and 1 taken as-is (L/R). A source with >2 channels
|
||||
// takes channels 0 and 1 (documented; the sampler's stereo image is
|
||||
// the first two channels — no surround fold).
|
||||
// Empty / zero-channel input -> a DecodedZonePcm with empty frames (the caller drops the zone
|
||||
// or plays silence). Pure — the shell does the file I/O and hands the interleaved buffer here.
|
||||
// Apply the cross-mode channel policy to freshly-decoded interleaved PCM, yielding the 1- or
|
||||
// 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's float
|
||||
// frames (stride = `sourceChannels`); `mode` is the instance's channel mode.
|
||||
// * MONO mode -> downmix to one channel (average all source channels).
|
||||
// * STEREO mode, mono src -> dual-mono: channel 0 duplicated into channel 1 (centered).
|
||||
// * STEREO mode, stereo+ src -> channels 0 and 1 as-is (no surround fold on >2 channels).
|
||||
// Empty/zero-channel input -> empty frames (caller drops the zone or plays silence).
|
||||
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||
int sourceChannels, ChannelMode mode, int sampleRate);
|
||||
|
||||
// The ComponentState envelope + zones-payload binary codec (serializePerformance /
|
||||
// serializeComponentState / serializeSelection + the deserializers and every version
|
||||
// constant) lives in component_state_io.h (Q-W2v split, T4-13 ≡ T2-07): the codec grows
|
||||
// on every envelope bump and is consumed by the EXTENSION's preset-blob path too — the
|
||||
// split lets both artifacts share the codec while only the VST links the voice engine.
|
||||
// The ComponentState envelope + zones-payload binary codec lives in component_state_io.h:
|
||||
// it grows on every envelope bump and is consumed by the extension's preset-blob path too,
|
||||
// so both artifacts share the codec while only the VST links the voice engine.
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -1,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"
|
||||
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
// trigger_seam.h — PURE Trigger-mode frames↔fraction converter for the S-VIEW-3 envelope seam.
|
||||
// NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
|
||||
// trigger_seam — converts Trigger fade lengths between the engine domain (TriggerParams:
|
||||
// SOURCE FRAMES, anchored to the source-timeline read pointer) and the overlay domain
|
||||
// (AmpEnvelope: FRACTIONS in [0,1] of the played span, so the drawn shape stays invariant
|
||||
// across sample-rate changes). Owns the one shared pack/unpack formula so both directions
|
||||
// stay consistent; reasampler_editor calls these from packEnvelope/unpackEnvelope.
|
||||
//
|
||||
// The TRIGGER SEAM (documented in envelope_overlay.h) converts between the two representations
|
||||
// of Trigger fade lengths:
|
||||
//
|
||||
// ENGINE domain (TriggerParams / sampler_core): SOURCE FRAMES — int64_t absolute frame counts
|
||||
// that anchor directly to the voice's source-timeline read pointer.
|
||||
//
|
||||
// OVERLAY domain (AmpEnvelope / envelope_overlay): FRACTIONS — doubles in [0,1] of the played
|
||||
// span, where the played span is:
|
||||
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
|
||||
// The overlay stores fractions so the drawn shape stays invariant across sample-rate changes;
|
||||
// the engine stores frames so the voice advances correctly at the live rate.
|
||||
//
|
||||
// This module owns the one shared formula so the pack (frames->fractions) and unpack
|
||||
// (fractions->frames) paths are provably consistent and unit-tested independently of the shell.
|
||||
// The shell (reasampler_editor.cpp) calls these two functions from packEnvelope / unpackEnvelope.
|
||||
//
|
||||
// S-VIEW-F2 safety: the fractions produced here are in [0,1] by construction; a caller that
|
||||
// clamps the fractions to [0,1] before writing the AmpEnvelope preserves the slider-range
|
||||
// invariant (a drag can never produce a value a slider couldn't reach).
|
||||
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -27,25 +12,18 @@
|
||||
|
||||
namespace reasampler::instrument::map {
|
||||
|
||||
// The source-frame length of the Trigger played span:
|
||||
// postStart = max(0, frameCount - startFrame)
|
||||
// playLength = round(lengthFraction * postStart)
|
||||
// `frameCount` is the total decoded sample length in source frames.
|
||||
// `startFrame` is the effective start point (zone.startPoint, or 0 when absent).
|
||||
// `lengthFraction` is TriggerParams::lengthFraction — (0,1], the fraction of the post-start span.
|
||||
// Returns 0 when postStart == 0 or lengthFraction <= 0.
|
||||
// postStart = max(0, frameCount - startFrame); playLength = round(lengthFraction * postStart).
|
||||
// `startFrame` is the effective start point (0 when absent). Returns 0 when postStart == 0
|
||||
// or lengthFraction <= 0.
|
||||
std::int64_t triggerPlayLength(double lengthFraction,
|
||||
std::int64_t frameCount,
|
||||
std::int64_t startFrame);
|
||||
|
||||
// Convert a source-frame fade count to a fraction of the play span (PACK direction, draw path).
|
||||
// Returns 0.0 when playLength == 0 (degenerate sample or zero %-length); the fraction is
|
||||
// NOT clamped — the caller clamps to [0,1] when filling AmpEnvelope so the overlay clamp logic
|
||||
// stays in envelope_edit, not here.
|
||||
// PACK direction (draw path): frames -> fraction of play span. Not clamped here — the
|
||||
// caller clamps to [0,1] when filling AmpEnvelope (envelope_edit owns that logic).
|
||||
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength);
|
||||
|
||||
// Convert a fade fraction to a source-frame count (UNPACK direction, commit path).
|
||||
// Rounds to nearest integer frame. Returns 0 when playLength == 0.
|
||||
// UNPACK direction (commit path): fraction -> nearest source frame.
|
||||
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength);
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10
|
||||
// capture_browser. No host types; only the shared Rect + BrowserLayout.
|
||||
// browser_scroll.cpp — see browser_scroll.h. Pure scroll + search geometry; no host types.
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h"
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // kPad / kTitleHeight / kNavButtonWidth (Q-W2v hoist)
|
||||
#include "core/instrument/ui/editor_geometry.h" // kPad / kTitleHeight / kNavButtonWidth
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
@@ -50,11 +49,10 @@ VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int of
|
||||
return vr;
|
||||
}
|
||||
if (offset < 0) offset = 0;
|
||||
// First visible ROW: the topmost row whose bottom edge is below the offset. Floor so a row
|
||||
// partially scrolled off the top still draws (its lower part is visible).
|
||||
// First row: floored so a row partially scrolled off the top still draws. Last row:
|
||||
// the row containing pixel (offset + gridH - 1), +1 for the exclusive end, so a row
|
||||
// straddling the bottom edge still draws.
|
||||
const int firstRow = offset / kBrowserCardHeight;
|
||||
// Last visible ROW: the row containing the pixel (offset + gridH - 1), inclusive; +1 for
|
||||
// the exclusive end. A row straddling the bottom edge still draws.
|
||||
const int lastRow = (offset + gridH - 1) / kBrowserCardHeight + 1;
|
||||
int first = firstRow * columns;
|
||||
int last = lastRow * columns;
|
||||
@@ -84,13 +82,11 @@ Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) {
|
||||
const int trackLeft = trackRight - kScrollbarWidth;
|
||||
const int trackTop = layout.grid.y;
|
||||
|
||||
// Thumb height proportional to the visible fraction, floored at a grabbable minimum but
|
||||
// never taller than the track.
|
||||
// Thumb height proportional to the visible fraction, floored/capped to the track.
|
||||
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
|
||||
thumbH = (std::max)(kMinThumbHeight, thumbH);
|
||||
thumbH = (std::min)(thumbH, gridH);
|
||||
|
||||
// Thumb top proportional to the offset over the movable track span.
|
||||
const int trackSpan = gridH - thumbH; // >= 0
|
||||
int thumbTop = trackTop;
|
||||
if (maxOff > 0 && trackSpan > 0) {
|
||||
@@ -106,7 +102,7 @@ int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffse
|
||||
const int gridH = (std::max)(0, layout.grid.height);
|
||||
if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset);
|
||||
|
||||
// Thumb height (same formula as scrollThumbRect) -> movable track span in thumb pixels.
|
||||
// Same thumb-height formula as scrollThumbRect.
|
||||
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
|
||||
thumbH = (std::max)(kMinThumbHeight, thumbH);
|
||||
thumbH = (std::min)(thumbH, gridH);
|
||||
@@ -157,8 +153,6 @@ std::vector<int> filterNameIndices(const std::vector<std::string>& names,
|
||||
return out;
|
||||
}
|
||||
|
||||
// The Browse-modal regions (hoisted from the editor shell, Q-W2v/T2-06 — body verbatim;
|
||||
// the band metrics come from editor_geometry, the search height from searchBoxRect).
|
||||
BrowseModal computeBrowseModal(int w, int h) {
|
||||
constexpr int kBrowseFooterH = 30;
|
||||
BrowseModal m;
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
// browser_scroll.h — PURE scroll + type-to-filter geometry LAYERED over the S10
|
||||
// capture_browser. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of
|
||||
// capture_browser / editor_geometry: the fiddly scroll-window + scrollbar-thumb + search-box
|
||||
// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws the
|
||||
// clipped card window + the scrollbar + the search field and routes wheel/drag/keystrokes
|
||||
// into these functions.
|
||||
// browser_scroll.h — scroll + type-to-filter geometry layered over capture_browser. Mirror
|
||||
// of capture_browser/editor_geometry; the shell draws the clipped card window, scrollbar,
|
||||
// and search field, and routes wheel/drag/keystrokes into these functions.
|
||||
//
|
||||
// WHY IT EXISTS (S12). capture_browser (S10) lays out EVERY card top-down and the shell
|
||||
// clips at the browser bottom — a bank longer than the panel runs off with no way to reach
|
||||
// it (the S12 gap). This module adds the two things S12 layers over that stable geometry:
|
||||
// * SCROLL — a vertical pixel offset into the card grid, with the max-offset clamp, the
|
||||
// visible-row window, a scrollbar thumb rect, and the thumb-drag<->offset mapping so a
|
||||
// wheel tick or a thumb drag reaches every card; and
|
||||
// * SEARCH — a name-substring filter (case-insensitive) that narrows the drawn cards,
|
||||
// COMPOSING with capture_browser's bank filter (the shell applies the bank filter first,
|
||||
// then this search narrows within it) + the search-box rect the shell draws the field in.
|
||||
// capture_browser lays out every card top-down and the shell clips at the browser bottom —
|
||||
// a bank longer than the panel has no way to reach the rest. This module adds scroll (a
|
||||
// vertical pixel offset with max-offset clamp, visible-row window, scrollbar thumb, and
|
||||
// thumb-drag<->offset mapping) and search (a case-insensitive name-substring filter that
|
||||
// composes with capture_browser's bank filter — the shell applies the bank filter first,
|
||||
// then this search narrows within it).
|
||||
//
|
||||
// It holds NO card data and draws nothing — it knows only the browser layout (from
|
||||
// capture_browser), COUNTS, and the scroll OFFSET the shell owns as transient UI state. It
|
||||
// reuses capture_browser's BrowserLayout + the shared Rect (one geometry idiom).
|
||||
// Holds no card data and draws nothing — knows only the browser layout, counts, and the
|
||||
// scroll offset the shell owns as transient UI state.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -28,96 +21,74 @@
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell
|
||||
// draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so
|
||||
// the shell and tests agree. When the content fits (no scroll needed) the scrollbar is
|
||||
// suppressed (scrollThumbRect returns empty) and the shell may reclaim the gutter.
|
||||
// Width of the vertical scrollbar gutter at the grid's right edge. When content fits (no
|
||||
// scroll needed), scrollThumbRect returns empty and the shell may reclaim the gutter.
|
||||
inline constexpr int kScrollbarWidth = 10;
|
||||
|
||||
// The height (px) of the type-to-filter search box the shell draws ABOVE the tab strip (a
|
||||
// thin band spanning the browser width). Exposed so the shell reserves the band and tests
|
||||
// agree. capture_browser's tab strip + grid sit BELOW this band (the shell offsets the
|
||||
// BrowserLayout it feeds to capture_browser by kSearchBoxHeight).
|
||||
// Height of the type-to-filter search box the shell draws above the tab strip.
|
||||
// capture_browser's tab strip + grid sit below this band.
|
||||
inline constexpr int kSearchBoxHeight = 22;
|
||||
|
||||
// The total pixel HEIGHT the card grid needs to draw all `cardCount` cards at `layout`'s
|
||||
// column count: the number of ROWS (ceil(cardCount / columns)) times the fixed cell height.
|
||||
// Zero cards -> 0. Pure — the content extent the scroll offset ranges over.
|
||||
// Total pixel height the card grid needs for `cardCount` cards at `layout`'s column
|
||||
// count: rows (ceil(cardCount / columns)) times the fixed cell height.
|
||||
int scrollContentHeight(const BrowserLayout& layout, int cardCount);
|
||||
|
||||
// The maximum scroll offset (px): content height minus the visible grid height, floored at 0.
|
||||
// When the content fits within the grid this is 0 (nothing to scroll). Pure — the clamp
|
||||
// ceiling for every offset the shell tracks.
|
||||
// Maximum scroll offset: content height minus visible grid height, floored at 0.
|
||||
int scrollMaxOffset(const BrowserLayout& layout, int cardCount);
|
||||
|
||||
// Clamp a proposed scroll offset into [0, scrollMaxOffset]. The shell clamps after every wheel
|
||||
// tick / thumb drag so an over-scroll pins to an edge rather than showing past the last card
|
||||
// or above the first. Pure.
|
||||
// Clamps a proposed scroll offset into [0, scrollMaxOffset].
|
||||
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset);
|
||||
|
||||
// The half-open range of card INDICES [first, last) at least partially visible in the grid at
|
||||
// scroll `offset`. The shell draws only these cards (the S12 clip window) rather than every
|
||||
// card. `offset` is assumed pre-clamped (the shell clamps on input); a first past the last row
|
||||
// yields an empty range (first==last==cardCount). Pure.
|
||||
// Half-open range of card indices [first, last) at least partially visible at scroll
|
||||
// `offset` (assumed pre-clamped). The shell draws only these cards.
|
||||
struct VisibleRange {
|
||||
int first = 0; // first card index drawn (inclusive)
|
||||
int last = 0; // one past the last card index drawn (exclusive)
|
||||
int first = 0;
|
||||
int last = 0;
|
||||
};
|
||||
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset);
|
||||
|
||||
// The cell rect of card `index` SHIFTED UP by the scroll offset, ready to draw (the shell
|
||||
// still adds the browser sub-area origin). Equivalent to capture_browser::cardCellRect with
|
||||
// the offset subtracted from top/bottom. Pure — the one place the offset applies to a card.
|
||||
// Cell rect of card `index` shifted up by the scroll offset (the shell still adds the
|
||||
// browser sub-area origin).
|
||||
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset);
|
||||
|
||||
// The vertical scrollbar THUMB rect within the grid's right-edge gutter, sized proportional to
|
||||
// the visible fraction (grid height / content height) and positioned proportional to the
|
||||
// scroll offset. Returns an EMPTY rect when the content fits (no scroll needed) — the shell
|
||||
// suppresses the scrollbar then. A minimum thumb height keeps a tiny thumb grabbable on a very
|
||||
// long bank. Pure — the geometry the shell draws + hit-tests the thumb grab against.
|
||||
// Vertical scrollbar thumb rect within the grid's right-edge gutter, sized proportional
|
||||
// to the visible fraction and positioned proportional to the scroll offset. Empty when
|
||||
// the content fits. A minimum thumb height keeps a tiny thumb grabbable on a long bank.
|
||||
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset);
|
||||
|
||||
// Map a thumb-drag to a scroll offset. Given the offset the thumb held at grab time
|
||||
// (`startOffset`) and the vertical pixel delta since grab (`dyPixels`), returns the new
|
||||
// (clamped) scroll offset: startOffset shifted by the delta scaled from thumb-track pixels to
|
||||
// content pixels (a 1px thumb move covers content/track px of content). A degenerate track /
|
||||
// fitting content pins to startOffset. Pure — the inverse of scrollThumbRect's position map.
|
||||
// Maps a thumb-drag to a new (clamped) scroll offset: `startOffset` shifted by the pixel
|
||||
// delta scaled from thumb-track pixels to content pixels. A degenerate track or fitting
|
||||
// content pins to startOffset.
|
||||
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels);
|
||||
|
||||
// The search-box rect: a full-width band of height kSearchBoxHeight at the TOP of the browser
|
||||
// area (above where capture_browser's tab strip draws). `w` is the browser sub-area width;
|
||||
// the shell adds its origin. A zero/negative width yields an empty rect. Pure.
|
||||
// Search-box rect: full-width band of height kSearchBoxHeight at the top of the browser
|
||||
// area. `w` is the browser sub-area width; the shell adds its origin.
|
||||
Rect searchBoxRect(int w);
|
||||
|
||||
// True iff `name` contains `query` as a case-insensitive ASCII substring. An EMPTY query
|
||||
// matches everything (the no-filter identity). Matching is ASCII case-folded (the display
|
||||
// names are ASCII until the Phase L type kit lands, mirroring the editor's other ASCII-only
|
||||
// text). Pure — the single match predicate the shell's search narrow is built from.
|
||||
// True iff `name` contains `query` as a case-insensitive ASCII substring. An empty query
|
||||
// matches everything.
|
||||
bool nameMatchesQuery(const std::string& name, const std::string& query);
|
||||
|
||||
// Narrow a list of display `names` to the INDICES whose name matches `query`, preserving
|
||||
// order. An EMPTY query returns every index [0, names.size()) (the composition base so "bank
|
||||
// filter, no search" == today's browser). Kept name-only (indices, not card structs) so this
|
||||
// module stays free of the sample_map/bank_book chain — the shell owns the SampleChoice list
|
||||
// and applies the bank filter FIRST, then feeds the surviving display names here (search
|
||||
// narrows within the bank). Pure.
|
||||
// Narrows a list of display `names` to the indices whose name matches `query`, preserving
|
||||
// order. An empty query returns every index. Kept name-only (indices, not card structs)
|
||||
// so this module stays free of the sample_map/bank_book chain — the shell applies the
|
||||
// bank filter first, then feeds the surviving display names here.
|
||||
std::vector<int> filterNameIndices(const std::vector<std::string>& names,
|
||||
const std::string& query);
|
||||
|
||||
// --- The Browse-modal (S-VIEW-5) top-level regions (Q-W2v hoist, T2-06) -------
|
||||
// --- Browse-modal top-level regions --------------------------------------------
|
||||
//
|
||||
// A title band with a Back button, the search box, the browser sub-area (tabs + card
|
||||
// grid — layoutBrowser's origin), and a footer with Cancel / Load-confirm. The picker
|
||||
// covers the full window (F3: full-window overlay). Draw + hit-test both derive from
|
||||
// this single layout so they never drift. Homed here (not editor_geometry) because the
|
||||
// search-box height feeds it — browser_scroll already owns the search/scroll geometry.
|
||||
// covers the full window. Homed here (not editor_geometry) because the search-box
|
||||
// height feeds it.
|
||||
struct BrowseModal {
|
||||
Rect title;
|
||||
Rect back; // the "Back" title-band button
|
||||
Rect search; // the type-to-filter box (absolute)
|
||||
Rect content; // the browser sub-area (tabs + grid) — layoutBrowser's origin
|
||||
Rect cancel; // footer Cancel
|
||||
Rect confirm; // footer Load (confirm)
|
||||
Rect back;
|
||||
Rect search;
|
||||
Rect content; // browser sub-area (tabs + grid) — layoutBrowser's origin
|
||||
Rect cancel;
|
||||
Rect confirm;
|
||||
};
|
||||
BrowseModal computeBrowseModal(int w, int h);
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
// The left edge of tab i in a strip of the given x-origin and width divided into `count`
|
||||
// equal segments (mirror of mode_switch::segmentEdge). Every boundary derives from the same
|
||||
// formula, so consecutive tabs share an exact edge and the last tab reaches x+width exactly.
|
||||
// Left edge of tab i in a strip divided into `count` equal segments (mirror of
|
||||
// mode_switch::segmentEdge). Same formula for every boundary so consecutive tabs share
|
||||
// an exact edge.
|
||||
int tabEdge(int x, int width, int i, int count) {
|
||||
return x + (i * width) / count;
|
||||
}
|
||||
|
||||
@@ -1,92 +1,67 @@
|
||||
// capture_browser.h — PURE layout + hit-test for the S10 capture-first editor's default
|
||||
// face: a scannable grid of capture CARDS with a bank-FILTER tab strip above it. NO VST3,
|
||||
// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry /
|
||||
// embed_strip / mode_switch: the fiddly card-grid + tab arithmetic lives here so it is
|
||||
// unit-tested outside the DAW, while the editor shell draws each card's peak thumbnail +
|
||||
// name + root/key badge and routes clicks into these functions.
|
||||
// capture_browser.h — layout + hit-test for the capture-first editor's default face: a
|
||||
// scannable grid of capture cards with a bank-filter tab strip above it. Mirror of
|
||||
// editor_geometry/embed_strip/mode_switch; the shell draws thumbnails/names/badges and
|
||||
// routes clicks into these functions.
|
||||
//
|
||||
// The browser replaces the old text item-list (the named anti-pattern). It lays out N
|
||||
// cards in a fixed-cell grid that wraps across the browser width, and a horizontal tab
|
||||
// strip of bank filters (one tab per bank_book bank + an "All" tab) above the grid. This
|
||||
// module knows only COUNTS and RECTS — it draws nothing and holds no sample data; the
|
||||
// shell owns the SampleChoice list, the peak envelopes, and the filter state, and asks this
|
||||
// module only "where does card i draw" / "what did the user click".
|
||||
// This module knows only counts and rects — it draws nothing and holds no sample data;
|
||||
// the shell owns the SampleChoice list, peak envelopes, and filter state.
|
||||
//
|
||||
// Scroll is NOT here (S12 layers it over this module). The browser lays out every card
|
||||
// top-down; the shell clips at the browser's bottom until S12 adds a scroll offset. Keeping
|
||||
// scroll out keeps this module the stable card/tab geometry S12 builds on.
|
||||
//
|
||||
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom).
|
||||
// Scroll is layered on top by browser_scroll — this module lays out every card top-down
|
||||
// and the shell clips at the bottom until a scroll offset is applied.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// Fixed browser metrics, exposed so the shell and tests agree. The card is sized to show a
|
||||
// peak thumbnail with a name + badge line under it — scannable by eye, not a dense list.
|
||||
inline constexpr int kBrowserTabHeight = 26; // the bank-filter tab strip band height
|
||||
inline constexpr int kBrowserCardWidth = 132; // one card cell width (incl. gutter)
|
||||
inline constexpr int kBrowserCardHeight = 84; // one card cell height (incl. gutter)
|
||||
inline constexpr int kBrowserCardGutter = 8; // inset between the cell edge and the card
|
||||
inline constexpr int kBrowserThumbHeight = 44; // the peak-thumbnail band inside a card
|
||||
// Fixed browser metrics, exposed so the shell and tests agree.
|
||||
inline constexpr int kBrowserTabHeight = 26;
|
||||
inline constexpr int kBrowserCardWidth = 132;
|
||||
inline constexpr int kBrowserCardHeight = 84;
|
||||
inline constexpr int kBrowserCardGutter = 8;
|
||||
inline constexpr int kBrowserThumbHeight = 44;
|
||||
|
||||
// The browser's regions, derived from the (w x h) area the shell allots it. Both clamp to
|
||||
// the area so a degenerate (tiny/zero) size never yields an inverted rect.
|
||||
// Clamped so a degenerate (tiny/zero) size never yields an inverted rect.
|
||||
struct BrowserLayout {
|
||||
Rect tabStrip; // top: the bank-filter tabs
|
||||
Rect grid; // below the tabs: where the capture cards tile
|
||||
int columns = 1; // cards per row in `grid` (>= 1); derived from grid.width
|
||||
Rect tabStrip;
|
||||
Rect grid;
|
||||
int columns = 1; // cards per row in `grid` (>= 1)
|
||||
};
|
||||
|
||||
// Divide a (w x h) browser area into its regions and compute the column count. Pure: same
|
||||
// inputs -> same layout. The tab strip takes a fixed height at the top (clamped so it never
|
||||
// exceeds the area); the grid takes the rest. columns = max(1, grid.width/cardWidth) so a
|
||||
// browser narrower than one card still lays out a single column. A zero/negative size
|
||||
// yields empty rects + columns==1.
|
||||
// Divide a (w x h) browser area into its regions and compute the column count. columns =
|
||||
// max(1, grid.width/cardWidth) so a browser narrower than one card still lays out a
|
||||
// single column.
|
||||
BrowserLayout layoutBrowser(int w, int h);
|
||||
|
||||
// The cell rect of capture card `index` (0-based) in the grid, laid out left-to-right then
|
||||
// top-to-bottom across `columns`. This is the full CELL (card + gutter); cardContentRect
|
||||
// insets it to the drawable card. Rows past the visible grid are still computed (the shell
|
||||
// clips at paint time). A negative index yields an empty rect. Pure.
|
||||
// Cell rect of capture card `index` (0-based), left-to-right then top-to-bottom across
|
||||
// `columns`. This is the full cell (card + gutter); cardContentRect insets it.
|
||||
Rect cardCellRect(const BrowserLayout& layout, int index);
|
||||
|
||||
// The drawable card rect inside a cell: the cell inset by kBrowserCardGutter on all sides.
|
||||
// The shell fills this (background + border) and draws the thumbnail/name/badge inside it. Pure.
|
||||
// Drawable card rect inside a cell: the cell inset by kBrowserCardGutter on all sides.
|
||||
Rect cardContentRect(const BrowserLayout& layout, int index);
|
||||
|
||||
// The peak-thumbnail sub-rect at the top of a card's content: full card width, the top
|
||||
// kBrowserThumbHeight (clamped to the card height). The shell draws the envelope here; the
|
||||
// name + badge go in the remaining strip below. Pure.
|
||||
// Peak-thumbnail sub-rect at the top of a card's content: full card width, the top
|
||||
// kBrowserThumbHeight (clamped to the card height).
|
||||
Rect cardThumbnailRect(const BrowserLayout& layout, int index);
|
||||
|
||||
// The name/badge sub-rect below the thumbnail: the card content minus the thumbnail band.
|
||||
// The shell draws the display name + root/key badge here. Pure.
|
||||
// Name/badge sub-rect below the thumbnail.
|
||||
Rect cardLabelRect(const BrowserLayout& layout, int index);
|
||||
|
||||
// The card a click at (x, y) lands on, given `cardCount` cards, or -1 for a click outside
|
||||
// every card (in a gutter, past the last card, or on the tab strip). Only the card CONTENT
|
||||
// rect counts as a hit — a click in the inter-card gutter is a miss. Pure.
|
||||
// Card a click at (x, y) lands on, given `cardCount` cards, or -1 for a miss. Only the
|
||||
// card content rect counts as a hit — a click in the inter-card gutter is a miss.
|
||||
int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y);
|
||||
|
||||
// --- Bank-filter tabs --------------------------------------------------------
|
||||
// --- Bank-filter tabs ---------------------------------------------------------
|
||||
//
|
||||
// The tab strip divides tabStrip into `tabCount` equal segments (mirror of mode_switch):
|
||||
// one tab per bank_book bank plus a leading "All" tab the shell prepends, so tabCount ==
|
||||
// bankCount + 1 in practice. This module only divides the strip + hit-tests; the shell
|
||||
// supplies the labels and tracks which tab is active. A tab click narrows the card list to
|
||||
// that bank (the shell filters its SampleChoice list before laying out cards).
|
||||
// Divides tabStrip into `tabCount` equal segments: one tab per bank plus a leading "All"
|
||||
// tab the shell prepends. This module only divides the strip + hit-tests; the shell
|
||||
// supplies labels and tracks the active tab.
|
||||
|
||||
// The rect of tab `index` (0-based) when the strip is divided into `tabCount` equal
|
||||
// segments. The last tab absorbs any width remainder so the tabs tile the whole strip with
|
||||
// no gap (mirror of mode_switch's segment split). A negative index or tabCount<=0 yields an
|
||||
// empty rect. Pure.
|
||||
// Rect of tab `index` when the strip is divided into `tabCount` equal segments. The last
|
||||
// tab absorbs any width remainder so the tabs tile the whole strip with no gap.
|
||||
Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index);
|
||||
|
||||
// The tab a click at (x, y) lands on, given `tabCount` tabs, or -1 for a click outside the
|
||||
// tab strip. Pure.
|
||||
int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
// curve_popup.h — PURE sheet geometry + dismissal test for the r11 velocity-curve popup
|
||||
// editor (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
|
||||
// of overflow_menu: the size-clamp / centering / title-row arithmetic lives here, unit-tested
|
||||
// at the clamps outside the DAW, while the editor shell draws the wash + sheet through the
|
||||
// L1 kit and routes clicks (close / curve box / outside-sheet dismiss) via these rects.
|
||||
// curve_popup.h — sheet geometry + dismissal test for the velocity-curve popup editor.
|
||||
// Mirror of overflow_menu; the shell draws through the L1 kit and routes clicks via
|
||||
// these rects.
|
||||
//
|
||||
// THE POPUP (CONTEXT.md §S-VIEW r11). Summoned by the mini curve-preview button, a CENTERED
|
||||
// SHEET over the Sample face (a 0.50-alpha bg/base wash behind it — lighter than Browse's
|
||||
// 0.82; a focused sub-editor, not a view change): width clamp(60% of window, 360..520),
|
||||
// height clamp(55% of window, 260..380). Inside: a ~22px title row ("VELOCITY -> AMP"
|
||||
// micro-caps left, an 18x18 Close button right) over the full-size curve box filling the
|
||||
// remainder. The curve box rect here is the BORDER rect — the shell derives the mapping box
|
||||
// through its ONE curveBoxFromRect formula (the landed inset grammar), so the popup editor
|
||||
// and the Zone-panel inline editor share coordinates by construction.
|
||||
// A centered sheet over the Sample face (a lighter wash than Browse's, since this is a
|
||||
// focused sub-editor, not a view change): width/height each clamp to a fraction of the
|
||||
// window within min/max bounds. A title row sits over the curve box. The curve box rect
|
||||
// here is the border rect — the shell derives the mapping box via its curveBoxFromRect
|
||||
// formula, so the popup editor and the Zone-panel inline editor share coordinates.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -19,7 +14,7 @@
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// Fixed popup metrics (spec r11), exposed so the shell and tests agree.
|
||||
// Fixed popup metrics, exposed so the shell and tests agree.
|
||||
inline constexpr int kCurvePopupMinW = 360;
|
||||
inline constexpr int kCurvePopupMaxW = 520;
|
||||
inline constexpr int kCurvePopupMinH = 260;
|
||||
@@ -29,20 +24,19 @@ inline constexpr int kCurvePopupCloseSize = 18;
|
||||
inline constexpr int kCurvePopupPad = 8; // sheet inner padding (title inset + box margins)
|
||||
|
||||
struct CurvePopupLayout {
|
||||
Rect sheet; // the bg/panel sheet, centered in the window
|
||||
Rect title; // the caption text rect (left part of the title row)
|
||||
Rect close; // the 18x18 Close (x) button, right-anchored in the title row
|
||||
Rect curveBox; // the full-size curve editor BORDER rect (shell insets via curveBoxFromRect)
|
||||
Rect sheet;
|
||||
Rect title;
|
||||
Rect close; // Close (x) button, right-anchored in the title row
|
||||
Rect curveBox; // full-size curve editor border rect (shell insets via curveBoxFromRect)
|
||||
};
|
||||
|
||||
// The popup geometry for a (w x h) window: sheet width clamp(60% w, 360..520) and height
|
||||
// clamp(55% h, 260..380) — each additionally capped at the window dimension so a degenerate
|
||||
// window never yields an overhanging sheet — centered; title row + close button at the top;
|
||||
// the curve box filling the remainder inside kCurvePopupPad margins. Pure.
|
||||
// Popup geometry for a (w x h) window: sheet width clamp(60% w, min..max) and height
|
||||
// clamp(55% h, min..max), each additionally capped at the window dimension so a
|
||||
// degenerate window never yields an overhanging sheet — centered.
|
||||
CurvePopupLayout computeCurvePopup(int w, int h);
|
||||
|
||||
// True when (x, y) lands OUTSIDE the sheet (on the wash) — the click-outside dismissal test.
|
||||
// The shell additionally gates on "no drag in flight" (spec). Pure.
|
||||
// True when (x, y) lands outside the sheet (on the wash) — the click-outside dismissal
|
||||
// test. The shell additionally gates on "no drag in flight".
|
||||
bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -8,8 +8,6 @@ namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
// Spike editor layout constants. These are the editor's fixed metrics; the real
|
||||
// editor (S4/S5) will parameterize as its content demands.
|
||||
constexpr int kTitleBarHeight = 28;
|
||||
constexpr int kButtonMargin = 10;
|
||||
constexpr int kButtonWidth = 120;
|
||||
@@ -17,26 +15,18 @@ constexpr int kButtonHeight = 24;
|
||||
|
||||
} // namespace
|
||||
|
||||
// contains() now lives with the shared ui::Rect (core/ui/rect.h) — same half-open
|
||||
// semantics, re-exported through the header's using-declaration.
|
||||
|
||||
EditorLayout layoutEditor(int w, int h) {
|
||||
// Clamp the surface to non-negative extents so a degenerate view can't produce
|
||||
// inverted rects.
|
||||
// Clamp to non-negative extents so a degenerate view can't produce inverted rects.
|
||||
const int cw = std::max(0, w);
|
||||
const int ch = std::max(0, h);
|
||||
|
||||
EditorLayout out;
|
||||
|
||||
// Title bar spans the top, clamped so it never exceeds the client height.
|
||||
const int titleH = std::min(kTitleBarHeight, ch);
|
||||
out.titleBar = Rect::ltrb(0, 0, cw, titleH);
|
||||
|
||||
// Canvas is everything below the title bar.
|
||||
out.canvas = Rect::ltrb(0, titleH, cw, ch);
|
||||
|
||||
// Button sits at the top-left of the canvas, inset by a margin, and is clamped to
|
||||
// fit inside the canvas so it never overhangs on a small view.
|
||||
// Button inset from the canvas top-left, clamped so it never overhangs a small view.
|
||||
const int bx = out.canvas.x + kButtonMargin;
|
||||
const int by = out.canvas.y + kButtonMargin;
|
||||
const int bRight = std::min(bx + kButtonWidth, out.canvas.right());
|
||||
@@ -59,15 +49,11 @@ Rect sampleRowRect(const EditorLayout& layout, int index) {
|
||||
|
||||
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) {
|
||||
if (rowCount <= 0) return -1;
|
||||
// Must be within the canvas horizontally and at/below its top.
|
||||
if (x < layout.canvas.x || x >= layout.canvas.right()) return -1;
|
||||
if (y < layout.canvas.y) return -1;
|
||||
// Clip at the canvas bottom: clicks in the canvas's dead-zone below the last
|
||||
// visible row agree with sampleRowRect, which does not clamp rows to canvas.bottom().
|
||||
if (y >= layout.canvas.bottom()) return -1;
|
||||
const int index = (y - layout.canvas.y) / kSampleRowHeight;
|
||||
if (index < 0 || index >= rowCount) return -1;
|
||||
// Guard the bottom edge: a click below the last row's bottom is outside.
|
||||
const Rect r = sampleRowRect(layout, index);
|
||||
if (y >= r.bottom()) return -1;
|
||||
return index;
|
||||
@@ -80,9 +66,6 @@ KeymapEditorLayout layoutKeymapEditor(int w, int h) {
|
||||
out.base = layoutEditor(w, h);
|
||||
const Rect& canvas = out.base.canvas;
|
||||
|
||||
// Split the canvas vertically: the left column is the bank-sample list, the right
|
||||
// column (1/kZonePanelFraction of the width) is the zone panel. Guard tiny widths so
|
||||
// the split point never crosses the canvas edges.
|
||||
const int canvasW = std::max(0, canvas.width);
|
||||
const int splitW = canvasW / kZonePanelFraction; // width of the zone panel
|
||||
const int splitX = std::max(canvas.x, canvas.right() - splitW);
|
||||
@@ -90,13 +73,11 @@ KeymapEditorLayout layoutKeymapEditor(int w, int h) {
|
||||
out.sampleList = Rect::ltrb(canvas.x, canvas.y, splitX, canvas.bottom());
|
||||
out.zonePanel = Rect::ltrb(splitX, canvas.y, canvas.right(), canvas.bottom());
|
||||
|
||||
// "Add Zone" button spans the top of the zone panel, clamped to its height.
|
||||
const int addH = std::min(kAddZoneHeight, std::max(0, out.zonePanel.height));
|
||||
out.addZoneButton =
|
||||
Rect::ltrb(out.zonePanel.x, out.zonePanel.y, out.zonePanel.right(),
|
||||
out.zonePanel.y + addH);
|
||||
|
||||
// Zone rows stack below the button.
|
||||
out.zoneRowArea = Rect::ltrb(out.zonePanel.x, out.addZoneButton.bottom(),
|
||||
out.zonePanel.right(), out.zonePanel.bottom());
|
||||
return out;
|
||||
@@ -138,10 +119,8 @@ ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int
|
||||
const Rect row = zoneRowRect(layout, index);
|
||||
if (y >= row.bottom()) return ZoneHit{};
|
||||
|
||||
// Seven mini-buttons pinned to the right edge, right-to-left:
|
||||
// delete, root+, root-, high+, high-, low+, low-
|
||||
// Each is kZoneCtrlWidth wide. A click left of the leftmost is the label ("select").
|
||||
// The fields laid out LEFT-TO-RIGHT in slot order 0..6.
|
||||
// Seven mini-buttons pinned to the right edge, each kZoneCtrlWidth wide, in slot
|
||||
// order 0..6; a click left of the leftmost is the label ("select").
|
||||
const ZoneField fields[7] = {
|
||||
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
|
||||
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp,
|
||||
@@ -159,35 +138,25 @@ bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) {
|
||||
return contains(layout.addZoneButton, x, y);
|
||||
}
|
||||
|
||||
// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ----------------------
|
||||
// Bodies moved verbatim from the reasampler_editor shell (behavior-identical); the
|
||||
// only signature change is clusterRects' `knobSize` parameter (formerly knob_deck's
|
||||
// kDeckKnobSize read directly — passed in so this module stays knob_deck-free).
|
||||
|
||||
namespace {
|
||||
|
||||
// Fixed band metrics (formerly the editor shell's anon-ns constants).
|
||||
constexpr int kHeroMinHeight = 150; // the elastic hero's floor (r11)
|
||||
constexpr int kHeroMinHeight = 150; // elastic hero's floor
|
||||
constexpr int kClusterHeight = 52; // root strip + preview + vel knob + curve btn + channel toggle
|
||||
constexpr int kStripBandHeight = 40; // the keyboard-strip band height (root strip + zone strip)
|
||||
constexpr int kStripBandHeight = 40; // keyboard-strip band height (root strip + zone strip)
|
||||
|
||||
// The r11 cluster's fixed right-anchored run (left -> right: Preview button, the radial
|
||||
// preview-velocity knob cell, the mini curve-preview button, Mono|Stereo).
|
||||
// Cluster's fixed right-anchored run: Preview button, vel knob cell, curve button, Mono|Stereo.
|
||||
constexpr int kPreviewBtnW = 64;
|
||||
constexpr int kVelCellW = 48; // the Vel knob cell (deck cell grammar)
|
||||
constexpr int kCurveBtnSize = 28; // the square curve-preview button
|
||||
constexpr int kVelCellW = 48;
|
||||
constexpr int kCurveBtnSize = 28;
|
||||
|
||||
// The S7 mono/stereo toggle segments.
|
||||
constexpr int kChanSegW = 52;
|
||||
constexpr int kChanSegH = 18;
|
||||
|
||||
} // namespace
|
||||
|
||||
// r11 band order: title (fixed) -> hero (ELASTIC: absorbs all height left after the fixed
|
||||
// bands, floor kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom-
|
||||
// anchored). When the window is too short for the floor (below the checkSizeConstraint
|
||||
// minimum — a defensive case), the hero keeps its floor and the lower bands clip past the
|
||||
// window bottom gracefully.
|
||||
// Band order: title (fixed) -> hero (elastic, absorbs remaining height, floor
|
||||
// kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom-anchored). A
|
||||
// window too short for the floor keeps the hero at its floor and clips lower bands.
|
||||
SampleBands computeSampleBands(int w, int h, int deckH) {
|
||||
SampleBands b;
|
||||
const int titleH = (std::min)(kTitleHeight, h);
|
||||
@@ -214,7 +183,6 @@ SampleBands computeSampleBands(int w, int h, int deckH) {
|
||||
return b;
|
||||
}
|
||||
|
||||
// Draw + hit-test both derive from this ONE formula.
|
||||
ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize) {
|
||||
ClusterRects r;
|
||||
const int stripTop = cluster.y + (cluster.height - kStripBandHeight) / 2;
|
||||
@@ -261,16 +229,14 @@ Rect zoneDeleteRect(const Rect& addR) {
|
||||
return Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom());
|
||||
}
|
||||
|
||||
// Zone content sits below the "+ Add Zone" affordance (top+4, height 20) with a 12px
|
||||
// gap, padded kPad horizontally. All call sites use this formula.
|
||||
// Sits below the "+ Add Zone" affordance (top+4, height 20) with a 12px gap.
|
||||
Rect zonesStripArea(const Rect& content) {
|
||||
const int stripTop = content.y + 4 + 20 + 12; // addR.bottom() + 12
|
||||
return Rect::ltrb(content.x + kPad, stripTop, content.right() - kPad,
|
||||
stripTop + kStripBandHeight);
|
||||
}
|
||||
|
||||
// Anchored off zonesStripArea.bottom() so the legend top tracks the strip bottom
|
||||
// without re-inlining the strip arithmetic here.
|
||||
// Anchored off zonesStripArea.bottom() so the legend top tracks the strip bottom.
|
||||
Rect noteEntryFieldsArea(const Rect& content) {
|
||||
const int stripBottom = zonesStripArea(content).bottom();
|
||||
const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom() + 8)
|
||||
@@ -292,9 +258,8 @@ Rect zonesControlPanel(const Rect& content) {
|
||||
content.bottom() - 4);
|
||||
}
|
||||
|
||||
// FB2 (R11-F2 parity): the deck lays out from the panel top (top-anchored), with a
|
||||
// column at the panel's right reserved for the mini curve-preview button so no deck row
|
||||
// starts inside it.
|
||||
// Top-anchored; reserves a column at the panel's right for the curve-preview button so
|
||||
// no deck row starts inside it.
|
||||
Rect zonesDeckArea(const Rect& content) {
|
||||
const Rect panel = zonesControlPanel(content);
|
||||
return Rect::ltrb(panel.x, panel.y, panel.right() - kCurveBtnSize - kPad, panel.bottom());
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
// editor_geometry.h — PURE view geometry + hit-test for the VST3 IPlugView LICE
|
||||
// editor (Phase S1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
|
||||
//
|
||||
// The IPlugView shell (reasampler_editor.cpp) owns the window/bitmap/SWELL plumbing
|
||||
// and is DAW-verified; this module holds the fiddly rectangle math and hit-testing so
|
||||
// it can be unit-tested outside the DAW — the mirror of how bank_grid / mode_switch /
|
||||
// tab_strip split their layout math out of the panel shell.
|
||||
//
|
||||
// The spike's editor is deliberately trivial (a title band + one clickable button),
|
||||
// enough to PROVE the host->draw/hit-test event routing works. As the real editor
|
||||
// (S4/S5) grows, its layout math accretes here, not in the shell.
|
||||
// editor_geometry.h — view geometry + hit-test for the VST3 IPlugView LICE editor. The
|
||||
// IPlugView shell owns window/bitmap/SWELL plumbing; the rectangle math and hit-testing
|
||||
// live here so they can be unit-tested outside the DAW.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -16,101 +8,80 @@
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// The shared pixel rectangle + containment test (Q-W1, T2-05 ≡ T4-21): the former
|
||||
// LTRB Rect defined here is folded into the ONE concrete ui::Rect (XYWH storage,
|
||||
// right()/bottom() accessors, Rect::ltrb() for edge-wise construction, same
|
||||
// half-open convention). Aliased here so every instrument-ui call site keeps its
|
||||
// established `Rect` / `contains` spelling.
|
||||
using Rect = ::reasampler::ui::Rect;
|
||||
using ::reasampler::ui::contains;
|
||||
|
||||
// The regions the spike editor draws, derived from the current view size. All are
|
||||
// clamped to the client area so a degenerate (too-small) view never yields a region
|
||||
// that spills outside the surface.
|
||||
// Title band + one button + remaining canvas, clamped so a degenerate (too-small) view
|
||||
// never yields a region spilling outside the surface.
|
||||
struct EditorLayout {
|
||||
Rect titleBar; // top band: the plugin name + a live-state readout
|
||||
Rect button; // a single clickable button (proves hit-test routing)
|
||||
Rect canvas; // the remaining surface below the title bar
|
||||
Rect titleBar;
|
||||
Rect button;
|
||||
Rect canvas;
|
||||
};
|
||||
|
||||
// Divide a (w x h) client area into the spike editor's regions. Pure: the same
|
||||
// inputs always yield the same layout. Guards tiny sizes — every returned rect stays
|
||||
// within [0,w] x [0,h], and the button never overhangs the canvas.
|
||||
// Divide a (w x h) client area into the editor's top-level regions. Pure.
|
||||
EditorLayout layoutEditor(int w, int h);
|
||||
|
||||
// The editor's hit-test targets. kNone means the point landed on inert surface.
|
||||
enum class HitTarget {
|
||||
kNone,
|
||||
kButton,
|
||||
};
|
||||
|
||||
// Classify a click at (x, y) against a layout. The button wins only when the point is
|
||||
// inside the button rect; everything else (including the title bar and empty canvas)
|
||||
// is kNone in the spike.
|
||||
// Classify a click at (x, y) against a layout.
|
||||
HitTarget hitTest(const EditorLayout& layout, int x, int y);
|
||||
|
||||
// --- Sample-selection list (S4 Tier-0 UI) -----------------------------------
|
||||
// --- Sample-selection list ---------------------------------------------------
|
||||
//
|
||||
// The Tier-0 editor lists the bank's samples as a vertical stack of fixed-height rows
|
||||
// below the title bar; clicking a row selects that sample. This is the pure geometry:
|
||||
// the row rectangles and the point->row hit-test, unit-tested outside the DAW while the
|
||||
// shell draws the names and routes the click into the processor's reloadInstrument.
|
||||
// A vertical stack of fixed-height rows below the title bar; clicking a row selects that
|
||||
// sample. Pure geometry only — the shell draws names and routes the click.
|
||||
|
||||
// The fixed row height (px) for one sample entry. Exposed so the shell and tests agree.
|
||||
inline constexpr int kSampleRowHeight = 22;
|
||||
|
||||
// The rectangle for row `index` (0-based) of the sample list, laid out top-down inside
|
||||
// the layout's canvas. Rows beyond what the canvas can show are still computed (the
|
||||
// shell clips at paint time); a negative index yields an empty rect. Pure.
|
||||
// Rect for row `index` (0-based), laid out top-down inside the layout's canvas. Rows
|
||||
// beyond what the canvas can show are still computed (the shell clips at paint time); a
|
||||
// negative index yields an empty rect.
|
||||
Rect sampleRowRect(const EditorLayout& layout, int index);
|
||||
|
||||
// The row index a click at (x, y) lands on, given `rowCount` rows, or -1 for a click
|
||||
// outside the list (above the first row, past the last, or on the title bar). Pure.
|
||||
// Row index a click at (x, y) lands on given `rowCount` rows, or -1 for a click outside
|
||||
// the list.
|
||||
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y);
|
||||
|
||||
// --- Keymap editor (S5 Tier-1 UI) -------------------------------------------
|
||||
// --- Keymap editor ------------------------------------------------------------
|
||||
//
|
||||
// The Tier-1 editor splits the canvas into a LEFT bank-sample list (the same rows as
|
||||
// Tier 0, reused for the "sample to add / fallback pick") and a RIGHT zone panel listing
|
||||
// the performance map's zones. An "Add Zone" button sits at the top of the zone panel;
|
||||
// each zone row carries small nudge/delete controls so the user can set the range and
|
||||
// root note without a text field (LICE has no native numeric entry). All rectangle math
|
||||
// is here so the shell only draws + routes — the mirror of the sample-list split above.
|
||||
// Splits the canvas into a LEFT bank-sample list (the sample-selection rows above, reused
|
||||
// as the "sample to add / fallback pick") and a RIGHT zone panel listing the performance
|
||||
// map's zones. An "Add Zone" button sits at the top of the zone panel; each zone row
|
||||
// carries nudge/delete mini-buttons (LICE has no native numeric entry field).
|
||||
|
||||
// Fixed metrics for the zone panel, exposed so the shell and tests agree.
|
||||
inline constexpr int kZoneRowHeight = 24;
|
||||
inline constexpr int kZonePanelFraction = 2; // zone panel gets the RIGHT 1/2 of the canvas
|
||||
inline constexpr int kZoneCtrlWidth = 20; // width of one nudge/delete mini-button
|
||||
inline constexpr int kAddZoneHeight = 22; // the "Add Zone" button band height
|
||||
inline constexpr int kAddZoneHeight = 22; // "Add Zone" button band height
|
||||
|
||||
// The keymap editor's regions, derived from the (w x h) client area. All clamp to the
|
||||
// canvas so a degenerate view yields in-bounds rects.
|
||||
// Clamps every rect to the canvas so a degenerate view still yields in-bounds rects.
|
||||
struct KeymapEditorLayout {
|
||||
EditorLayout base; // title bar + canvas (the sample list uses base.canvas.x half)
|
||||
Rect sampleList; // LEFT column: the bank-sample rows (sampleRowRect is relative here)
|
||||
Rect zonePanel; // RIGHT column: the "Add Zone" button + the zone rows
|
||||
EditorLayout base;
|
||||
Rect sampleList; // LEFT column
|
||||
Rect zonePanel; // RIGHT column
|
||||
Rect addZoneButton; // top of the zone panel
|
||||
Rect zoneRowArea; // below addZoneButton: where zone rows stack
|
||||
Rect zoneRowArea; // below addZoneButton
|
||||
};
|
||||
|
||||
KeymapEditorLayout layoutKeymapEditor(int w, int h);
|
||||
|
||||
// The rectangle for bank-sample row `index` inside the LEFT sample list column of a
|
||||
// keymap layout. Same fixed height as the Tier-0 list; laid out top-down inside
|
||||
// sampleList. Negative index -> empty. Pure.
|
||||
// Rect for bank-sample row `index` inside the LEFT column. Negative index -> empty.
|
||||
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index);
|
||||
|
||||
// The bank-sample row a click lands on inside the left list, or -1 outside it. Pure.
|
||||
// Bank-sample row a click lands on inside the left list, or -1 outside it.
|
||||
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y);
|
||||
|
||||
// The rectangle for zone row `index` inside the zone panel's zoneRowArea. Negative
|
||||
// index -> empty. Pure.
|
||||
// Rect for zone row `index` inside zoneRowArea. Negative index -> empty.
|
||||
Rect zoneRowRect(const KeymapEditorLayout& layout, int index);
|
||||
|
||||
// A zone row's interactive fields. The row is a horizontal strip: a label on the left,
|
||||
// then seven fixed-width mini-buttons on the right (left-to-right: low-, low+, high-, high+,
|
||||
// root-, root+, delete). kZoneNone means the click missed a control
|
||||
// (e.g. on the label) — the shell may still treat that as "select this zone".
|
||||
// A zone row's interactive fields: a label on the left, then seven fixed-width
|
||||
// mini-buttons on the right (low-, low+, high-, high+, root-, root+, delete). kZoneNone
|
||||
// means the click missed a control (e.g. the label) — the shell may still treat that as
|
||||
// "select this zone".
|
||||
enum class ZoneField {
|
||||
kZoneNone,
|
||||
kLowDown,
|
||||
@@ -122,101 +93,90 @@ enum class ZoneField {
|
||||
kDelete,
|
||||
};
|
||||
|
||||
// The result of hit-testing a click against the zone rows: which zone row (or -1) and
|
||||
// which field within it. A click on the "Add Zone" button is reported separately by
|
||||
// addZoneHitTest — this covers only the zone rows.
|
||||
// Which zone row (or -1) and which field within it a click landed on. A click on
|
||||
// "Add Zone" is reported separately by addZoneHitTest.
|
||||
struct ZoneHit {
|
||||
int zoneIndex = -1;
|
||||
ZoneField field = ZoneField::kZoneNone;
|
||||
};
|
||||
|
||||
// Classify a click at (x, y) against `zoneCount` zone rows. Returns {-1, kZoneNone} for a
|
||||
// click outside every zone row. Within a row, the seven mini-buttons occupy fixed-width
|
||||
// slots on the right edge (left-to-right: low-, low+, high-, high+, root-, root+, delete);
|
||||
// a click left of those slots is {index, kZoneNone} (the label area — "select"). Pure.
|
||||
// Classify a click at (x, y) against `zoneCount` zone rows. {-1, kZoneNone} for a miss.
|
||||
// Within a row, the seven mini-buttons occupy fixed-width slots on the right edge; a
|
||||
// click left of those slots is {index, kZoneNone} (the label area — "select").
|
||||
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y);
|
||||
|
||||
// True if (x, y) lands on the "Add Zone" button. Pure.
|
||||
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y);
|
||||
|
||||
// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ----------------------
|
||||
// --- Sample / Zone face layout ------------------------------------------------
|
||||
//
|
||||
// The capture-first editor's band/cluster/zone-surface layout math, hoisted out of the
|
||||
// reasampler_editor shell where it had accreted untestable (the §2 scope gap). Draw and
|
||||
// hit-test both derive every rect from these ONE formulas so they can never drift; the
|
||||
// shell only draws + routes. The Browse-modal layout lives in browser_scroll (its search
|
||||
// box height feeds it — dependency-clean placement beside its scroll/search siblings).
|
||||
// The capture-first editor's band/cluster/zone-surface layout math. Draw and hit-test
|
||||
// both derive every rect from these formulas so they can never drift; the shell only
|
||||
// draws + routes. The Browse-modal layout lives in browser_scroll (its search box
|
||||
// height feeds it).
|
||||
|
||||
// Shared band metrics (the shell's remaining direct uses: horizontal padding + the
|
||||
// title-band height; everything else is internal to the layout functions below).
|
||||
inline constexpr int kPad = 8;
|
||||
inline constexpr int kTitleHeight = 26;
|
||||
inline constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons
|
||||
|
||||
// The r11 Sample-face bands (top->bottom): a TITLE band (name + Browse/Zone nav), the
|
||||
// FULL-WIDTH ELASTIC HERO (absorbs all height left after the fixed bands, floor
|
||||
// kHeroMinHeight), the ROOT + PREVIEW CLUSTER, and the bottom-anchored KNOB DECK
|
||||
// (height `deckH` from the pure knob_deck wrap). When the window is too short for the
|
||||
// hero floor (below the checkSizeConstraint minimum — defensive), the hero keeps its
|
||||
// floor and the lower bands clip past the window bottom gracefully.
|
||||
// Sample-face bands (top->bottom): TITLE (name + Browse/Zone nav), a full-width elastic
|
||||
// HERO (absorbs all height left after the fixed bands, floored), the root+preview
|
||||
// CLUSTER, and the bottom-anchored knob DECK (height `deckH` from knob_deck's wrap). A
|
||||
// window shorter than the hero floor clips the lower bands past the window bottom.
|
||||
struct SampleBands {
|
||||
Rect title; // top: name + Browse/Zone nav buttons
|
||||
Rect navBrowse; // the "Browse" title-band button
|
||||
Rect navZone; // the "Zone" title-band button
|
||||
Rect hero; // the FULL-WIDTH ELASTIC hero waveform + S-VIEW-3 envelope overlay
|
||||
Rect title;
|
||||
Rect navBrowse;
|
||||
Rect navZone;
|
||||
Rect hero; // waveform + envelope overlay
|
||||
Rect cluster; // root strip + preview + vel knob + curve button + channel toggle
|
||||
Rect deck; // the bottom-anchored knob deck (height from the pure knob_deck wrap)
|
||||
Rect deck;
|
||||
};
|
||||
SampleBands computeSampleBands(int w, int h, int deckH);
|
||||
|
||||
// The r11 cluster sub-rects: the root strip keeps the left side at REMAINDER width; the
|
||||
// right side is the fixed-width right-anchored run (Preview 64 · Vel knob cell 48 · curve
|
||||
// preview button 28 · Mono|Stereo). `knobSize` is the deck knob square (knob_deck's
|
||||
// kDeckKnobSize — passed in so this module does not depend on knob_deck).
|
||||
// Cluster sub-rects: the root strip keeps the left side at remainder width; the right
|
||||
// side is the fixed-width right-anchored run (Preview · vel knob cell · curve button ·
|
||||
// Mono|Stereo). `knobSize` is the deck knob square, passed in so this module does not
|
||||
// depend on knob_deck.
|
||||
struct ClusterRects {
|
||||
Rect rootStrip; // remainder-width fenced root strip
|
||||
Rect preview; // the preview-trigger button
|
||||
Rect velCell; // the radial preview-velocity knob cell (knob + label band)
|
||||
Rect velKnob; // the knob square at the cell's top
|
||||
Rect velLabel; // the label band beneath it
|
||||
Rect curveBtn; // the mini curve-preview button (opens the popup)
|
||||
Rect rootStrip;
|
||||
Rect preview;
|
||||
Rect velCell; // preview-velocity knob cell (knob + label band)
|
||||
Rect velKnob;
|
||||
Rect velLabel;
|
||||
Rect curveBtn; // opens the curve-preview popup
|
||||
};
|
||||
ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize);
|
||||
|
||||
// The S7 mono/stereo toggle: a two-segment control right-anchored in `area`, vertically
|
||||
// centered. Returns {mono-segment, stereo-segment}, side by side.
|
||||
// Mono/stereo toggle: a two-segment control right-anchored in `area`, vertically centered.
|
||||
struct ChannelToggleRects {
|
||||
Rect mono;
|
||||
Rect stereo;
|
||||
};
|
||||
ChannelToggleRects channelToggleRects(const Rect& area);
|
||||
|
||||
// The Zone-view (S-VIEW-8) content area: the whole window below the title band.
|
||||
// Zone-view content area: the whole window below the title band.
|
||||
Rect zoneContentArea(int w, int h);
|
||||
|
||||
// The Zone/Browse "Back" title-band button (right-anchored — the same slot the Sample
|
||||
// face's Zone nav button occupies).
|
||||
// Zone/Browse "Back" button — the same slot the Sample face's Zone nav button occupies.
|
||||
Rect zoneBackRect(int w, int h);
|
||||
|
||||
// The "+ Add Zone" affordance at the top of the Zone content, and the "Delete" button
|
||||
// beside it (Delete only draws/hits when a zone is selected).
|
||||
// "+ Add Zone" affordance and the "Delete" button beside it (Delete only draws/hits
|
||||
// when a zone is selected).
|
||||
Rect zoneAddRect(const Rect& content);
|
||||
Rect zoneDeleteRect(const Rect& addR);
|
||||
|
||||
// The Zone-view keyboard strip rect: below the "+ Add Zone" affordance with a 12px gap,
|
||||
// padded kPad horizontally.
|
||||
// Zone-view keyboard strip rect: below "+ Add Zone" with a 12px gap, padded kPad
|
||||
// horizontally.
|
||||
Rect zonesStripArea(const Rect& content);
|
||||
|
||||
// The S12 numeric-entry field ROW area inside the Zones legend (a band to the right of
|
||||
// the sample label), and the rect of field `f` (0=low, 1=high, 2=root) within it —
|
||||
// three equal segments left-to-right. An out-of-range index yields an empty rect.
|
||||
// Numeric-entry field row area inside the Zones legend, and the rect of field `f`
|
||||
// (0=low, 1=high, 2=root) within it — three equal segments left-to-right. Out-of-range
|
||||
// index yields an empty rect.
|
||||
Rect noteEntryFieldsArea(const Rect& content);
|
||||
Rect noteEntryFieldRect(const Rect& fields, int f);
|
||||
|
||||
// The per-zone parameter panel below the strip + the one-line legend, running to the
|
||||
// content bottom; the FB2 knob-deck area within it (a column at the right reserved for
|
||||
// the mini curve-preview button); and that button's rect (the cluster's 28px square,
|
||||
// right-anchored at the panel top).
|
||||
// Per-zone parameter panel below the strip + legend, running to the content bottom; the
|
||||
// knob-deck area within it (a right column reserved for the curve-preview button); and
|
||||
// that button's rect (right-anchored at the panel top).
|
||||
Rect zonesControlPanel(const Rect& content);
|
||||
Rect zonesDeckArea(const Rect& content);
|
||||
Rect zonesCurveButton(const Rect& content);
|
||||
|
||||
@@ -8,17 +8,15 @@ namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
// Clamp a MIDI note to [0, kEmbedKeyCount-1].
|
||||
int clampNote(int n) {
|
||||
if (n < 0) return 0;
|
||||
if (n > kEmbedKeyCount - 1) return kEmbedKeyCount - 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Map a key boundary in [0, kEmbedKeyCount] to an x pixel inside a band of the given
|
||||
// left/width. keyEdge is a boundary (0..128), so keyEdge==128 maps to the band's right.
|
||||
// Integer math, floored — a zone's left uses floor(low) and its right uses floor(high+1),
|
||||
// which tiles adjacent zones without a seam.
|
||||
// Maps a key boundary (0..128) to an x pixel; keyEdge==128 maps to the band's right. A
|
||||
// zone's left uses floor(low) and its right uses floor(high+1), tiling adjacent zones
|
||||
// without a seam.
|
||||
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
|
||||
if (keyEdge <= 0) return bandLeft;
|
||||
if (keyEdge >= kEmbedKeyCount) return bandLeft + bandWidth;
|
||||
@@ -33,9 +31,8 @@ EmbedLayout layoutEmbed(int w, int h) {
|
||||
|
||||
EmbedLayout out;
|
||||
|
||||
// The level band takes a fixed height at the bottom, but never so much that the keymap
|
||||
// above it falls below its minimum (or that the band exceeds the area). On a very short
|
||||
// area the band yields to the keymap entirely.
|
||||
// Fixed height at the bottom, but never so much that the keymap falls below its
|
||||
// minimum; on a very short area the band yields to the keymap entirely.
|
||||
int bandH = std::min(kEmbedLevelBandHeight, ch);
|
||||
if (ch - bandH < kEmbedKeymapMinHeight) {
|
||||
bandH = std::max(0, ch - kEmbedKeymapMinHeight);
|
||||
|
||||
@@ -1,76 +1,57 @@
|
||||
// embed_strip.h — PURE layout + hit-test for the S6 embedded TCP/MCP strip. NO VST3,
|
||||
// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry /
|
||||
// mode_switch: the fiddly rectangle math for the compact inline keymap/level strip lives
|
||||
// here so it is unit-tested outside the DAW, while the embed shell (reasampler_embed.cpp)
|
||||
// marshals REAPER's embed messages (paint bitmap + mouse coords) into these functions.
|
||||
// embed_strip.h — layout + hit-test for the embedded TCP/MCP strip. Mirror of
|
||||
// editor_geometry/mode_switch; the embed shell marshals REAPER's embed messages (paint
|
||||
// bitmap + mouse coords) into these functions.
|
||||
//
|
||||
// The strip is a single compact band REAPER draws inline in the track/mixer control panel
|
||||
// (context TCP or MCP) via the Cockos embedded-UI surface. It shows:
|
||||
// * the zone layout — each performance zone as a horizontal segment across the keyboard
|
||||
// span (MIDI 0..127 mapped to the strip width), so the keymap reads at a glance; and
|
||||
// * a thin level band at the bottom — a 0..1 activity indicator the shell fills.
|
||||
// Interaction is zone SELECTION at most (S6 constraint: no new editing semantics) — a
|
||||
// click maps to the zone whose key range covers that point, or -1.
|
||||
//
|
||||
// It reuses the same Rect + contains() as editor_geometry (the strip and the editor share
|
||||
// one geometry idiom), so this header depends on editor_geometry.h rather than redefining
|
||||
// a second rectangle type.
|
||||
// A single compact band REAPER draws inline in the track/mixer control panel via the
|
||||
// Cockos embedded-UI surface: each performance zone as a horizontal segment across the
|
||||
// keyboard span (MIDI 0..127 mapped to the strip width), plus a thin activity level band
|
||||
// at the bottom. Interaction is zone selection only — no editing.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// The full MIDI key span the strip maps across its width. 128 keys (0..127); the strip's
|
||||
// horizontal axis is this range, so a zone [lowNote, highNote] becomes a sub-rectangle.
|
||||
inline constexpr int kEmbedKeyCount = 128;
|
||||
|
||||
// Fixed metrics for the strip, exposed so the shell and tests agree.
|
||||
inline constexpr int kEmbedLevelBandHeight = 4; // the bottom activity band (px)
|
||||
inline constexpr int kEmbedKeymapMinHeight = 6; // keymap area collapses no smaller
|
||||
inline constexpr int kEmbedLevelBandHeight = 4;
|
||||
inline constexpr int kEmbedKeymapMinHeight = 6;
|
||||
|
||||
// One zone rendered on the strip: its inclusive MIDI key range. This is the minimal
|
||||
// projection of a PerformanceZone the strip needs (it does not carry sample ids or PCM —
|
||||
// the shell resolves labels; the strip only lays out ranges). lowNote/highNote are
|
||||
// expected in [0,127] with low <= high, but the layout clamps defensively so a malformed
|
||||
// zone never yields an out-of-strip rect.
|
||||
// One zone rendered on the strip: its inclusive MIDI key range — the minimal projection
|
||||
// of a PerformanceZone the strip needs (no sample ids or PCM). Expected in [0,127] with
|
||||
// low <= high; layout clamps defensively regardless.
|
||||
struct EmbedZone {
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
};
|
||||
|
||||
// The strip's regions, derived from the (w x h) embed area REAPER reports. Both clamp to
|
||||
// the area so a degenerate (tiny) size never yields a region spilling outside the surface.
|
||||
// Clamped to the area so a degenerate (tiny) size never yields a region spilling outside
|
||||
// the surface.
|
||||
struct EmbedLayout {
|
||||
Rect keymap; // top: the zone-segment band (the compact keymap)
|
||||
Rect levelBand; // bottom: the thin level/activity indicator
|
||||
Rect keymap; // top: zone-segment band
|
||||
Rect levelBand; // bottom: level/activity indicator
|
||||
};
|
||||
|
||||
// Divide a (w x h) embed area into the strip's regions. Pure: same inputs -> same layout.
|
||||
// The level band takes a fixed height at the bottom (clamped so it never exceeds the area
|
||||
// or starves the keymap below kEmbedKeymapMinHeight); the keymap takes the rest. A zero or
|
||||
// negative size yields empty rects (no inversion).
|
||||
// Divide a (w x h) embed area into the strip's regions. The level band takes a fixed
|
||||
// height at the bottom (clamped so it never starves the keymap below
|
||||
// kEmbedKeymapMinHeight); the keymap takes the rest.
|
||||
EmbedLayout layoutEmbed(int w, int h);
|
||||
|
||||
// The horizontal sub-rectangle of the keymap band for a zone spanning [lowNote, highNote]
|
||||
// (inclusive). The 128-key span maps linearly across keymap.width; the returned rect
|
||||
// spans the half-open pixel range [x(lowNote), x(highNote+1)) so adjacent zones (e.g.
|
||||
// 0..59 and 60..127) tile without a gap or overlap. Notes are clamped to [0,127] and low
|
||||
// is clamped to <= high, so a malformed zone yields an in-band (possibly zero-width) rect,
|
||||
// never an inverted one. Pure.
|
||||
// Horizontal sub-rect of the keymap band for a zone spanning [lowNote, highNote]
|
||||
// (inclusive). Spans the half-open pixel range so adjacent zones tile without a gap or
|
||||
// overlap. Notes clamp to [0,127] and low clamps to <= high.
|
||||
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote);
|
||||
|
||||
// The zone a click at (x, y) lands on, given the zones in draw order, or -1 for a click
|
||||
// outside the keymap band or on a key not covered by any zone. When zones overlap on a
|
||||
// key, the FIRST covering zone in order wins — mirroring the sampler core's first-match
|
||||
// Keymap::resolve and the editor's zone order, so selection agrees with playback. Pure.
|
||||
// Zone a click at (x, y) lands on, given zones in draw order, or -1 for a miss. When
|
||||
// zones overlap on a key, the first covering zone in order wins — mirroring the sampler
|
||||
// core's first-match Keymap::resolve, so selection agrees with playback.
|
||||
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
|
||||
int y);
|
||||
|
||||
// The filled portion of the level band for a 0..1 level. Clamps level to [0,1]; the
|
||||
// returned rect is the left sub-rectangle of levelBand whose width is level * band width
|
||||
// (rounded down). level <= 0 -> empty rect; level >= 1 -> the whole band. Pure.
|
||||
// Filled portion of the level band for a 0..1 level (clamped); left sub-rect of levelBand
|
||||
// whose width is level * band width.
|
||||
Rect levelFillRect(const EmbedLayout& layout, double level);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -9,34 +9,28 @@ namespace reasampler::instrument::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
|
||||
// Seconds represented by one horizontal pixel under the overlay's linear time base. Zero when the
|
||||
// area is degenerate (the caller then produces no motion). Matches envelope_overlay::timeToX.
|
||||
// Matches envelope_overlay::timeToX. Zero when the area is degenerate (no motion).
|
||||
double secondsPerPixel(const Rect& area, double totalSeconds) {
|
||||
const int w = std::max(0, area.width);
|
||||
if (w <= 0 || totalSeconds <= 0.0) return 0.0;
|
||||
return totalSeconds / static_cast<double>(w);
|
||||
}
|
||||
|
||||
// Seconds per pixel for a GATE time-node drag (FA2): the reciprocal of the overlay's
|
||||
// param-domain gatePxPerSecond(area) scale — sample-length-free, matching
|
||||
// envelope_overlay::gatePolyline exactly so the dragged handle tracks the cursor 1:1 (each
|
||||
// node's x is affine in its own segment duration with slope gatePxPerSecond). Zero when the
|
||||
// area is degenerate.
|
||||
// Reciprocal of the overlay's gatePxPerSecond, matching gatePolyline's scale exactly so a
|
||||
// dragged handle tracks the cursor 1:1.
|
||||
double gateSecondsPerPixel(const Rect& area) {
|
||||
const double pps = gatePxPerSecond(area);
|
||||
return pps > 0.0 ? 1.0 / pps : 0.0;
|
||||
}
|
||||
|
||||
// Level (0..1) represented by one vertical pixel. levelToY spans (height-1) rows for [0,1], so one
|
||||
// pixel is 1/(height-1). Zero when degenerate. Matches envelope_overlay::levelToY.
|
||||
// Matches envelope_overlay::levelToY (spans height-1 rows for [0,1]).
|
||||
double levelPerPixel(const Rect& area) {
|
||||
const int h = std::max(0, area.height);
|
||||
if (h <= 1) return 0.0;
|
||||
return 1.0 / static_cast<double>(h - 1);
|
||||
}
|
||||
|
||||
// True for the nodes the user can grab-and-drag (Origin + ReleaseStart are draw-only anchors).
|
||||
// Origin + ReleaseStart are draw-only anchors, not grabbable.
|
||||
bool isDraggable(EnvNode n) {
|
||||
switch (n) {
|
||||
case EnvNode::Origin:
|
||||
@@ -47,10 +41,8 @@ bool isDraggable(EnvNode n) {
|
||||
}
|
||||
}
|
||||
|
||||
// True when the node belongs to the envelope's active mode. Guards the degenerate cross-mode
|
||||
// write: the degenerate baseline polyline carries a ReleaseEnd vertex regardless of mode, so a
|
||||
// zero-height Trigger-mode grab of it must not write releaseSeconds (and vice versa for Gate
|
||||
// nodes vs Trigger fields). Applied by BOTH the hit-test and the drag resolver so they agree.
|
||||
// Guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds in
|
||||
// Trigger mode (and vice versa). Applied by both the hit-test and the drag resolver.
|
||||
bool nodeInMode(EnvNode n, EnvMode m) {
|
||||
switch (n) {
|
||||
case EnvNode::AttackEnd:
|
||||
@@ -64,7 +56,7 @@ bool nodeInMode(EnvNode n, EnvMode m) {
|
||||
return m == EnvMode::Trigger;
|
||||
case EnvNode::Origin:
|
||||
case EnvNode::ReleaseStart:
|
||||
return false; // never draggable in any mode (isDraggable filters these anyway)
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -73,18 +65,15 @@ bool nodeInMode(EnvNode n, EnvMode m) {
|
||||
|
||||
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y) {
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, area, totalSeconds);
|
||||
// NEAREST draggable, mode-matching node within the pick radius wins (Chebyshev distance —
|
||||
// the square grab box); ties break to the earlier draw-order node (FA2). Gate nodes never
|
||||
// coincide (the forward map enforces kGateNodeSepPx separation), so the tie-break only
|
||||
// matters for Trigger's zero-fade-out coincidence: FadeOutStart overlays LengthEnd, WINS the
|
||||
// tie, and can be dragged inward from the right edge. The mode filter keeps the degenerate
|
||||
// baseline's ReleaseEnd vertex from registering as a grabbable node in Trigger mode.
|
||||
// Nearest draggable, mode-matching node within the pick radius wins (Chebyshev distance);
|
||||
// ties go to the earlier draw-order node. Only matters for Trigger's zero-fade-out
|
||||
// coincidence (FadeOutStart overlaps LengthEnd and wins).
|
||||
NodeHit best;
|
||||
int bestDist = kNodeGrabRadius + 1;
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (!isDraggable(v.node) || !nodeInMode(v.node, env.mode)) continue;
|
||||
const int dist = std::max(std::abs(x - v.x), std::abs(y - v.y));
|
||||
if (dist < bestDist) { // strictly closer only: earlier draw order keeps ties
|
||||
if (dist < bestDist) { // strict-less-than keeps ties at the earlier draw order
|
||||
bestDist = dist;
|
||||
best = NodeHit{true, v.node};
|
||||
}
|
||||
@@ -101,16 +90,12 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
|
||||
const double secPerPx = secondsPerPixel(area, totalSeconds);
|
||||
if (secPerPx <= 0.0) return out; // degenerate area / duration — no motion
|
||||
const double dSec = static_cast<double>(dxPixels) * secPerPx;
|
||||
// Gate time nodes use the schematic's PARAM-DOMAIN px->seconds scale (FA2) — the reciprocal
|
||||
// of the overlay's gatePxPerSecond, sample-length-free — so the dragged handle tracks the
|
||||
// cursor 1:1. gateTimedWidth >= 1 whenever the area is non-empty, so gateDSec is
|
||||
// well-defined past the degenerate guard above.
|
||||
const double gateDSec = static_cast<double>(dxPixels) * gateSecondsPerPixel(area);
|
||||
|
||||
switch (node) {
|
||||
// --- Gate: each cumulative-time node edits its OWN segment duration. Non-negative
|
||||
// durations ARE the monotonic-in-time guarantee (a node can never cross a neighbour
|
||||
// because every segment stays >= 0), so the [0, max] clamp is the whole constraint.
|
||||
// Gate: each cumulative-time node edits its own segment duration. Non-negative durations
|
||||
// ARE the monotonic-in-time guarantee (a segment can never go negative, so a node can
|
||||
// never cross a neighbour) — the [0, max] clamp is the whole constraint.
|
||||
case EnvNode::AttackEnd:
|
||||
out.attackSeconds =
|
||||
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
|
||||
@@ -119,8 +104,7 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
|
||||
out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
|
||||
break;
|
||||
case EnvNode::DecayEnd: {
|
||||
// Sustain node: X sets decay time, Y sets sustain level (drag DOWN = higher y = lower
|
||||
// level, so subtract the level delta).
|
||||
// X sets decay time, Y sets sustain level (drag down = higher y = lower level).
|
||||
out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
|
||||
const double lvlPerPx = levelPerPixel(area);
|
||||
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
|
||||
@@ -132,17 +116,9 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
|
||||
std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds);
|
||||
break;
|
||||
|
||||
// --- Trigger: fades + length are FRACTIONS. X pixels convert to a fraction of the PLAYED
|
||||
// span (fades) or the whole sample (length). Monotonic: fadeIn + fadeOut <= 1 so the
|
||||
// two fade nodes never cross (each clamps against the other), and length in [0, max].
|
||||
//
|
||||
// TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this):
|
||||
// fadeInFraction/fadeOutFraction in AmpEnvelope are fractions of the played span.
|
||||
// TriggerParams (sampler_core.h) stores the corresponding values as SOURCE FRAMES
|
||||
// (fadeInFrames/fadeOutFrames, int64_t). The shell owes a converter on BOTH directions:
|
||||
// pack (draw): fadeInFrames/fadeOutFrames -> fraction (needs frameCount + rate)
|
||||
// unpack (commit): fraction -> fadeInFrames/fadeOutFrames (same inputs)
|
||||
// See the TRIGGER SEAM note on AmpEnvelope in envelope_overlay.h for the formula.
|
||||
// Trigger: fades + length are fractions. X pixels convert to a fraction of the played
|
||||
// span (fades) or the whole sample (length). fadeIn + fadeOut <= 1 keeps the two fade
|
||||
// nodes from crossing (each clamps against the other).
|
||||
case EnvNode::FadeInEnd: {
|
||||
if (dxPixels == 0) break; // zero-motion grab: no param change, no division
|
||||
const double playSeconds = std::max(0.0, grabEnv.lengthFraction) * totalSeconds;
|
||||
|
||||
@@ -1,41 +1,18 @@
|
||||
// envelope_edit.h — PURE node hit-test + pixel-delta→clamped-param inverse map for the S-VIEW-3
|
||||
// draggable envelope nodes. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
|
||||
// of card_drag / waveform_view: the drag arithmetic + clamp/monotonic constraints live here,
|
||||
// unit-tested at the boundaries outside the DAW, while the editor shell (reasampler_editor.cpp)
|
||||
// draws the handles, captures the grab on WM_LBUTTONDOWN, feeds each move's pixel delta back
|
||||
// through here, and commits the resulting params to the zone through the same off-audio-thread
|
||||
// path a slider edit uses.
|
||||
// envelope_edit.h — node hit-test + pixel-delta -> clamped-param inverse map for the draggable
|
||||
// envelope nodes. Mirror of card_drag/waveform_view: drag arithmetic lives here, unit-tested
|
||||
// outside the DAW; the shell draws handles, captures the grab, and feeds pixel deltas back in.
|
||||
//
|
||||
// TWO SURFACES, ONE MODEL. envelope_overlay owns the params→polyline FORWARD map (draw); this
|
||||
// module owns the pixel→params INVERSE map (edit) + node hit-test. Both read/write the SAME
|
||||
// AmpEnvelope fields (the shell re-reads the zone every paint — no listener chain), so a node
|
||||
// drag and a slider edit are two views on one source of truth and can never diverge.
|
||||
// envelope_overlay owns the params->polyline forward (draw) map; this module owns the inverse
|
||||
// (edit) map + hit-test. Both read/write the same AmpEnvelope fields (shell re-reads the zone
|
||||
// every paint), so a node drag and a slider edit are two views on one source of truth.
|
||||
//
|
||||
// THE INVARIANT (S-VIEW-F2). A drag can NEVER produce a param a slider couldn't:
|
||||
// * MONOTONIC IN TIME — a node clamps between its time predecessor and successor, so attack-end
|
||||
// can't pass hold-end, decay can't pass release, etc. Each segment stays >= 0.
|
||||
// * RANGE-CLAMPED — times clamp to the SAME per-param [min,max] the slider enforces; levels
|
||||
// clamp to [0,1]. Because the concrete second/fraction maxima live SHELL-SIDE (param_slider
|
||||
// is deliberately engine-free — the shell owns the 0..1↔domain mapping), the clamp bounds are
|
||||
// CALLER-SUPPLIED here (EnvClampBounds): the shell passes the same maxima it feeds the slider,
|
||||
// so the two surfaces share one clamp by construction.
|
||||
// A drag can never produce a param a slider couldn't: nodes are monotonic in time (clamped
|
||||
// between time predecessor/successor) and range-clamped to the same per-param [min,max] the
|
||||
// slider uses (EnvClampBounds, caller-supplied since those maxima live shell-side).
|
||||
//
|
||||
// WHICH AXES. Time-only nodes (AttackEnd, HoldEnd, ReleaseEnd; FadeInEnd, FadeOutStart,
|
||||
// LengthEnd) drag on X only. The sustain node (DecayEnd) drags on BOTH axes — its X sets the
|
||||
// decay time, its Y sets the sustain level (the standard ADSR-editor grammar). Origin and the
|
||||
// drawing-only ReleaseStart vertex are NOT draggable.
|
||||
//
|
||||
// GATE DRAG SCALE (FA2). Gate time nodes convert px->seconds via the reciprocal of the
|
||||
// schematic's PARAM-DOMAIN scale (envelope_overlay's gatePxPerSecond — sample-length-free), so
|
||||
// a dragged handle tracks the cursor exactly 1:1 for stages within the schematic domain (each
|
||||
// node's x is affine in its own segment duration). Trigger nodes keep the full-canvas
|
||||
// PCM-aligned scale. Both match the forward map in envelope_overlay. A node is only editable in
|
||||
// its OWN mode: Gate nodes ignore drags while the envelope is in Trigger mode and vice versa
|
||||
// (guards the degenerate baseline's cross-mode ReleaseEnd vertex from writing releaseSeconds).
|
||||
//
|
||||
// Reuses editor_geometry's Rect + the EnvNode / AmpEnvelope / EnvMode types from
|
||||
// envelope_overlay (one shared node vocabulary across draw + edit), and the shared timeToX /
|
||||
// levelToY maps so the handle the overlay drew and the grab region here agree pixel-for-pixel.
|
||||
// Time-only nodes drag on X; DecayEnd (the sustain node) drags on both axes (X = decay time,
|
||||
// Y = sustain level). Origin and the drawing-only ReleaseStart are not draggable. A node is only
|
||||
// editable in its own mode (Gate nodes ignore drags in Trigger mode and vice versa).
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -47,60 +24,46 @@
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// The pick radius (px) around a node's drawn point: a grab within this many pixels (in BOTH x and
|
||||
// y) of a node handle grabs it. Mirrors waveform_view's kMarkerGrabWidth — wide enough to grab a
|
||||
// small handle comfortably, narrow enough that adjacent nodes stay distinguishable.
|
||||
// Pick radius (px) around a node's drawn point, in both x and y. Mirrors waveform_view's
|
||||
// kMarkerGrabWidth.
|
||||
inline constexpr int kNodeGrabRadius = 6;
|
||||
|
||||
// The per-param clamp bounds the shell supplies (the SAME maxima its sliders map 0..1 onto). All
|
||||
// are upper bounds in the param's own domain; the lower bound is 0 (each stage >= 0), and the
|
||||
// monotonic-in-time constraint tightens these further at edit time. Defaults are conservative
|
||||
// placeholders; the shell OVERRIDES them with its live slider domain so the clamp matches exactly.
|
||||
// Per-param clamp bounds the shell supplies — the same maxima its sliders map [0,1] onto.
|
||||
// Lower bound is always 0; the monotonic-in-time constraint tightens further at edit time.
|
||||
// Defaults are placeholders; the shell overrides with its live slider domain.
|
||||
struct EnvClampBounds {
|
||||
double maxAttackSeconds = 4.0; // upper bound of the attack slider
|
||||
double maxAttackSeconds = 4.0;
|
||||
double maxHoldSeconds = 4.0;
|
||||
double maxDecaySeconds = 4.0;
|
||||
double maxReleaseSeconds = 4.0;
|
||||
// Trigger fades + length are fractions; their natural upper bound is 1.0. Exposed so a shell
|
||||
// that caps a fade below the full span (e.g. 0.5) shares that cap with its slider.
|
||||
double maxFadeInFraction = 1.0;
|
||||
double maxFadeOutFraction = 1.0;
|
||||
double maxLengthFraction = 1.0;
|
||||
// sustainLevel is always [0,1] — no shell knob needed, kept implicit.
|
||||
// sustainLevel is always [0,1] — no shell knob needed.
|
||||
};
|
||||
|
||||
// Which node a grab at (x, y) lands on, given the CURRENT envelope + overlay rect + sample
|
||||
// duration (the same inputs buildEnvelopePolyline drew from, so the grab tests the drawn handles).
|
||||
// Returns EnvNode::Origin's NON-membership as a miss via the bool return: `hit` is false for a
|
||||
// point off every DRAGGABLE node. Origin and ReleaseStart are never returned (not draggable),
|
||||
// and a node from the OTHER mode is never returned (the degenerate baseline's ReleaseEnd vertex
|
||||
// is not grabbable in Trigger mode). The NEAREST node within the radius wins (Chebyshev
|
||||
// distance); an exact tie goes to the earlier draw-order node (FA2 — deterministic). Gate nodes
|
||||
// never coincide (the forward map enforces kGateNodeSepPx separation, so every Gate handle is
|
||||
// individually grabbable in every state); the tie-break matters only for Trigger's zero-fade-out
|
||||
// coincidence, where FadeOutStart overlays LengthEnd, wins the tie, and can be dragged inward
|
||||
// from the right edge. Pure.
|
||||
// Which node a grab at (x, y) lands on, given the current envelope/rect/duration (the same
|
||||
// inputs buildEnvelopePolyline drew from). `hit` is false for a point off every draggable node;
|
||||
// Origin/ReleaseStart and nodes from the other mode never hit. Nearest node within the radius
|
||||
// wins (Chebyshev distance); an exact tie goes to the earlier draw-order node — this only matters
|
||||
// for Trigger's zero-fade-out coincidence (FadeOutStart overlaps LengthEnd and wins, so the fade
|
||||
// can be dragged open from zero). Gate nodes never coincide (forward map enforces
|
||||
// kGateNodeSepPx), so every Gate handle is independently grabbable.
|
||||
struct NodeHit {
|
||||
bool hit = false;
|
||||
EnvNode node = EnvNode::Origin; // meaningful only when hit == true
|
||||
};
|
||||
NodeHit nodeAtPoint(const AmpEnvelope& env, const Rect& area, double totalSeconds, int x, int y);
|
||||
|
||||
// Resolve a drag of `node` to a new AmpEnvelope. Given the envelope AS OF GRAB TIME (`grabEnv` —
|
||||
// the shell snapshots it on WM_LBUTTONDOWN so the delta is absolute, not accumulated), the overlay
|
||||
// rect + sample duration (the pixel↔param maps), the caller's clamp bounds, and the pixel delta
|
||||
// since grab (`dxPixels`, `dyPixels`), returns the envelope the node should now describe:
|
||||
// * X delta -> the node's TIME param, shifted proportionally (same linear map as timeToX),
|
||||
// clamped to [0, per-param max] AND to its monotonic-in-time neighbours (>= predecessor time,
|
||||
// <= successor time). For a cumulative-time node the shift lands on that node's OWN segment
|
||||
// duration (e.g. dragging HoldEnd changes holdSeconds, not attack).
|
||||
// * Y delta -> the LEVEL param, but ONLY for the sustain node (DecayEnd); clamped to [0,1].
|
||||
// dyPixels is IGNORED for every time-only node.
|
||||
// * Non-draggable node (Origin / ReleaseStart), a node from the OTHER mode (a Gate node while
|
||||
// grabEnv.mode is Trigger, or vice versa), a zero-width/zero-height area, or
|
||||
// totalSeconds <= 0 -> `grabEnv` returned unchanged (no motion).
|
||||
// Only the dragged node's param(s) change; every other field carries through from `grabEnv`. Pure
|
||||
// — rounding is to the param's continuous value (no snapping, matching the sliders' resolution).
|
||||
// Resolves a drag of `node` to a new AmpEnvelope. `grabEnv` is the envelope as of grab time (the
|
||||
// shell snapshots it on button-down so the delta is absolute, not accumulated); `dxPixels`/
|
||||
// `dyPixels` is the pixel delta since grab.
|
||||
// * X delta -> the node's time param, shifted via the same linear map as timeToX, clamped to
|
||||
// [0, per-param max] and to its monotonic-in-time neighbours.
|
||||
// * Y delta -> the level param, only for DecayEnd; clamped to [0,1]. Ignored for time-only nodes.
|
||||
// * A non-draggable node, an other-mode node, a zero-size area, or totalSeconds <= 0 returns
|
||||
// `grabEnv` unchanged.
|
||||
// Only the dragged node's param(s) change. Pure.
|
||||
AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect& area,
|
||||
double totalSeconds, const EnvClampBounds& bounds,
|
||||
int dxPixels, int dyPixels);
|
||||
|
||||
@@ -8,15 +8,14 @@
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
|
||||
using util::clamp01;
|
||||
|
||||
int timeToX(const Rect& area, double totalSeconds, double t) {
|
||||
const int w = std::max(0, area.width);
|
||||
if (w <= 0 || totalSeconds <= 0.0) return area.x;
|
||||
if (t < 0.0) t = 0.0;
|
||||
// Linear map, clamped on BOTH sides (FA2 bounds invariant): t past totalSeconds pins to the
|
||||
// last in-bounds column area.right()-1. Clamp in DOUBLE space BEFORE the integer cast — a huge
|
||||
// t would overflow a 32-bit long (Windows) and wrap to the WRONG edge — then round.
|
||||
// Clamp in double space before the int cast — a huge t would overflow a 32-bit long
|
||||
// (Windows) and wrap to the wrong edge.
|
||||
double px = (t / totalSeconds) * static_cast<double>(w);
|
||||
if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1);
|
||||
return area.x + static_cast<int>(px + 0.5);
|
||||
@@ -33,9 +32,7 @@ int gateTimedWidth(const Rect& area) {
|
||||
double gatePxPerSecond(const Rect& area) {
|
||||
const int timedW = gateTimedWidth(area);
|
||||
if (timedW <= 0) return 0.0;
|
||||
// Usable width = timed region minus the four per-segment separation bases and the last
|
||||
// in-bounds column, floored at 1 px so the scale never degenerates; the domain is the four
|
||||
// stages end-to-end at their schematic maxima (param-domain scale — sample-length-free).
|
||||
// Minus the four per-segment separation bases and the last in-bounds column, floored at 1.
|
||||
const double usable =
|
||||
std::max(1.0, static_cast<double>(timedW - 1 - 4 * kGateNodeSepPx));
|
||||
return usable / (4.0 * kGateStageMaxSeconds);
|
||||
@@ -46,8 +43,8 @@ int levelToY(const Rect& area, double level) {
|
||||
if (h <= 0) return area.y;
|
||||
if (level < 0.0) level = 0.0;
|
||||
if (level > 1.0) level = 1.0;
|
||||
// Level 1 -> top row, level 0 -> bottom row (bottom-1 under the half-open convention). The
|
||||
// range spans (h-1) pixels so both endpoints land ON a drawable row.
|
||||
// Level 1 -> top row, level 0 -> bottom row; spans (h-1) px so both endpoints land on a
|
||||
// drawable row.
|
||||
const int span = h - 1;
|
||||
const long dy = static_cast<long>((1.0 - level) * static_cast<double>(span) + 0.5);
|
||||
return area.y + static_cast<int>(dy);
|
||||
@@ -64,10 +61,8 @@ EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, dou
|
||||
return v;
|
||||
}
|
||||
|
||||
// One Gate vertex from a pixel offset inside the area (the Gate schematic works in px space —
|
||||
// timed px + the fixed sustain-plateau reserve — not through the plain timeToX map). Clamps x in
|
||||
// DOUBLE space to the last in-bounds column BEFORE the integer cast (FA2 bounds invariant; a
|
||||
// huge px would overflow a 32-bit long on Windows and wrap to the WRONG edge).
|
||||
// Gate works in px space (timed px + the fixed sustain-plateau reserve) rather than the plain
|
||||
// timeToX map; clamps in double space before the int cast for the same overflow reason as above.
|
||||
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
|
||||
const int w = std::max(1, area.width);
|
||||
if (px < 0.0) px = 0.0;
|
||||
@@ -81,18 +76,16 @@ EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
|
||||
}
|
||||
|
||||
std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
|
||||
// Non-negative segment durations (a stored negative would be an upstream bug; clamp defensively).
|
||||
// Clamp defensively — a stored negative duration would be an upstream bug.
|
||||
const double a = std::max(0.0, env.attackSeconds);
|
||||
const double h = std::max(0.0, env.holdSeconds);
|
||||
const double d = std::max(0.0, env.decaySeconds);
|
||||
const double r = std::max(0.0, env.releaseSeconds);
|
||||
const double sus = clamp01(env.sustainLevel);
|
||||
|
||||
// BOUNDED SCHEMATIC (FA2): A/H/D and R map onto the TIMED region (canvas minus the reserved
|
||||
// sustain-plateau width) at the PARAM-DOMAIN scale — sample-length-free — and every segment
|
||||
// gets a kGateNodeSepPx base so consecutive nodes never coincide (every node individually
|
||||
// grabbable at any params, incl. the tier-0 zero-hold/zero-decay defaults). The sustain
|
||||
// plateau is the fixed reserve between DecayEnd and ReleaseStart.
|
||||
// A/H/D/R map onto the timed region at the param-domain scale, each segment getting a
|
||||
// kGateNodeSepPx base so nodes never coincide even at the tier-0 zero-hold/zero-decay
|
||||
// defaults. The sustain plateau is the fixed reserve between DecayEnd and ReleaseStart.
|
||||
const int W = std::max(1, area.width);
|
||||
const double sustainPx = static_cast<double>(W - gateTimedWidth(area));
|
||||
const double sep = static_cast<double>(kGateNodeSepPx);
|
||||
@@ -104,10 +97,9 @@ std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
|
||||
double xPlateau = xDecay + sustainPx; // ReleaseStart (schematic note-off)
|
||||
double xRelease = xPlateau + sep + r * pps; // ReleaseEnd
|
||||
|
||||
// Right-edge overrun (a stored stage beyond the schematic domain): compress from the RIGHT
|
||||
// preserving the minimum gaps, so trailing nodes stay individually separated instead of
|
||||
// piling on the last column. The re-floor pass only bites when the canvas is too narrow to
|
||||
// hold the minimum gaps at all — then gateVtx's [0, W-1] clamp wins (in-bounds > separation).
|
||||
// Overrun beyond the schematic domain compresses from the right, preserving minimum gaps so
|
||||
// trailing nodes stay separated instead of piling on the last column. This re-floor only
|
||||
// bites when the canvas is too narrow to hold the gaps at all — gateVtx's clamp wins then.
|
||||
const double xMax = static_cast<double>(W - 1);
|
||||
if (xRelease > xMax) {
|
||||
xRelease = xMax;
|
||||
@@ -135,12 +127,11 @@ std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
|
||||
|
||||
std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
|
||||
double totalSeconds) {
|
||||
// The played span is lengthFraction of the whole sample; fades are fractions OF that span.
|
||||
// Played span is lengthFraction of the whole sample; fades are fractions of that span.
|
||||
const double len = clamp01(env.lengthFraction);
|
||||
double fadeIn = clamp01(env.fadeInFraction);
|
||||
double fadeOut = clamp01(env.fadeOutFraction);
|
||||
// Fades cannot overlap: clamp so fadeIn + fadeOut <= 1 (of the played span), mirroring the
|
||||
// engine's TriggerParams clamp. Trim the LATER fade (fade-out) first, matching the engine.
|
||||
// Fades cannot overlap; trim fade-out first, matching the engine's TriggerParams clamp.
|
||||
if (fadeIn + fadeOut > 1.0) fadeOut = std::max(0.0, 1.0 - fadeIn);
|
||||
|
||||
const double playSeconds = len * totalSeconds;
|
||||
@@ -161,12 +152,10 @@ std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
|
||||
double totalSeconds) {
|
||||
if (area.width <= 0 || area.height <= 0 || totalSeconds <= 0.0) {
|
||||
// Degenerate surface: a two-point flat baseline at level 0 so the shell always has a line.
|
||||
// Degenerate surface: flat two-point baseline so the shell always has a line.
|
||||
return {vtx(EnvNode::Origin, area, 1.0, 0.0, 0.0),
|
||||
vtx(EnvNode::ReleaseEnd, area, 1.0, 1.0, 0.0)};
|
||||
}
|
||||
// Gate is a param-domain schematic — totalSeconds only gates the degenerate branch above
|
||||
// (no loaded duration -> baseline); Trigger is PCM-aligned and consumes it.
|
||||
return env.mode == EnvMode::Gate ? gatePolyline(env, area)
|
||||
: triggerPolyline(env, area, totalSeconds);
|
||||
}
|
||||
|
||||
@@ -1,59 +1,7 @@
|
||||
// envelope_overlay.h — PURE amp-envelope → polyline geometry for the S-VIEW-3 Sample-view
|
||||
// envelope overlay. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of
|
||||
// waveform_view / param_slider: the params→pixel polyline math lives here, unit-tested outside
|
||||
// the DAW, while the editor shell (reasampler_editor.cpp) traces the polyline in an accent hue
|
||||
// and draws the node handles (via envelope_edit's hit-test).
|
||||
//
|
||||
// WHAT IT DRAWS. The amp envelope over the Sample view's hero waveform (Simpler / Phase-Plant
|
||||
// grammar):
|
||||
// * Gate -> the AHDSR shape: attack ramp 0->1, hold plateau at 1, decay 1->sustain,
|
||||
// sustain plateau, release sustain->0. Since there is no held note-off to draw
|
||||
// against, Gate is a BOUNDED SCHEMATIC (FA2): a fixed fraction of the canvas
|
||||
// width (kGateSustainDisplayFraction) is RESERVED for the sustain plateau, and
|
||||
// the remaining "timed" width carries A/H/D AND the release at the PARAM-DOMAIN
|
||||
// scale — the timed width represents 4 x kGateStageMaxSeconds (the four stage
|
||||
// sliders end-to-end at their maxima), NOT the sample's duration, so the layout
|
||||
// is identical for a 0.3s and a 10s capture. Each segment additionally gets a
|
||||
// kGateNodeSepPx pixel base, so consecutive nodes NEVER coincide: every Gate
|
||||
// node is individually grabbable at ANY param values, including the tier-0
|
||||
// defaults (hold 0 / decay 0). A -> (H) -> D -> S-plateau -> R all render INSIDE
|
||||
// the canvas and the release is a visible, draggable segment.
|
||||
// * Trigger -> the fade/%-length shape: fade-in 0->1, unity plateau, fade-out 1->0 anchored
|
||||
// to playEnd (= lengthFraction of the post-start span). Trigger keeps the
|
||||
// waveform's exact time base so the shape lines up with the PCM under it.
|
||||
// The horizontal axis is TIME (Gate: schematic, see above; Trigger: wall-clock across the rect);
|
||||
// the vertical axis is LEVEL (0 at rect bottom, 1 at rect top).
|
||||
//
|
||||
// BOUNDS INVARIANT (FA2). EVERY vertex of EVERY polyline is clamped inside the canvas:
|
||||
// x in [area.x, area.right()-1], y in [area.y, area.bottom()-1] (half-open rect convention).
|
||||
// No node and no drawn segment ever exceeds the canvas — paint-time clipping of handles is no
|
||||
// longer needed (and never fires) in the shell.
|
||||
//
|
||||
// FA2 CONTRACT CHANGE — WAVE B SHELL AUTHOR, READ THIS:
|
||||
// * The EnvNode enum is UNCHANGED (same node set, same draggable set — Origin + ReleaseStart
|
||||
// remain the only non-draggable anchors).
|
||||
// * ALL vertices are now in-bounds (see above). The shell's previous "skip handle when
|
||||
// v.x >= waveArea.right()" clip is dead code: ReleaseEnd (Gate) and FadeOutStart/LengthEnd
|
||||
// (Trigger, at full length / zero fade-out) now land at area.right()-1 and MUST get handles.
|
||||
// * Gate's x-axis is SCHEMATIC, not PCM-aligned: the timed region is scaled to the param
|
||||
// domain (4 x kGateStageMaxSeconds), the sustain reserve is a fixed width, and every
|
||||
// segment carries a kGateNodeSepPx pixel base. The Gate curve does NOT line up with the
|
||||
// waveform under it — do not label it as if it did. Trigger's x-axis IS still PCM-aligned.
|
||||
// * Gate nodes never coincide (min-separation, above), so every Gate handle is individually
|
||||
// grabbable in every state. nodeAtPoint (envelope_edit) resolves to the NEAREST node within
|
||||
// the grab radius with a draw-order tie-break; the tie-break only matters for the one
|
||||
// remaining coincidence, Trigger's zero-fade-out (FadeOutStart overlays LengthEnd at the
|
||||
// right edge and wins the tie, so the fade can be dragged open from zero).
|
||||
//
|
||||
// DELIBERATELY ENGINE-FREE (house pattern — param_slider does the same). It does NOT depend on
|
||||
// sample_map / sampler_core (which would drag bank_book / wav_codec in). The shell reads the
|
||||
// zone's AdsrSeconds / TriggerParams and packs them into the small AmpEnvelope view struct here.
|
||||
// AHDSR times are wall-clock SECONDS (rate-free, matching the stored domain — Daniel's no-
|
||||
// hardcoded-rate ruling); Trigger fades are FRACTIONS of the play span. The one rate-bound input
|
||||
// is the total sample duration in seconds, which the shell resolves once from the live rate and
|
||||
// the frame count and passes in — this module never sees a sample rate.
|
||||
//
|
||||
// It reuses editor_geometry's Rect + contains(), the one shared geometry idiom.
|
||||
// envelope_overlay.h — amp-envelope -> polyline geometry for the Sample-view envelope overlay.
|
||||
// Engine-free by design (no sample_map/sampler_core dependency); mirror of waveform_view /
|
||||
// param_slider. The shell packs the zone's AdsrSeconds/TriggerParams into AmpEnvelope and draws
|
||||
// the polyline plus a handle at each node (envelope_edit does the hit-test).
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -64,166 +12,96 @@
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// The play mode the overlay draws — a LOCAL mirror of sampler_core's PlayMode kept here so the
|
||||
// geometry module stays engine-free (the shell maps the zone's PlayMode to this). Same two cases.
|
||||
// Local mirror of sampler_core's PlayMode, kept here so this module stays engine-free.
|
||||
enum class EnvMode { Gate, Trigger };
|
||||
|
||||
// Which breakpoint a polyline vertex / node is. The shell draws a draggable handle at each of
|
||||
// these; envelope_edit hit-tests against them. Kept in one enum shared by overlay + edit so the
|
||||
// forward map (draw) and inverse map (edit) name the same nodes.
|
||||
//
|
||||
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(=sustain corner) -> ReleaseStart
|
||||
// -> ReleaseEnd. The sustain node is DecayEnd (its Y is the sustain level);
|
||||
// ReleaseStart is a drawing-only plateau-end vertex (the schematic note-off);
|
||||
// release is edited by dragging ReleaseEnd.
|
||||
// Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd, level 0). The fade-out
|
||||
// ramp is the FadeOutStart->LengthEnd segment; LengthEnd is the playEnd terminal.
|
||||
// Gate nodes: Origin -> AttackEnd -> HoldEnd -> DecayEnd(sustain) -> ReleaseStart -> ReleaseEnd.
|
||||
// Trigger nodes: Origin -> FadeInEnd -> FadeOutStart -> LengthEnd(playEnd).
|
||||
// Shared by envelope_overlay (forward/draw map) and envelope_edit (inverse/edit map).
|
||||
enum class EnvNode {
|
||||
Origin, // t=0, level 0 (both modes) — not draggable (fixed anchor)
|
||||
AttackEnd, // Gate: top of the attack ramp (level 1) — X sets attackSeconds
|
||||
HoldEnd, // Gate: end of the hold plateau (level 1) — X sets holdSeconds
|
||||
DecayEnd, // Gate: decay settles to sustain — the SUSTAIN node (X sets decaySeconds,
|
||||
// Y sets sustainLevel)
|
||||
ReleaseStart, // Gate: end of the sustain plateau / start of the release (sustain level) —
|
||||
// a DRAWING vertex only, not a draggable handle (release is edited at
|
||||
// ReleaseEnd; this vertex sits a fixed sustain-plateau width right of
|
||||
// DecayEnd — the schematic note-off — Y = sustain level)
|
||||
ReleaseEnd, // Gate: end of the release tail (level 0) — X sets releaseSeconds
|
||||
FadeInEnd, // Trigger: top of the fade-in (level 1) — X sets fadeInFraction
|
||||
FadeOutStart, // Trigger: end of the unity plateau / start of the fade-out (level 1) —
|
||||
// X sets fadeOutFraction
|
||||
LengthEnd, // Trigger: the playEnd terminal / %-length (level 0) — X sets lengthFraction
|
||||
Origin, // t=0, level 0 — not draggable
|
||||
AttackEnd, // Gate: attack ramp top — sets attackSeconds
|
||||
HoldEnd, // Gate: hold plateau end — sets holdSeconds
|
||||
DecayEnd, // Gate: decay settles to sustain — sets decaySeconds (X) and sustainLevel (Y)
|
||||
ReleaseStart, // Gate: sustain plateau end — drawing-only, not draggable
|
||||
ReleaseEnd, // Gate: release tail end — sets releaseSeconds
|
||||
FadeInEnd, // Trigger: fade-in top — sets fadeInFraction
|
||||
FadeOutStart, // Trigger: fade-out start — sets fadeOutFraction
|
||||
LengthEnd, // Trigger: playEnd terminal — sets lengthFraction
|
||||
};
|
||||
|
||||
// The amp-envelope params the overlay draws — the small view struct the shell packs from the
|
||||
// zone's stored AdsrSeconds / TriggerParams. Engine-free by design (no sampler_core include).
|
||||
//
|
||||
// Gate fields (SECONDS, wall-clock): attack / hold / decay / release; sustain is a LEVEL 0..1.
|
||||
// These map 1-to-1 with the stored AdsrSeconds fields — no conversion required.
|
||||
//
|
||||
// Trigger fields (FRACTIONS of play): fadeIn / fadeOut as a fraction of the played span;
|
||||
// lengthFraction is the played span as a fraction of the
|
||||
// post-start sample length (matching TriggerParams).
|
||||
//
|
||||
// TRIGGER SEAM — CONVERSION REQUIRED ON BOTH PATHS (Wave 2 shell author, read this):
|
||||
// TriggerParams (sampler_core.h) stores Trigger fades as SOURCE FRAMES:
|
||||
// fadeInFrames (int64_t) — 0->1 ramp length in source frames
|
||||
// fadeOutFrames (int64_t) — 1->0 ramp length in source frames
|
||||
// AmpEnvelope stores them as FRACTIONS of the played span:
|
||||
// fadeInFraction = fadeInFrames / playLengthFrames
|
||||
// fadeOutFraction = fadeOutFrames / playLengthFrames
|
||||
// where playLengthFrames = round(lengthFraction * (frameCount - startFrame)).
|
||||
// This is a NON-TRIVIAL derived view — NOT a direct field copy. The shell owes a
|
||||
// converter on BOTH directions:
|
||||
// PACK (draw): frames -> fraction (TriggerParams -> AmpEnvelope, needs frameCount + rate)
|
||||
// UNPACK (commit): fraction -> frames (AmpEnvelope -> TriggerParams, same inputs)
|
||||
// lengthFraction maps 1-to-1 with TriggerParams::lengthFraction and needs no conversion.
|
||||
//
|
||||
// Unused fields for the active mode are ignored.
|
||||
// Amp-envelope params the overlay draws. Trigger's fadeIn/fadeOutFraction are derived from
|
||||
// TriggerParams' frame counts, not a direct field copy — see the trigger_seam gotcha in
|
||||
// core/instrument/CLAUDE.md.
|
||||
struct AmpEnvelope {
|
||||
EnvMode mode = EnvMode::Gate;
|
||||
|
||||
// Gate (AHDSR), seconds + a dimensionless sustain level.
|
||||
// Gate (AHDSR): seconds, plus a dimensionless sustain level.
|
||||
double attackSeconds = 0.003;
|
||||
double holdSeconds = 0.0;
|
||||
double decaySeconds = 0.0;
|
||||
double sustainLevel = 1.0;
|
||||
double releaseSeconds = 0.060;
|
||||
|
||||
// Trigger, fractions of the play span (fadeIn/fadeOut) and of the post-start length.
|
||||
// NOTE: fadeInFraction/fadeOutFraction are DERIVED from TriggerParams::fadeInFrames/
|
||||
// fadeOutFrames — see the TRIGGER SEAM note above. A converter is owed on both the
|
||||
// pack (draw) and unpack (commit) paths; these fields are NOT a direct TriggerParams copy.
|
||||
double lengthFraction = 1.0; // (0,1] of the post-start span that plays (1-to-1 with TriggerParams)
|
||||
double fadeInFraction = 0.0; // 0->1 ramp as a fraction of the played span (DERIVED — see above)
|
||||
double fadeOutFraction = 0.0; // 1->0 ramp as a fraction of the played span (DERIVED — see above)
|
||||
// Trigger: fractions of the played span.
|
||||
double lengthFraction = 1.0;
|
||||
double fadeInFraction = 0.0;
|
||||
double fadeOutFraction = 0.0;
|
||||
};
|
||||
|
||||
// One polyline vertex: a pixel point plus which node it is. The shell draws a line through the
|
||||
// points in order (the amp curve) and a draggable handle at each vertex whose node is not Origin.
|
||||
// Level is carried alongside (0..1) for callers that want to label/inspect; it is redundant with y.
|
||||
// One polyline vertex: pixel point plus which node it is. level is redundant with y, carried for
|
||||
// inspection.
|
||||
struct EnvVertex {
|
||||
EnvNode node = EnvNode::Origin;
|
||||
int x = 0; // pixel x inside the overlay rect
|
||||
int y = 0; // pixel y inside the overlay rect (top = level 1, bottom = level 0)
|
||||
double level = 0.0; // 0..1, the vertex's amplitude (redundant with y; for inspection)
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
double level = 0.0;
|
||||
|
||||
bool operator==(const EnvVertex& o) const {
|
||||
return node == o.node && x == o.x && y == o.y && level == o.level;
|
||||
}
|
||||
};
|
||||
|
||||
// The fraction of the canvas width RESERVED for the Gate sustain-plateau display (FA2). The
|
||||
// plateau is a fixed-width schematic region between DecayEnd and ReleaseStart; the remaining
|
||||
// width is the "timed" region A/H/D/R map onto at the schematic param-domain scale. One
|
||||
// constant shared by the forward map (here) and the inverse map (envelope_edit).
|
||||
// Fraction of canvas width reserved for the Gate sustain-plateau display; the remaining width
|
||||
// carries A/H/D/R at the param-domain scale. Shared with envelope_edit.
|
||||
inline constexpr double kGateSustainDisplayFraction = 0.15;
|
||||
|
||||
// The minimum pixel separation between consecutive Gate polyline nodes: every Gate segment gets
|
||||
// this many px as a base, PLUS its time-proportional extent, so zero-duration stages (tier-0
|
||||
// defaults: hold 0, decay 0) still render as distinct, individually grabbable handles. Chosen
|
||||
// larger than envelope_edit's kNodeGrabRadius (6) so a click dead-on a node can never tie with
|
||||
// its neighbour. Shared by the forward map and the drag inverse.
|
||||
// Minimum pixel separation between consecutive Gate nodes, so zero-duration stages (tier-0
|
||||
// defaults) still render as distinct, grabbable handles. Larger than envelope_edit's grab
|
||||
// radius (6) so a click can never tie between neighbours.
|
||||
inline constexpr int kGateNodeSepPx = 8;
|
||||
|
||||
// The Gate schematic's per-stage time domain (seconds): the timed region represents the four
|
||||
// stages end-to-end at this maximum each (4 x this total). MIRRORS the shell's stage-slider
|
||||
// ceiling (kEnvTimeMaxSeconds in reasampler_editor.cpp) — keep the two equal so a stage at its
|
||||
// slider max lands exactly at the canvas edge. Drag safety does NOT depend on this constant
|
||||
// (param clamps are caller-supplied in envelope_edit); only layout does.
|
||||
// Gate schematic's per-stage time domain (seconds) — the timed region represents four stages
|
||||
// end-to-end at this max each. Must match the shell's stage-slider ceiling so a maxed slider
|
||||
// lands exactly at the canvas edge.
|
||||
inline constexpr double kGateStageMaxSeconds = 2.0;
|
||||
|
||||
// The pixel width of the Gate timed region: area.width minus the sustain-plateau reserve,
|
||||
// floored at 1 px so the px<->seconds scale never degenerates for a non-empty area. Returns 0
|
||||
// for a zero/negative-width area. Shared by gatePolyline and envelope_edit's gate drag scale.
|
||||
// Pixel width of the Gate timed region (area width minus the sustain reserve), floored at 1 for
|
||||
// a non-empty area; 0 for a zero/negative-width area.
|
||||
int gateTimedWidth(const Rect& area);
|
||||
|
||||
// Pixels per second of the Gate timed region under the PARAM-DOMAIN scale: the timed width,
|
||||
// minus the four per-segment kGateNodeSepPx bases and the last in-bounds column, spread over
|
||||
// 4 x kGateStageMaxSeconds. Independent of the sample's duration. Returns 0 for a
|
||||
// zero/negative-width area; otherwise > 0 (the usable width floors at 1 px). The ONE px<->sec
|
||||
// scale shared by the forward map (gatePolyline) and the drag inverse (envelope_edit), so a
|
||||
// dragged handle tracks the cursor 1:1.
|
||||
// Pixels per second of the Gate timed region, independent of the sample's actual duration.
|
||||
// Shared by buildEnvelopePolyline and envelope_edit's drag inverse so a dragged handle tracks
|
||||
// the cursor 1:1.
|
||||
double gatePxPerSecond(const Rect& area);
|
||||
|
||||
// Map an amp envelope to its polyline vertices inside `area`, over a sample of `totalSeconds`
|
||||
// wall-clock duration. `area` is the waveform rect (left/top inclusive, right/bottom exclusive);
|
||||
// y maps level 0..1 across [area.bottom()-1 .. area.y] (level 1 at the TOP). The polyline reads
|
||||
// left-to-right in draw order, Origin first.
|
||||
// Maps an amp envelope to polyline vertices inside `area` over a sample of `totalSeconds`
|
||||
// duration. y maps level [0,1] across [area.bottom()-1, area.y] (level 1 at the top); vertices
|
||||
// are in draw order, Origin first.
|
||||
//
|
||||
// TIME BASE (FA2).
|
||||
// * Gate: a bounded schematic, INDEPENDENT of totalSeconds. The canvas splits into a TIMED
|
||||
// region of gateTimedWidth(area) px — where attack/hold/decay run from t=0 and the release
|
||||
// ramp runs after the plateau, at the gatePxPerSecond(area) PARAM-DOMAIN scale, each segment
|
||||
// carrying a kGateNodeSepPx base so consecutive nodes never coincide — plus a FIXED sustain
|
||||
// plateau of (width - timedWidth) px between DecayEnd and ReleaseStart (the schematic
|
||||
// note-off). Stages beyond the schematic domain (a stored stage > kGateStageMaxSeconds)
|
||||
// compress from the RIGHT preserving the minimum gaps, so trailing nodes stay individually
|
||||
// separated instead of piling on the last column; only a canvas too narrow to hold the
|
||||
// minimum gaps at all sacrifices separation (in-bounds wins).
|
||||
// * Trigger: the waveform's exact time base (PCM-aligned). The played span is
|
||||
// lengthFraction * totalSeconds; fade-in/out are fractions OF that played span. Nodes past
|
||||
// the played span never appear (FadeOutStart/LengthEnd sit at the played span's right edge).
|
||||
//
|
||||
// BOUNDS: every vertex is inside the canvas — x in [area.x, area.right()-1], y in
|
||||
// [area.y, area.bottom()-1]. Nothing maps past area.right() (the pre-FA2 release tail is gone). A
|
||||
// degenerate area (zero width/height) or totalSeconds <= 0 yields the two-point flat baseline
|
||||
// [Origin, end at level 0] so the shell always has a drawable line. Pure — same inputs, same
|
||||
// polyline.
|
||||
// Gate's x-axis is a bounded schematic independent of totalSeconds (does NOT line up with the
|
||||
// waveform under it); Trigger's x-axis is PCM-aligned wall-clock. Every vertex is clamped inside
|
||||
// the canvas: x in [area.x, area.right()-1], y in [area.y, area.bottom()-1]. A degenerate area
|
||||
// or totalSeconds <= 0 yields the flat two-point baseline [Origin, end at level 0].
|
||||
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
|
||||
double totalSeconds);
|
||||
|
||||
// Map a time (seconds) to a pixel x inside `area`: t=0 -> area.x, t=totalSeconds ->
|
||||
// area.right()-1, linear, CLAMPED on both sides (t < 0 pins to area.x; t past totalSeconds pins
|
||||
// to area.right()-1 — the in-bounds invariant, FA2). A zero-width area or totalSeconds <= 0 yields
|
||||
// area.x. Pure — the shared time->x map the Trigger polyline and the node hit-test
|
||||
// (envelope_edit) use, so the drawn handle and its grab region agree.
|
||||
// Maps a time (seconds) to a pixel x inside `area`, linear and clamped at both ends. Shared
|
||||
// with envelope_edit's node hit-test so the drawn handle and its grab region agree.
|
||||
int timeToX(const Rect& area, double totalSeconds, double t);
|
||||
|
||||
// Map a level (0..1) to a pixel y inside `area`: level 1 -> area.y, level 0 -> area.bottom()-1
|
||||
// (so the full-amplitude line sits at the top edge and silence at the bottom pixel row). level is
|
||||
// clamped to [0,1]. A zero-height area yields area.y. Pure — the shared level->y map the polyline
|
||||
// and the node hit-test share.
|
||||
// Maps a level [0,1] to a pixel y inside `area` (level 1 at the top, 0 at the bottom row),
|
||||
// clamped. Shared with envelope_edit's node hit-test.
|
||||
int levelToY(const Rect& area, double level);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -14,10 +14,8 @@ int clampNote(int n) {
|
||||
return n;
|
||||
}
|
||||
|
||||
// Map a key BOUNDARY in [0, kStripKeyCount] to an x pixel inside a band of the given
|
||||
// left/width. keyEdge is a boundary (0..128): 0 -> band left, 128 -> band right. Integer
|
||||
// math, floored — key N's left is keyEdgeToX(N) and its right is keyEdgeToX(N+1), tiling
|
||||
// adjacent keys/zones without a seam (mirror of embed_strip::keyEdgeToX).
|
||||
// Maps a key boundary (0..128) to an x pixel. Key N's left is keyEdgeToX(N), right is
|
||||
// keyEdgeToX(N+1) — tiles adjacent keys/zones without a seam. Mirrors embed_strip::keyEdgeToX.
|
||||
int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) {
|
||||
if (keyEdge <= 0) return bandLeft;
|
||||
if (keyEdge >= kStripKeyCount) return bandLeft + bandWidth;
|
||||
@@ -37,8 +35,7 @@ StripLayout layoutStrip(int w, int h) {
|
||||
int keyLeftX(const StripLayout& layout, int note) {
|
||||
const Rect& band = layout.keys;
|
||||
const int bandWidth = std::max(0, band.width);
|
||||
// note is a KEY here (0..127); its left edge is boundary `note`. Callers pass note+1 to
|
||||
// get a key's right edge, and 128 maps to the band right.
|
||||
// note is a key (0..127); callers pass note+1 to get its right edge, 128 -> band right.
|
||||
const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note);
|
||||
return keyEdgeToX(band.x, bandWidth, edge);
|
||||
}
|
||||
@@ -59,8 +56,7 @@ int keyAtPoint(const StripLayout& layout, int x, int y) {
|
||||
if (!contains(band, x, y)) return -1;
|
||||
const int bandWidth = std::max(0, band.width);
|
||||
if (bandWidth <= 0) return -1;
|
||||
// Invert keyEdgeToX: the key whose half-open [leftX, rightX) contains x. Floor-divide
|
||||
// the pixel offset back to a key; clamp defensively (a point on band.right()-1 maps to 127).
|
||||
// Inverts keyEdgeToX: the key whose half-open [leftX, rightX) contains x.
|
||||
const int offset = x - band.x;
|
||||
int note = (offset * kStripKeyCount) / bandWidth;
|
||||
return clampNote(note);
|
||||
@@ -69,7 +65,7 @@ int keyAtPoint(const StripLayout& layout, int x, int y) {
|
||||
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) {
|
||||
int lo = clampNote(lowNote);
|
||||
int hi = clampNote(highNote);
|
||||
if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts
|
||||
if (lo > hi) lo = hi; // malformed zone collapses rather than inverts
|
||||
const int leftX = keyLeftX(layout, lo);
|
||||
const int rightX = keyLeftX(layout, hi + 1);
|
||||
return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom());
|
||||
@@ -80,8 +76,7 @@ ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x,
|
||||
if (!contains(bar, x, y)) return ZoneGrab::kNone;
|
||||
|
||||
const int barW = bar.width;
|
||||
// A narrow bar (< 2*edge) has no body: split at the midpoint, LOW edge wins the tie so
|
||||
// a click exactly on the midpoint resizes low (deterministic).
|
||||
// A narrow bar has no body: split at the midpoint, low edge wins the tie.
|
||||
if (barW < 2 * kStripEdgeGrabWidth) {
|
||||
const int mid = bar.x + barW / 2;
|
||||
return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge;
|
||||
@@ -103,11 +98,7 @@ ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int*
|
||||
}
|
||||
|
||||
bool isNaturalKey(int note) {
|
||||
// Clamp to the valid MIDI range before indexing.
|
||||
const int n = note < 0 ? 0 : (note > kStripKeyCount - 1 ? kStripKeyCount - 1 : note);
|
||||
// The 12-semitone pattern of natural (white) keys within an octave, starting at C:
|
||||
// positions 0(C) 2(D) 4(E) 5(F) 7(G) 9(A) 11(B) are natural;
|
||||
// positions 1(C#) 3(D#) 6(F#) 8(G#) 10(A#) are accidental.
|
||||
static constexpr bool kNatural[12] = {
|
||||
true, // 0 C
|
||||
false, // 1 C#
|
||||
@@ -129,13 +120,8 @@ int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) {
|
||||
if (dxPixels == 0) return clampNote(startNote);
|
||||
const int bandWidth = std::max(0, layout.keys.width);
|
||||
if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion
|
||||
// Proportional shift: same linear mapping as keyAtPoint/keyEdgeToX so click and drag
|
||||
// agree across the full strip, even on non-divisible-by-128 widths. The proportional
|
||||
// key width is (bandWidth / kStripKeyCount) in exact rational arithmetic; rounding to
|
||||
// the nearest key (half-key drag flips at the key centre) is achieved by adding
|
||||
// bandWidth/2 to the absolute pixel delta before dividing — identical to the old
|
||||
// formula except keyWidth is now derived from the same linear map (exact rational)
|
||||
// rather than the truncated-integer bandWidth/128 that caused drift at the far end.
|
||||
// Same linear mapping as keyAtPoint/keyEdgeToX (exact rational), not a truncated-integer
|
||||
// bandWidth/128 key width — that drifted at the far end of the strip.
|
||||
const int half = bandWidth / 2;
|
||||
int shift;
|
||||
if (dxPixels > 0) {
|
||||
|
||||
@@ -1,106 +1,70 @@
|
||||
// keyboard_strip.h — PURE layout + hit-test + drag math for the S10 capture-first
|
||||
// editor's keyboard strip. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
|
||||
// The mirror of editor_geometry / embed_strip / mode_switch: the fiddly rectangle +
|
||||
// note-mapping arithmetic lives here so it is unit-tested outside the DAW, while the
|
||||
// editor shell (reasampler_editor.cpp) draws the strip and marshals mouse events into
|
||||
// these functions.
|
||||
// keyboard_strip.h — layout + hit-test + drag math for the capture-first editor's
|
||||
// keyboard strip. Mirror of editor_geometry/embed_strip/mode_switch; the shell draws
|
||||
// and marshals mouse events into these functions.
|
||||
//
|
||||
// The strip maps the full 128-key MIDI span across a horizontal band (the same key-span
|
||||
// idiom embed_strip uses). It serves TWO faces of the S10 editor:
|
||||
// * the SINGLE-CAPTURE fast path (default): one loaded capture with a ROOT MARKER on
|
||||
// the strip, click-a-key (or drag the marker) sets the capture's root note; and
|
||||
// * the opt-in ZONES panel (S10-Z, demoted): each performance zone drawn as a bar over
|
||||
// the keys it covers, with edge-grab resize handles + a body move-handle so a drag
|
||||
// sets low/high (edges) or moves the span (body), and a key-click sets the zone root.
|
||||
//
|
||||
// All interaction resolves through the pure DRAG-DELTA resolver here: the shell captures
|
||||
// a grab on WM_LBUTTONDOWN, feeds each WM_MOUSEMOVE's pixel delta back through
|
||||
// resolveDragNote, and commits the resolved note(s) on WM_LBUTTONUP. Live feedback is the
|
||||
// shell re-drawing the in-flight note; one coherent edit lands on release.
|
||||
//
|
||||
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom),
|
||||
// so this header depends on editor_geometry.h rather than redefining a rectangle type.
|
||||
// The strip maps the full 128-key MIDI span across a horizontal band (the same idiom
|
||||
// embed_strip uses) and serves two faces: the single-capture fast path (a root marker,
|
||||
// click-a-key or drag it to set root) and the opt-in zones panel (each zone drawn as a
|
||||
// bar with edge-grab resize handles + a body move-handle).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// The full MIDI key span the strip maps across its width: 128 keys (0..127). Named
|
||||
// distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips stay
|
||||
// independent — the editor strip may grow octave labels/metrics the embed strip never does.
|
||||
// Named distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips
|
||||
// stay independent.
|
||||
inline constexpr int kStripKeyCount = 128;
|
||||
|
||||
// The width (px) of an edge-grab hit region at each end of a zone bar: a drag started
|
||||
// within this many pixels of the bar's left/right edge resizes that edge; a drag started
|
||||
// anywhere else on the bar moves the whole span. A zone narrower than 2*this has no body
|
||||
// move-handle (both edges win their halves) — deliberate: a 1-key zone is all edges.
|
||||
// Pixel width of a zone bar's edge-grab region. A zone narrower than 2x this has no
|
||||
// body move-handle (both edges win their halves).
|
||||
inline constexpr int kStripEdgeGrabWidth = 6;
|
||||
|
||||
// The strip's regions, derived from the (w x h) band the shell allots it. The keys band
|
||||
// takes the whole area today (a future octave-label lane can carve a sub-band here without
|
||||
// changing callers). Clamped so a degenerate (tiny/zero) size never yields an inverted rect.
|
||||
// The keys band takes the whole strip area today; clamped so a degenerate size never
|
||||
// yields an inverted rect.
|
||||
struct StripLayout {
|
||||
Rect keys; // the key band: the 128-key span maps linearly across keys.width
|
||||
Rect keys;
|
||||
};
|
||||
|
||||
// Divide a (w x h) strip area into its regions. Pure: same inputs -> same layout. A zero or
|
||||
// negative size yields empty rects (no inversion).
|
||||
// Divide a (w x h) strip area into its regions. Pure.
|
||||
StripLayout layoutStrip(int w, int h);
|
||||
|
||||
// The x pixel (inside the keys band) of the LEFT edge of key `note` (0..127). The 128-key
|
||||
// span maps linearly across keys.width; key N occupies the half-open pixel range
|
||||
// [keyLeftX(N), keyLeftX(N+1)). Notes are clamped to [0,127]; note==128 maps to the band's
|
||||
// right edge (so a key's right edge is keyLeftX(note+1)). Pure.
|
||||
// x pixel of the LEFT edge of key `note` (0..127) under the linear 128-key map; key N
|
||||
// occupies [keyLeftX(N), keyLeftX(N+1)). note==128 maps to the band's right edge.
|
||||
int keyLeftX(const StripLayout& layout, int note);
|
||||
|
||||
// The half-open pixel rect of a single key `note` (0..127): [keyLeftX(note),
|
||||
// keyLeftX(note+1)) horizontally, the full keys-band height. A malformed (out-of-range)
|
||||
// note clamps to [0,127]. Pure.
|
||||
// Half-open rect of a single key `note`, clamped to [0,127].
|
||||
Rect keyRect(const StripLayout& layout, int note);
|
||||
|
||||
// The rect of the ROOT MARKER for the single-capture fast path: the key cell of `rootNote`,
|
||||
// drawn as a highlighted key. Equivalent to keyRect(layout, rootNote) — a named entry point
|
||||
// so the shell's intent (this is the root marker, not just any key) reads at the call site,
|
||||
// and so a future marker shape (a triangle over the key) has one place to change. Pure.
|
||||
// Root-marker rect for the single-capture fast path; equivalent to
|
||||
// keyRect(layout, rootNote) but named so the intent reads at the call site.
|
||||
Rect rootMarkerRect(const StripLayout& layout, int rootNote);
|
||||
|
||||
// The MIDI note a point (x, y) lands on, or -1 for a point outside the keys band. Backs
|
||||
// click-to-set-root (single capture) and click-a-key-sets-zone-root (zones). Pure.
|
||||
// MIDI note a point (x, y) lands on, or -1 outside the keys band.
|
||||
int keyAtPoint(const StripLayout& layout, int x, int y);
|
||||
|
||||
// The horizontal sub-rect of the keys band for a zone spanning [lowNote, highNote]
|
||||
// (inclusive): [keyLeftX(low), keyLeftX(high+1)) horizontally, the full band height. Notes
|
||||
// clamp to [0,127] and low clamps to <= high, so a malformed zone yields an in-band
|
||||
// (possibly zero-width) rect, never an inverted one. Mirrors embed_strip::zoneSegmentRect.
|
||||
// Pure.
|
||||
// Horizontal sub-rect for a zone spanning [lowNote, highNote] inclusive. Notes clamp to
|
||||
// [0,127] and low clamps to <= high, so a malformed zone never yields an inverted rect.
|
||||
Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote);
|
||||
|
||||
// Which part of a zone bar a grab landed on. The shell uses this to decide what a drag
|
||||
// edits: an edge resizes that boundary; the body moves the whole span; none means the grab
|
||||
// missed the bar entirely (the shell may treat that as a key-click to set the root, or as a
|
||||
// deselect).
|
||||
// Which part of a zone bar a grab landed on: an edge resizes that boundary, the body
|
||||
// moves the whole span, kNone means the grab missed the bar.
|
||||
enum class ZoneGrab {
|
||||
kNone, // the point is not on this zone's bar
|
||||
kLowEdge, // within kStripEdgeGrabWidth of the bar's LEFT edge -> resize low
|
||||
kHighEdge, // within kStripEdgeGrabWidth of the bar's RIGHT edge -> resize high
|
||||
kBody, // on the bar but not an edge -> move the whole span
|
||||
kNone,
|
||||
kLowEdge,
|
||||
kHighEdge,
|
||||
kBody,
|
||||
};
|
||||
|
||||
// Classify a grab at (x, y) against ONE zone's bar (low..high). Returns kNone when the
|
||||
// point is off the bar (or off the keys band). On the bar: kLowEdge/kHighEdge when within
|
||||
// kStripEdgeGrabWidth of that edge, else kBody. A narrow bar (< 2*kStripEdgeGrabWidth)
|
||||
// resolves the near half to each edge (no body). The LOW edge wins a tie at the exact
|
||||
// midpoint of a narrow bar (deterministic). Pure.
|
||||
// Classify a grab at (x, y) against one zone's bar. A narrow bar (< 2*kStripEdgeGrabWidth)
|
||||
// resolves the near half to each edge (no body); the low edge wins a tie at the exact
|
||||
// midpoint.
|
||||
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y);
|
||||
|
||||
// The zone (index into `lows`/`highs`, draw order) whose bar a grab at (x, y) lands on,
|
||||
// plus which part of it, or {-1, kNone} for a point off every bar. First covering zone in
|
||||
// draw order wins (first-match, mirroring the core's Keymap::resolve + embed_strip). The
|
||||
// arrays are parallel (lows[i]/highs[i] is zone i's inclusive range); `count` is their
|
||||
// length. Pure — no host containers at the boundary (a raw pointer pair, like
|
||||
// embed_strip::zoneAtPoint).
|
||||
// Zone (index into the parallel `lows`/`highs` arrays, draw order) whose bar a grab
|
||||
// lands on, plus which part, or {-1, kNone} for a miss. First covering zone in draw
|
||||
// order wins.
|
||||
struct ZoneBarHit {
|
||||
int zoneIndex = -1;
|
||||
ZoneGrab grab = ZoneGrab::kNone;
|
||||
@@ -108,24 +72,13 @@ struct ZoneBarHit {
|
||||
ZoneBarHit zoneBarAtPoint(const StripLayout& layout, const int* lows, const int* highs,
|
||||
int count, int x, int y);
|
||||
|
||||
// Resolve a drag to a new MIDI note. Given the note the grabbed field held at grab time
|
||||
// (`startNote`) and the horizontal pixel delta since grab (`dxPixels`), returns the note
|
||||
// the field should now hold: startNote shifted by round(dxPixels / keyWidth), clamped to
|
||||
// [0,127]. keyWidth is derived from the layout (band width / 128); a zero-width band pins
|
||||
// the result to startNote (no motion). This is the single arithmetic behind edge-resize,
|
||||
// body-move (apply to both edges with the SAME delta so the span is preserved), and
|
||||
// root-marker drag. Pure — rounding is to the nearest key so a half-key drag flips at the
|
||||
// key centre. Returns startNote unchanged for dxPixels==0.
|
||||
// Resolves a drag to a new MIDI note: `startNote` shifted by round(dxPixels / keyWidth),
|
||||
// clamped to [0,127]. The one arithmetic behind edge-resize, body-move (apply to both
|
||||
// edges with the same delta to preserve span), and root-marker drag.
|
||||
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels);
|
||||
|
||||
// Returns true when `note` (0..127) is a NATURAL (white) key in standard 12-tone equal
|
||||
// temperament; false when it is an ACCIDENTAL (black) key. Notes out of the [0,127]
|
||||
// range are clamped to [0,127] before classification (i.e. this never throws/UBs on a
|
||||
// bad input). The 12 semitone positions within an octave:
|
||||
// Natural (white): 0(C) 2(D) 4(E) 5(F) 7(G) 9(A) 11(B)
|
||||
// Accidental (black): 1(C#) 3(D#) 6(F#) 8(G#) 10(A#)
|
||||
// Used by the shell to overlay the two-tone bright/dark piano-key pattern over the
|
||||
// pastel spectral fill (S-VIEW-7). Pure — no layout required, no host types.
|
||||
// True when `note` (clamped to [0,127]) is a natural (white) key in 12-tone equal
|
||||
// temperament; false for an accidental (black) key.
|
||||
bool isNaturalKey(int note);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -37,7 +37,7 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
|
||||
const int innerLeft = box.x + kDeckGroupPadX;
|
||||
const int innerRight = box.right() - kDeckGroupPadX;
|
||||
|
||||
// Caption row: text left, compact toggle right-anchored (r11 — the not-full-width home).
|
||||
// Caption row: text left, compact toggle right-anchored.
|
||||
out.caption = Rect::ltrb(innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH);
|
||||
if (g.captionToggle.id >= 0) {
|
||||
const int segW = g.captionToggle.segWidth;
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
// knob_deck.h — PURE knob-deck layout + hit-test for the r11 Sample-face recomposition
|
||||
// (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary, and — like
|
||||
// param_slider — NO engine types: cells and toggles carry opaque shell-owned control ids.
|
||||
// The mirror of action_bar / param_slider: the fiddly group-box / caption-row / cell-grid
|
||||
// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws each
|
||||
// group (fence, caption, compact toggles, knobs) through the L1 kit and routes clicks/drags
|
||||
// via the hit-test. The KNOB PRIMITIVE itself (value<->needle-angle, vertical drag) is
|
||||
// param_slider's (FA4); a knob cell here is just a rect — the shell composes the two.
|
||||
// knob_deck.h — knob-deck layout + hit-test for the Sample-face knob deck. Engine-free
|
||||
// like param_slider: cells and toggles carry opaque shell-owned control ids. Mirror of
|
||||
// action_bar/param_slider; the knob primitive itself (value<->needle-angle, drag) is
|
||||
// param_slider's — a knob cell here is just a rect the shell composes it into.
|
||||
//
|
||||
// THE DECK (CONTEXT.md §S-VIEW r11). A horizontal run of FENCED GROUPS, left -> right, each
|
||||
// a hairline-bordered bg/panel box with a CAPTION ROW (micro-caps caption left; the group's
|
||||
// compact mode toggle right-anchored IN the caption row — this is where the not-full-width
|
||||
// toggles live) over a KNOB ROW of fixed 48x58 cells (28px knob centered, 12px label band
|
||||
// beneath). A group may additionally place one 18px-tall two-segment toggle IN the knob row
|
||||
// after its cells (the VOICE group's Retrig|Legato — same Mono/Stereo segment grammar,
|
||||
// vertically centered). Groups that must keep stable geometry across a mode flip reserve
|
||||
// blank cells (id -1): the AMP ENVELOPE group always spans 5 cells so Gate<->Trigger never
|
||||
// reflows its neighbours.
|
||||
// The deck is a horizontal run of fenced groups, left->right, each a bordered box with a
|
||||
// caption row (caption left, the group's compact mode toggle right-anchored) over a knob
|
||||
// row of fixed cells (knob centered, label band beneath). A group may also place one
|
||||
// two-segment toggle in the knob row after its cells. Groups that must keep stable
|
||||
// geometry across a mode flip reserve blank cells (id -1) so a mode flip never reflows
|
||||
// neighbouring groups.
|
||||
//
|
||||
// WRAP (deterministic): groups place left-to-right with kDeckGroupGap between; a group that
|
||||
// does not fit the remaining width starts a new deck row (whole groups only, never split).
|
||||
// The first group of a row always places even if wider than the row (degenerate width).
|
||||
// deckHeight() exposes the resulting height so the shell can bottom-anchor the deck band and
|
||||
// give the ELASTIC HERO the rest (r11 band order).
|
||||
// Wrap is deterministic: groups place left-to-right with kDeckGroupGap between; a group
|
||||
// that does not fit the remaining width starts a new row (whole groups only, never
|
||||
// split); the first group of a row always places even if wider than the row.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -31,7 +22,7 @@
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// Fixed deck metrics (spec r11), exposed so the shell and tests agree.
|
||||
// Fixed deck metrics, exposed so the shell and tests agree.
|
||||
inline constexpr int kDeckCellW = 48; // one knob cell
|
||||
inline constexpr int kDeckCellH = 58;
|
||||
inline constexpr int kDeckKnobSize = 28; // knob diameter inside the cell
|
||||
@@ -55,9 +46,8 @@ struct DeckToggleDesc {
|
||||
};
|
||||
|
||||
// One fenced group, in deck order. `cellIds` are the knob cells left-to-right; an id of -1
|
||||
// is a RESERVED BLANK cell (geometry held, never hit — the AMP ENVELOPE Trigger face).
|
||||
// `captionWidth` is the px the shell reserves for the caption text (this module does not
|
||||
// measure text — the house constant-metrics pattern).
|
||||
// is a reserved blank cell (geometry held, never hit). `captionWidth` is the px the shell
|
||||
// reserves for the caption text (this module does not measure text).
|
||||
struct DeckGroupDesc {
|
||||
int id = 0; // shell group id (opaque here)
|
||||
int captionWidth = 60;
|
||||
@@ -96,21 +86,20 @@ struct DeckLayout {
|
||||
int height = 0; // rowCount * kDeckGroupH + (rowCount-1) * kDeckRowGap; 0 for no groups
|
||||
};
|
||||
|
||||
// The width of one group box: the wider of its caption row (caption + gap + toggle) and its
|
||||
// knob row (cells + gap + row toggle), plus the horizontal padding. Pure.
|
||||
// Width of one group box: the wider of its caption row (caption + gap + toggle) and its
|
||||
// knob row (cells + gap + row toggle), plus horizontal padding.
|
||||
int deckGroupWidth(const DeckGroupDesc& g);
|
||||
|
||||
// The number of deck rows the groups occupy at `availWidth` under the greedy whole-group
|
||||
// wrap (a group that does not fit the remaining row width starts a new row; the first group
|
||||
// of a row always places). 0 for an empty group list. Pure — the wrap is deterministic.
|
||||
// Number of deck rows the groups occupy at `availWidth` under the greedy whole-group wrap.
|
||||
// 0 for an empty list.
|
||||
int deckRowCount(const std::vector<DeckGroupDesc>& groups, int availWidth);
|
||||
|
||||
// The total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). 0 for an
|
||||
// empty list. The shell bottom-anchors a band of exactly this height. Pure.
|
||||
// Total deck height at `availWidth` (rows * kDeckGroupH + inter-row gaps). The shell
|
||||
// bottom-anchors a band of exactly this height.
|
||||
int deckHeight(const std::vector<DeckGroupDesc>& groups, int availWidth);
|
||||
|
||||
// Lay the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's rule.
|
||||
// Every rect is absolute. Pure — same inputs, same layout.
|
||||
// Lays the groups out from (left, top) within `availWidth`, wrapping per deckRowCount's
|
||||
// rule. Every rect is absolute.
|
||||
DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int top,
|
||||
int availWidth);
|
||||
|
||||
@@ -124,10 +113,9 @@ struct DeckHit {
|
||||
int segment = -1; // 0/1 for a toggle hit; -1 otherwise
|
||||
};
|
||||
|
||||
// The deck element a point lands on: a knob CELL (the whole 48x58 cell — friendlier than the
|
||||
// bare knob circle; the shell anchors the vertical drag wherever the grab lands), a caption-
|
||||
// toggle segment, or a row-toggle segment. Blank cells (id -1) and everything else miss.
|
||||
// Pure — the shell's routing entry point.
|
||||
// The deck element a point lands on: a knob cell (the whole cell, not just the knob
|
||||
// circle — the shell anchors the vertical drag wherever the grab lands), a caption-toggle
|
||||
// segment, or a row-toggle segment. Blank cells (id -1) and everything else miss.
|
||||
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// param_slider.cpp — see param_slider.h. PURE control-surface geometry for the S12/S15/S16
|
||||
// editor parameter panel. No host types; only the shared Rect + contains().
|
||||
// param_slider.cpp — see param_slider.h. Pure control-surface geometry; no host types.
|
||||
|
||||
#include "core/instrument/ui/param_slider.h"
|
||||
|
||||
@@ -10,7 +9,7 @@
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
|
||||
using util::clamp01;
|
||||
|
||||
std::vector<ControlRow> layoutControls(const Rect& panel,
|
||||
const std::vector<ControlDesc>& controls) {
|
||||
@@ -83,7 +82,7 @@ double valueAtPoint(const Rect& control, int x) {
|
||||
return static_cast<double>(x - track.x) / static_cast<double>(span);
|
||||
}
|
||||
|
||||
// --- Radial knob (Wave A FA4) ---------------------------------------------------------
|
||||
// --- Radial knob -----------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
|
||||
@@ -1,180 +1,141 @@
|
||||
// param_slider.h — PURE control-surface layout + hit-test + value<->pixel mapping for the
|
||||
// S12/S15/S16 editor parameter panel. NO VST3, NO REAPER, NO SWELL/LICE types at the
|
||||
// boundary, and — deliberately — NO sampler_core / sample_map engine types either. The
|
||||
// mirror of keyboard_strip / waveform_view / mode_switch: the fiddly slider-track and
|
||||
// toggle-segment arithmetic lives here, unit-tested outside the DAW, while the editor shell
|
||||
// draws each row (label + track/segments + handle) and routes clicks/drags into these
|
||||
// functions, owning the control-id -> engine-param binding + the value DOMAIN mapping.
|
||||
// param_slider.h — control-surface layout + hit-test + value<->pixel mapping for the
|
||||
// editor parameter panel. Engine-free by design (no sampler_core/sample_map). Mirror of
|
||||
// keyboard_strip/waveform_view/mode_switch; the shell draws each row and routes
|
||||
// clicks/drags into these functions, owning the control-id -> engine-param binding and
|
||||
// the value domain mapping.
|
||||
//
|
||||
// WHY IT EXISTS (S12 + the S15/S16 control surfaces deferred here). The setup / Zones surface
|
||||
// grows a stack of parameter controls: the S15 play-mode toggle (Gate|Trigger), the AHDSR
|
||||
// amp-envelope sliders (attack/hold/decay/sustain/release), the Trigger %-length + fade
|
||||
// controls, the S16 Varispeed|Preserve engine toggle, and the AD pitch-envelope
|
||||
// enable/attack/decay/depth. They are three shapes — a two-segment TOGGLE, a horizontal
|
||||
// SLIDER, and (Wave A FA4) a radial KNOB with a needle indicator and vertical-drag value
|
||||
// mapping — laid out as a vertical stack of fixed-height rows. This module lays out that
|
||||
// stack and maps a control's NORMALIZED value (0..1) to/from its handle pixel / needle
|
||||
// angle; the shell converts each control's engine value (frames, seconds, a fraction, a
|
||||
// signed semitone depth) to/from that 0..1 with its own domain knowledge (this module stays
|
||||
// engine-free so it tests without the audio core).
|
||||
//
|
||||
// It reuses editor_geometry's Rect + contains() (one shared geometry idiom).
|
||||
// Controls are one of three shapes — a two-segment Toggle, a horizontal Slider, or a
|
||||
// radial Knob with a needle and vertical-drag mapping — laid out as a vertical stack of
|
||||
// fixed-height rows. This module maps a control's normalized value (0..1) to/from its
|
||||
// handle pixel / needle angle; the shell converts each control's engine value (frames,
|
||||
// seconds, a fraction, a signed semitone depth) to/from that 0..1.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
// Fixed control-panel metrics, exposed so the shell and tests agree.
|
||||
inline constexpr int kControlRowHeight = 22; // one control row (incl. its inter-row gap)
|
||||
inline constexpr int kControlRowGap = 4; // vertical gap below each row
|
||||
inline constexpr int kControlLabelWidth = 92; // the label column at the row's left
|
||||
inline constexpr int kSliderHandleWidth = 8; // the draggable slider handle width (px)
|
||||
inline constexpr int kToggleSegments = 2; // a toggle is always two segments
|
||||
inline constexpr int kControlRowHeight = 22;
|
||||
inline constexpr int kControlRowGap = 4;
|
||||
inline constexpr int kControlLabelWidth = 92;
|
||||
inline constexpr int kSliderHandleWidth = 8;
|
||||
inline constexpr int kToggleSegments = 2;
|
||||
|
||||
// A control is one of three shapes. Toggle = a two-segment selector (the active segment
|
||||
// highlights); Slider = a horizontal track with a draggable handle over a 0..1 value;
|
||||
// Knob = a radial dial with a needle indicator over a 0..1 value, dragged VERTICALLY
|
||||
// (up = increase).
|
||||
// Toggle = two-segment selector (active segment highlights); Slider = horizontal track
|
||||
// with a draggable handle over a 0..1 value; Knob = radial dial with a needle, dragged
|
||||
// vertically (up = increase).
|
||||
enum class ControlKind { Toggle, Slider, Knob };
|
||||
|
||||
// One control the shell places in the panel, in stack order. `id` is the shell's own control
|
||||
// identifier (an int the shell casts from its ControlId enum) returned by the hit-test so the
|
||||
// shell routes the interaction to the right engine param — this module never interprets it.
|
||||
// One control the shell places in the panel, in stack order. `id` is the shell's own
|
||||
// control identifier, returned by the hit-test so the shell routes to the right engine
|
||||
// param — this module never interprets it.
|
||||
struct ControlDesc {
|
||||
int id = 0;
|
||||
ControlKind kind = ControlKind::Slider;
|
||||
};
|
||||
|
||||
// The laid-out geometry of one control row: its full row rect plus the interactive sub-rect
|
||||
// (the track for a Slider, the whole control area for a Toggle — the shell splits a Toggle
|
||||
// into segments via toggleSegmentRect). `index` is the control's position in the stack.
|
||||
// Laid-out geometry of one control row: full row rect plus the interactive sub-rect (the
|
||||
// track for a Slider, the whole control area for a Toggle — the shell splits a Toggle
|
||||
// into segments via toggleSegmentRect).
|
||||
struct ControlRow {
|
||||
int id = 0;
|
||||
ControlKind kind = ControlKind::Slider;
|
||||
Rect row; // the full row (label column + control column)
|
||||
Rect label; // the label column at the left
|
||||
Rect control; // the control column to the right of the label (track / toggle area)
|
||||
Rect row;
|
||||
Rect label;
|
||||
Rect control;
|
||||
};
|
||||
|
||||
// Lay out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down. Each
|
||||
// row is kControlRowHeight tall with kControlRowGap below it; the label column takes the left
|
||||
// kControlLabelWidth (clamped so it never exceeds the panel), the control column the rest. A
|
||||
// row whose top falls past the panel bottom is still returned (the shell clips at paint /
|
||||
// suppresses it) so the stack geometry is deterministic regardless of panel height. An empty
|
||||
// control list or a degenerate panel yields an empty vector. Pure.
|
||||
// Lays out `controls` as a vertical stack of fixed-height rows inside `panel`, top-down.
|
||||
// Label column takes the left kControlLabelWidth (clamped to the panel), control column
|
||||
// the rest. A row past the panel bottom is still returned (the shell clips/suppresses it)
|
||||
// so stack geometry is deterministic regardless of panel height.
|
||||
std::vector<ControlRow> layoutControls(const Rect& panel,
|
||||
const std::vector<ControlDesc>& controls);
|
||||
|
||||
// The rect of segment `seg` (0..kToggleSegments-1) within a toggle control's `control` rect,
|
||||
// splitting it into kToggleSegments equal segments left-to-right (the last absorbs any width
|
||||
// remainder, mirror of mode_switch's segment split). An out-of-range segment or a degenerate
|
||||
// control rect yields an empty rect. Pure.
|
||||
// Rect of segment `seg` within a toggle's `control` rect, splitting it into
|
||||
// kToggleSegments equal segments left-to-right (last absorbs any width remainder).
|
||||
Rect toggleSegmentRect(const Rect& control, int seg);
|
||||
|
||||
// The toggle segment a point lands on within a toggle control's `control` rect, or -1 for a
|
||||
// miss (outside the control area). Pure.
|
||||
int toggleSegmentHitTest(const Rect& control, int x, int y);
|
||||
|
||||
// The slider track sub-rect inside a slider control's `control` rect: the control inset so the
|
||||
// handle (kSliderHandleWidth) stays fully within the control at value 0 and 1 (a half-handle
|
||||
// margin at each end). The handle CENTER ranges across [track.x, track.right()] as the value
|
||||
// ranges [0,1]. The shell draws the track fill + handle here. A degenerate control yields an
|
||||
// empty rect. Pure.
|
||||
// Slider track sub-rect inside `control`: inset so the handle stays fully within the
|
||||
// control at value 0 and 1. The handle center ranges across [track.x, track.right()] as
|
||||
// the value ranges [0,1].
|
||||
Rect sliderTrackRect(const Rect& control);
|
||||
|
||||
// The handle rect for a slider at normalized `value` (clamped to [0,1]) within `control`: a
|
||||
// kSliderHandleWidth-wide bar centered at the value's position along sliderTrackRect. A
|
||||
// degenerate control yields an empty rect. Pure — the inverse of valueAtPoint.
|
||||
// Handle rect for a slider at normalized `value` (clamped to [0,1]).
|
||||
Rect sliderHandleRect(const Rect& control, double value);
|
||||
|
||||
// Map a point x to a normalized slider value [0,1] within `control` (the handle-center range).
|
||||
// x at/left of the track start -> 0; at/right of the end -> 1; linear between. A degenerate
|
||||
// track (zero movable span) -> 0. Pure — the inverse of sliderHandleRect's position map; the
|
||||
// shell converts the returned 0..1 into its engine domain (frames/seconds/fraction/semitones).
|
||||
// Maps a point x to a normalized slider value [0,1]: at/left of track start -> 0, at/right
|
||||
// of end -> 1, linear between. Inverse of sliderHandleRect's position map.
|
||||
double valueAtPoint(const Rect& control, int x);
|
||||
|
||||
// --- Radial knob (Wave A FA4) --------------------------------------------------------------
|
||||
// --- Radial knob ---------------------------------------------------------------------
|
||||
//
|
||||
// Angle convention: DEGREES CLOCKWISE FROM 12 O'CLOCK, matching a clock face in screen
|
||||
// coordinates (y grows downward): 0 = 12 o'clock (up), 90 = 3 o'clock (right), 180 = 6
|
||||
// o'clock (down), 270 = 9 o'clock (left). The value arc sweeps CLOCKWISE from startDeg
|
||||
// (value 0) to endDeg (value 1); an endDeg at-or-behind startDeg wraps +360, so equal
|
||||
// angles mean a full 360° sweep.
|
||||
// Angle convention: degrees clockwise from 12 o'clock (screen coords, y grows downward).
|
||||
// The value arc sweeps clockwise from startDeg (value 0) to endDeg (value 1); an endDeg
|
||||
// at-or-behind startDeg wraps +360.
|
||||
//
|
||||
// The DEFAULT arc is the conventional 7→5 o'clock layout: min at 7 o'clock (210°) sweeping
|
||||
// clockwise 300° around to max at 5 o'clock (150°), leaving a symmetric 60° dead arc at the
|
||||
// bottom. The 50% (midpoint) value lands at 12 o'clock (0°/360°) — straight up. The angles
|
||||
// are PARAMETERS, not hardcoded — the shell sets the final sweep when the parallel layout
|
||||
// spec lands.
|
||||
inline constexpr double kKnobArcStartDeg = 210.0; // value 0 — 7 o'clock
|
||||
inline constexpr double kKnobArcEndDeg = 150.0; // value 1 — 5 o'clock (clockwise wrap)
|
||||
// Default arc: 7 o'clock (210°) sweeping clockwise 300° to 5 o'clock (150°), leaving a
|
||||
// symmetric 60° dead arc at the bottom; the 50% value lands at 12 o'clock. Angles are
|
||||
// parameters, not hardcoded.
|
||||
inline constexpr double kKnobArcStartDeg = 210.0;
|
||||
inline constexpr double kKnobArcEndDeg = 150.0;
|
||||
|
||||
// Default vertical-drag sensitivity: pixels of upward drag for one full 0->1 sweep.
|
||||
// Pixels of upward drag for one full 0->1 sweep.
|
||||
inline constexpr int kKnobDragRangePixels = 128;
|
||||
|
||||
// The configurable value arc of a knob. Defaults to the 7->5 o'clock reading above.
|
||||
struct KnobArc {
|
||||
double startDeg = kKnobArcStartDeg;
|
||||
double endDeg = kKnobArcEndDeg;
|
||||
};
|
||||
|
||||
// A knob's circle within its control cell: center + radius in pixel space (doubles so the
|
||||
// shell rounds once, at draw time). radius == 0 marks a degenerate cell.
|
||||
// A knob's circle within its control cell: center + radius (doubles so the shell rounds
|
||||
// once, at draw time). radius == 0 marks a degenerate cell.
|
||||
struct KnobGeometry {
|
||||
double centerX = 0.0;
|
||||
double centerY = 0.0;
|
||||
double radius = 0.0;
|
||||
};
|
||||
|
||||
// A pixel-space point (the needle endpoint the shell draws to).
|
||||
struct KnobPoint {
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
};
|
||||
|
||||
// The knob circle inscribed in `cell`, centered, radius = half the smaller dimension. A
|
||||
// degenerate cell yields radius 0. CONTRACT: the shell MUST pass `row.control` (the full
|
||||
// control column) both when drawing and when hit-testing — `controlAtPoint` always uses
|
||||
// `r.control` as the cell, so the draw cell and hit cell must be the same. If the shell
|
||||
// wants to draw a smaller circle it must center it within `row.control` and accept that the
|
||||
// hit area is the larger column-inscribed circle. Pure.
|
||||
// Knob circle inscribed in `cell`, centered, radius = half the smaller dimension. The
|
||||
// shell must pass `row.control` both when drawing and hit-testing — controlAtPoint always
|
||||
// uses `r.control` as the cell, so draw cell and hit cell must agree.
|
||||
KnobGeometry computeKnob(const Rect& cell);
|
||||
|
||||
// True if (x, y) falls strictly inside the knob circle (boundary exclusive, matching the
|
||||
// module's half-open Rect convention). A degenerate knob (radius <= 0) hits nothing. Pure.
|
||||
// True if (x, y) falls strictly inside the knob circle (boundary exclusive).
|
||||
bool knobHitTest(const KnobGeometry& knob, int x, int y);
|
||||
|
||||
// The clockwise sweep of `arc` in degrees, in (0, 360]: normalized end - start, wrapping
|
||||
// +360 when the end is at-or-behind the start (default arc -> 300). Pure.
|
||||
// Clockwise sweep of `arc` in degrees, in (0, 360]: normalized end - start, wrapping +360
|
||||
// when the end is at-or-behind the start (default arc -> 300).
|
||||
double knobSweepDeg(const KnobArc& arc);
|
||||
|
||||
// The needle angle for normalized `value` (clamped to [0,1]): startDeg at 0, endDeg at 1,
|
||||
// linear between, returned normalized to [0, 360). Pure.
|
||||
// Needle angle for normalized `value` (clamped to [0,1]): startDeg at 0, endDeg at 1,
|
||||
// linear between, normalized to [0, 360).
|
||||
double knobValueAngleDeg(const KnobArc& arc, double value);
|
||||
|
||||
// The needle endpoint for normalized `value`: the point on the knob circle at the value's
|
||||
// angle, from the center. The shell draws the needle from (centerX, centerY) to this point
|
||||
// (or lerps toward the center for a shorter needle). Pure.
|
||||
// Needle endpoint for normalized `value`: the point on the knob circle at the value's
|
||||
// angle, from the center.
|
||||
KnobPoint knobNeedlePoint(const KnobGeometry& knob, const KnobArc& arc, double value);
|
||||
|
||||
// Map a vertical drag onto a knob value: `startValue` is the value at drag start (clamped),
|
||||
// `dyPixels` the pointer's y displacement in screen coordinates (down = positive). Dragging
|
||||
// UP increases, DOWN decreases; `dragRangePixels` pixels of travel covers the full 0..1
|
||||
// range. Result clamps to [0,1]; a non-positive drag range yields the clamped start value.
|
||||
// Pure — the inverse map for the knob's drag interaction.
|
||||
// Maps a vertical drag onto a knob value: `startValue` is the value at drag start,
|
||||
// `dyPixels` the pointer's y displacement (down = positive). Up increases, down
|
||||
// decreases; `dragRangePixels` pixels of travel covers the full 0..1 range.
|
||||
double knobDragValue(double startValue, int dyPixels,
|
||||
int dragRangePixels = kKnobDragRangePixels);
|
||||
|
||||
// The control a point lands on, given the laid-out `rows`. Returns the control id (ControlDesc
|
||||
// id) whose interactive area (a Slider's track, a Toggle's whole control area, a Knob's
|
||||
// circle) contains the point, or -1 for a miss (a gap, the label column, or outside every
|
||||
// row). The FIRST matching row wins (rows never overlap, so at most one matches). Pure — the
|
||||
// shell's routing entry point: on a hit it reads the value (valueAtPoint /
|
||||
// toggleSegmentHitTest / knobDragValue over the ensuing drag) and commits.
|
||||
// Control a point lands on, given laid-out `rows`. Returns the control id whose
|
||||
// interactive area contains the point, or -1 for a miss. First matching row wins (rows
|
||||
// never overlap).
|
||||
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y);
|
||||
|
||||
} // namespace reasampler::instrument::ui
|
||||
|
||||
@@ -21,8 +21,7 @@ int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame) {
|
||||
const int w = std::max(0, area.width);
|
||||
if (frameCount <= 0 || w <= 0) return area.x;
|
||||
const std::int64_t f = clampFrame(frame, frameCount);
|
||||
// Linear map: x = left + round(f * w / frameCount). Rounding keeps the marker line
|
||||
// visually centered on its frame; the divide is exact rational (multiply first).
|
||||
// x = left + round(f * w / frameCount); multiply before divide to keep this exact.
|
||||
const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2;
|
||||
return area.x + static_cast<int>(num / frameCount);
|
||||
}
|
||||
@@ -33,8 +32,7 @@ std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x) {
|
||||
if (x <= area.x) return 0;
|
||||
if (x >= area.right()) return frameCount;
|
||||
const std::int64_t dx = static_cast<std::int64_t>(x - area.x);
|
||||
// Inverse of frameToX: frame = round(dx * frameCount / w). Round so click and marker draw
|
||||
// agree at bin granularity.
|
||||
// Inverse of frameToX: frame = round(dx * frameCount / w).
|
||||
const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2;
|
||||
return clampFrame(num / static_cast<std::int64_t>(w), frameCount);
|
||||
}
|
||||
|
||||
@@ -1,84 +1,52 @@
|
||||
// waveform_view.h — PURE waveform/marker geometry + zero-crossing snap for the S11
|
||||
// waveform surface. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
|
||||
// of keyboard_strip / editor_geometry: the fiddly frame<->pixel + marker hit-test + snap
|
||||
// arithmetic lives here, unit-tested outside the DAW, while the editor shell
|
||||
// (reasampler_editor.cpp) draws the envelope + markers and marshals mouse events into it.
|
||||
// waveform_view.h — waveform/marker geometry + zero-crossing snap. Mirror of keyboard_strip/
|
||||
// editor_geometry: frame<->pixel + marker hit-test + snap arithmetic lives here, unit-tested
|
||||
// outside the DAW; the shell draws and marshals mouse events into it.
|
||||
//
|
||||
// The surface maps a sample's full frame span [0, frameCount] linearly across a horizontal
|
||||
// waveform rect. Draggable MARKERS mark frames of interest (S11: start point, loop start,
|
||||
// loop end). The marker set is GENERIC — N named markers with drag + snap — deliberately
|
||||
// not three hardcoded specials, so S15 (Trigger/Gate) can repurpose this same surface with a
|
||||
// different marker set (start + %-length end + fades) without reworking the machinery.
|
||||
//
|
||||
// Interaction resolves through the pure DRAG-DELTA resolver here: the shell captures a grab
|
||||
// on WM_LBUTTONDOWN (markerAtPoint identifies the grabbed marker), feeds each WM_MOUSEMOVE's
|
||||
// pixel delta back through resolveDragFrame (which clamps + optionally zero-crossing-snaps),
|
||||
// and commits on WM_LBUTTONUP. Live feedback is the shell re-drawing the in-flight frame.
|
||||
//
|
||||
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom), so
|
||||
// this header depends on editor_geometry.h rather than redefining a rectangle type. Audio
|
||||
// is the peaks AudioSample float alias (the one house precedent — sampler_core / wav_codec do
|
||||
// the same), so the zero-crossing helper takes the same mono PCM the shell already decoded.
|
||||
// waveform rect. Markers are a generic N-named-marker set (not hardcoded specials), so a
|
||||
// different mode (e.g. start + %-length end + fades) can repurpose the same machinery.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
|
||||
#include "core/audio/peaks.h" // AudioSample (float), the mono PCM the snap scans
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
|
||||
#include "core/audio/peaks.h" // AudioSample (float)
|
||||
|
||||
namespace reasampler::instrument::ui {
|
||||
|
||||
using audio::AudioSample;
|
||||
|
||||
// The width (px) of a marker's grab region either side of its x line: a grab within this many
|
||||
// pixels of a marker's drawn x is a grab OF that marker. Mirrors keyboard_strip's edge-grab
|
||||
// idiom — wide enough to grab a 1px line comfortably, narrow enough that adjacent markers stay
|
||||
// distinguishable.
|
||||
// Pixel width of a marker's grab region either side of its x line. Mirrors keyboard_strip's
|
||||
// edge-grab idiom.
|
||||
inline constexpr int kMarkerGrabWidth = 5;
|
||||
|
||||
// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.x,
|
||||
// frame frameCount -> area.right(). A frame is clamped to [0, frameCount] before mapping, so an
|
||||
// out-of-range frame pins to an edge rather than escaping the rect. frameCount <= 0 or a
|
||||
// zero-width area pins every frame to area.x (a degenerate, non-inverting result). Pure.
|
||||
// x pixel of `frame` under the linear map: frame 0 -> area.x, frame frameCount -> area.right().
|
||||
// Frame is clamped to [0, frameCount] before mapping. frameCount <= 0 or a zero-width area pins
|
||||
// every frame to area.x.
|
||||
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame);
|
||||
|
||||
// The frame a point x (inside `area`) maps to under the inverse linear map, clamped to
|
||||
// [0, frameCount]. A point left of area.x yields 0; right of area.right() yields frameCount.
|
||||
// frameCount <= 0 or a zero-width area yields 0. Pure — the inverse of frameToX (round-trips
|
||||
// to the same frame at bin granularity).
|
||||
// Inverse of frameToX: the frame a point x maps to, clamped to [0, frameCount]. A point left of
|
||||
// area.x yields 0; right of area.right() yields frameCount.
|
||||
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x);
|
||||
|
||||
// Which marker (index into a caller-supplied parallel `frames` array, in draw order) a grab at
|
||||
// (x, y) lands on, or -1 for a point off every marker (or off the waveform area). A marker is
|
||||
// grabbed when x is within kMarkerGrabWidth of its drawn x AND y is inside `area`. First marker
|
||||
// in order wins a tie where two markers overlap within the grab band (deterministic, mirroring
|
||||
// keyboard_strip's first-match). `frames` is `count` frame indices; a null/empty array or
|
||||
// count <= 0 yields -1. Pure — a raw pointer at the boundary (no host container), like
|
||||
// keyboard_strip::zoneBarAtPoint.
|
||||
// Which marker (index into the caller's parallel `frames` array, in draw order) a grab at
|
||||
// (x, y) lands on, or -1 for a miss. A marker is grabbed when x is within kMarkerGrabWidth of
|
||||
// its drawn x and y is inside `area`. First marker in draw order wins an overlapping tie.
|
||||
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
|
||||
int count, int x, int y);
|
||||
|
||||
// Resolve a drag to a new frame. Given the frame the grabbed marker held at grab time
|
||||
// (`startFrame`) and the horizontal pixel delta since grab (`dxPixels`), returns the frame the
|
||||
// marker should now hold: startFrame shifted by round(dxPixels * frameCount / areaWidth),
|
||||
// clamped to [0, frameCount]. A zero-width area or non-positive frameCount pins the result to
|
||||
// the clamped startFrame (no motion). This is the single arithmetic behind every marker drag;
|
||||
// the shell applies clamps BETWEEN markers (start <= loopEnd, loopStart <= loopEnd) after this
|
||||
// per-marker resolve. Pure — rounding is to the nearest frame. Returns the clamped startFrame
|
||||
// for dxPixels == 0.
|
||||
// Resolves a drag to a new frame: `startFrame` shifted by round(dxPixels * frameCount /
|
||||
// areaWidth), clamped to [0, frameCount]. The shell applies between-marker clamps (e.g.
|
||||
// start <= loopEnd) after this per-marker resolve.
|
||||
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
|
||||
int dxPixels);
|
||||
|
||||
// The nearest zero-crossing frame to `target` in the mono PCM, for the loop/start snap (the
|
||||
// S2 zero-crossing-aware requirement). A zero crossing is a frame index i (1 <= i < frames)
|
||||
// where the sign of pcm[i-1] and pcm[i] differ (a sample exactly 0 counts as its own crossing
|
||||
// — pcm[i] == 0 snaps to i). The search fans out symmetrically from the clamped target and
|
||||
// returns the closest crossing frame; ties (equidistant crossings on both sides) resolve to
|
||||
// the LOWER frame (deterministic). When the PCM has NO sign change anywhere (all one sign, or
|
||||
// fewer than 2 frames), returns the clamped target unchanged (nothing to snap to — the caller
|
||||
// keeps the raw frame). `target` is clamped to [0, frames) before searching. Pure — scans the
|
||||
// decoded PCM the shell already holds; no host types, no file I/O.
|
||||
// Nearest zero-crossing frame to `target` in the mono PCM, for loop/start snap. A crossing is a
|
||||
// frame i (1 <= i < frames) where pcm[i-1] and pcm[i] differ in sign (pcm[i] == 0 snaps to i).
|
||||
// Search fans out symmetrically from the clamped target; an equidistant tie resolves to the
|
||||
// lower frame. No sign change anywhere (or fewer than 2 frames) returns the clamped target
|
||||
// unchanged.
|
||||
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
|
||||
std::int64_t target);
|
||||
|
||||
|
||||
@@ -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
@@ -1,6 +1,5 @@
|
||||
// core/json implementation — see json.h. The bodies are the (previously
|
||||
// quintuplicated) bank_model / view_mode_model lexical layer, verbatim; any
|
||||
// behavioral change here changes five persisted-blob parsers at once.
|
||||
// core/json implementation — see json.h. Any behavioral change here changes
|
||||
// every persisted-blob parser that shares this lexical layer at once.
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
@@ -11,9 +10,7 @@
|
||||
|
||||
namespace reasampler::json {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// emit helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- emit helpers -------------------------------------------------------
|
||||
|
||||
void writeEscaped(std::string& out, const std::string& s) {
|
||||
out += '"';
|
||||
@@ -75,9 +72,7 @@ void writeIntArray(std::string& out, const std::vector<int>& v) {
|
||||
out += ']';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- Reader ---------------------------------------------------------------
|
||||
|
||||
void Reader::skipWs() {
|
||||
while (!eof()) {
|
||||
@@ -117,7 +112,6 @@ bool Reader::parseString(std::string& out) {
|
||||
case 'r': out += '\r'; break;
|
||||
case 't': out += '\t'; break;
|
||||
case 'u': {
|
||||
// Decode a \uXXXX escape to its code point.
|
||||
auto readHex4 = [&](unsigned int& cp) -> bool {
|
||||
if (pos_ + 4 > s_.size()) return false;
|
||||
cp = 0;
|
||||
@@ -149,7 +143,6 @@ bool Reader::parseString(std::string& out) {
|
||||
return false; // unpaired low surrogate — malformed
|
||||
}
|
||||
|
||||
// Encode codePoint as UTF-8.
|
||||
if (codePoint <= 0x7F) {
|
||||
out += static_cast<char>(codePoint);
|
||||
} else if (codePoint <= 0x7FF) {
|
||||
|
||||
+16
-23
@@ -1,22 +1,19 @@
|
||||
// core/json — the ONE hand-rolled JSON lexical layer (Q-W1; audit T2-02 / §2
|
||||
// "Parser ×4"). Pure: standard library only — NO REAPER, NO SWELL, NO VST3.
|
||||
// core/json — the ONE hand-rolled JSON lexical layer. Pure: standard library only
|
||||
// — NO REAPER, NO SWELL, NO VST3.
|
||||
//
|
||||
// This module owns the lexical half of the house JSON dialect: the escape-aware
|
||||
// string literal (incl. \uXXXX + surrogate pairs re-encoded as UTF-8), the bare
|
||||
// scalar tokens, the number parses (strtod/strtoll with full-token + ERANGE
|
||||
// rejection), key+':' consumption, unknown-value skipping, and the emit side
|
||||
// (escaping, %.17g / %d / %lld number rendering, the scoped object writer).
|
||||
// The DOMAIN grammars — which keys exist, what shape each value takes, what is
|
||||
// rejected at the model boundary — stay in the consumers (bank_model, bank_book,
|
||||
// view_mode_model, owned_manifest, tail_control). One lexical definition means
|
||||
// the five decoders can no longer drift on tolerance or escaping.
|
||||
// Owns the lexical half of the house JSON dialect: escape-aware string literals
|
||||
// (incl. \uXXXX + surrogate pairs re-encoded as UTF-8), bare scalar tokens, number
|
||||
// parsing (strtod/strtoll, full-token + ERANGE rejection), key+':' consumption,
|
||||
// unknown-value skipping, and the emit side (escaping, %.17g/%d/%lld rendering,
|
||||
// the scoped object writer). Domain grammars — which keys exist, what shape each
|
||||
// value takes — stay in the consumers (bank_model, bank_book, view_mode_model,
|
||||
// owned_manifest, tail_control).
|
||||
//
|
||||
// Byte-compatibility contract (load-bearing): the emit helpers reproduce the
|
||||
// prior per-module writers EXACTLY — writeEscaped's escape set, %.17g for
|
||||
// doubles (shortest form that round-trips every IEEE-754 double bit-for-bit),
|
||||
// plain decimal for ints — so a re-serialized blob is byte-identical to what
|
||||
// the pre-extraction writers produced. This was a structural dedupe, not a
|
||||
// format change; persisted .rpp ext-state must not shift by a byte.
|
||||
// Byte-compatibility contract (load-bearing): the emit helpers reproduce the prior
|
||||
// per-module writers EXACTLY — writeEscaped's escape set, %.17g for doubles
|
||||
// (shortest form that round-trips every IEEE-754 double bit-for-bit), plain
|
||||
// decimal for ints — so a re-serialized blob is byte-identical to what the
|
||||
// pre-extraction writers produced. Persisted .rpp ext-state must not shift by a byte.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -26,9 +23,7 @@
|
||||
|
||||
namespace reasampler::json {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// emit helpers (writer side)
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- emit helpers (writer side) ----------------------------------------------
|
||||
|
||||
// Appends `s` as a quoted JSON string literal: the seven short escapes, \uXXXX
|
||||
// for remaining control chars, everything else verbatim (UTF-8 passes through).
|
||||
@@ -88,9 +83,7 @@ private:
|
||||
bool first_ = true;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader — the lexical cursor (parser side)
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- Reader — the lexical cursor (parser side) -------------------------------
|
||||
//
|
||||
// Every method returns false on malformed input and never reads out of bounds.
|
||||
// Only the subset the house writers emit is supported. The reader borrows the
|
||||
|
||||
@@ -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.
|
||||
@@ -5,19 +5,13 @@
|
||||
|
||||
// bank_book implementation — the registry RULES half: construction, pool
|
||||
// privileges, bank lifecycle, active bank, sample movement/removal, slot order,
|
||||
// and the reference queries. The JSON round-trip half (serialize / deserialize —
|
||||
// Q-W1's golden-literal-pinned byte format) lives in bank_book_json.cpp, compiled
|
||||
// into the same bank_book target (the slot_map extraction shape: same header, a
|
||||
// second TU). The one symbol both halves share is the private static
|
||||
// BankBook::nameKey display-name folding rule (declared in bank_book.h).
|
||||
// and the reference queries. The JSON round-trip half lives in bank_book_json.cpp,
|
||||
// compiled into the same target. The one symbol both halves share is the private
|
||||
// static BankBook::nameKey display-name folding rule (declared in bank_book.h).
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// SlotMap lives in core/model/slot_map.cpp (extracted Q-W1, T4-05).
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BankBook — construction + bank lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- construction + bank lookup ----------------------------------------------
|
||||
|
||||
BankBook::BankBook() {
|
||||
Bank pool;
|
||||
@@ -59,9 +53,7 @@ const Bank& BankBook::pool() const {
|
||||
return *bank(kPoolBankId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ordinal normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- Ordinal normalization ----------------------------------------------------
|
||||
|
||||
void BankBook::normalizeOrdinals() {
|
||||
// Stable-sort by ordinal with the pool pinned first, then rewrite ordinals to a
|
||||
@@ -75,16 +67,13 @@ void BankBook::normalizeOrdinals() {
|
||||
banks_[i].ordinal = static_cast<int>(i);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display-name uniqueness (trimmed + case-insensitive, ASCII)
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- Display-name uniqueness (trimmed + case-insensitive, ASCII) --------------
|
||||
|
||||
// Folds a display name to its uniqueness key: strip leading/trailing ASCII
|
||||
// whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share one
|
||||
// key and cannot coexist. ASCII-only by design — the pure core carries no locale
|
||||
// facility and must not grow one; bank names are short user labels, not full Unicode
|
||||
// case-folding candidates. Private static member (Q-W5): the one folding rule shared
|
||||
// with bank_book_json.cpp's parse-time duplicate-display-name coalesce.
|
||||
// whitespace, lower-case ASCII letters — so "Drums"/"drums"/" Drums " share one
|
||||
// key. ASCII-only by design — the pure core carries no locale facility; bank names
|
||||
// are short user labels, not Unicode case-folding candidates. Private static: the
|
||||
// one folding rule shared with bank_book_json.cpp's parse-time coalesce.
|
||||
std::string BankBook::nameKey(const std::string& s) {
|
||||
std::size_t b = 0, e = s.size();
|
||||
auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; };
|
||||
@@ -109,9 +98,7 @@ bool BankBook::displayNameTaken(const std::string& name, const std::string& exce
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bank lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- Bank lifecycle ------------------------------------------------------------
|
||||
|
||||
bool BankBook::createBank(const std::string& id, const std::string& displayName) {
|
||||
if (id.empty()) return false; // ids key the registry
|
||||
@@ -208,9 +195,7 @@ bool BankBook::evacuate(const std::string& id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active bank
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- Active bank ----------------------------------------------------------------
|
||||
|
||||
bool BankBook::setActiveBank(const std::string& id) {
|
||||
if (bank(id) == nullptr) return false; // unknown id never corrupts state
|
||||
@@ -227,9 +212,7 @@ const BankModel& BankBook::activeIndex() const {
|
||||
return bank(activeBankId_)->index;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample movement (index-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- Sample movement (index-only) -----------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -276,9 +259,7 @@ TransferResult BankBook::copySample(const std::string& sampleId,
|
||||
return applyDestAdd(to->index, copy, TransferResult::Copied);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample removal (index-only) + the last-reference query
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- Sample removal (index-only) + the last-reference query ---------------------
|
||||
|
||||
RemoveResult BankBook::removeSample(const std::string& sampleId,
|
||||
const std::string& fromBankId,
|
||||
@@ -310,9 +291,7 @@ bool BankBook::updateSampleInPlace(const std::string& sampleId, const Sample& up
|
||||
return false; // no bank holds the id
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample display order (L7) — SlotMap driven, index membership untouched
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- Sample display order — SlotMap driven, index membership untouched -----------
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -323,9 +302,9 @@ std::vector<std::string> indexIds(const BankModel& idx) {
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Squares one bank's SlotMap with its index membership. A map with NO overlap with the
|
||||
// index (the pre-L7 migration case, or a freshly-constructed bank) is seeded dense from
|
||||
// insertion order; an existing map is reconciled (drop stale markers, append unmapped).
|
||||
// Squares one bank's SlotMap with its index membership. A map with NO overlap with
|
||||
// the index (a bank with no persisted slot data, or freshly constructed) is seeded
|
||||
// dense from insertion order; an existing map is reconciled (drop stale, append unmapped).
|
||||
void reconcileBankSlots(Bank& b) {
|
||||
const std::vector<std::string> live = indexIds(b.index);
|
||||
if (b.slots.empty()) {
|
||||
|
||||
+108
-208
@@ -1,41 +1,13 @@
|
||||
#pragma once
|
||||
// bank_book — the pure core of the multi-bank phase (Phase B), deliberately free
|
||||
// of any REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the
|
||||
// third instance of the same "pure registry + JSON round-trip, unit-tested outside
|
||||
// the DAW" pattern as bank_model and view_mode_model.
|
||||
// bank_book — pure multi-bank registry: wraps N BankModel instances (bank_model
|
||||
// itself is untouched — additive, no bankId on Sample). Movement between banks is
|
||||
// index-only; files never relocate, banks are logical groupings over one shared pool.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only.
|
||||
//
|
||||
// -- What it is --------------------------------------------------------------
|
||||
//
|
||||
// An ordered registry of banks. Each bank = { stable id, display name, ordinal,
|
||||
// BankModel }. The book WRAPS N BankModel instances — bank_model / BankModel are
|
||||
// UNTOUCHED (additive: no bankId on Sample). Movement of samples between banks is
|
||||
// index-only (remove from source's BankModel, add to destination's); files never
|
||||
// relocate — banks are logical groupings over one shared file pool.
|
||||
//
|
||||
// -- The pool (privileged, not special-cased) --------------------------------
|
||||
//
|
||||
// Structurally the pool is bank-zero — one Bank among many, seeded on construction
|
||||
// with a fixed id (kPoolBankId) and fixed display name (kPoolBankName), ordinal 0.
|
||||
// Semantically it is privileged, and the privileges are enforced HERE in the pure
|
||||
// rules layer (CONTEXT.md §Multi-bank guardrail — not deferred to a shell):
|
||||
// * always exists (seeded on construction; the book never reaches zero banks)
|
||||
// * un-deletable (deleteBank rejects the pool)
|
||||
// * un-renamable (renameBank rejects the pool)
|
||||
// * un-evacuable (evacuate rejects the pool — the pool is evacuation's
|
||||
// destination, not a source)
|
||||
//
|
||||
// -- Id minting is the CALLER'S job (design decision) ------------------------
|
||||
//
|
||||
// createBank takes a caller-supplied stable id, mirroring bank_model's "id
|
||||
// assigned by the caller" and view_mode_model's mode ids. The pure core has no
|
||||
// REAPER genGuid / RNG and deliberately introduces none: a fake in-model id source
|
||||
// would not be a real GUID anyway, and keeping ids caller-supplied lets the B2
|
||||
// shell mint a genuine REAPER GUID while the model stays pure and deterministically
|
||||
// testable. The model still enforces the invariants: non-empty, unique, not the
|
||||
// reserved pool id.
|
||||
// The pool is bank-zero (fixed id/name, ordinal 0), privileged and enforced HERE:
|
||||
// always exists, un-deletable, un-renamable, un-evacuable (evacuate's destination
|
||||
// only). createBank takes a caller-supplied id — REAPER GUID minting stays in the
|
||||
// shell so this model stays pure and deterministic; the model still enforces
|
||||
// non-empty/unique/not-reserved.
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -47,26 +19,22 @@
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Q-W1 interim: this god module re-namespaces in its own split wave; until then the
|
||||
// clean model types it wraps live in reasampler::model.
|
||||
// Interim: this module re-namespaces later; the model types it wraps live in
|
||||
// reasampler::model.
|
||||
using namespace model;
|
||||
|
||||
// The pool's fixed identity. The id is reserved: createBank rejects it, and the
|
||||
// pool is always bank-zero. The name is fixed: renameBank rejects the pool.
|
||||
// The pool's fixed identity: createBank rejects this id; renameBank rejects this name.
|
||||
inline constexpr const char* kPoolBankId = "pool";
|
||||
inline constexpr const char* kPoolBankName = "Pool";
|
||||
|
||||
// SlotMap — extracted to its own TU/header pair (Q-W1, T4-05): core/model/slot_map.h.
|
||||
// Included above because Bank carries one per bank.
|
||||
|
||||
// One bank: a stable id, a display name, an ordinal (tab/display order), and its
|
||||
// own BankModel. The pool is the bank whose id == kPoolBankId.
|
||||
// One bank: id/display/ordinal/BankModel/SlotMap. The pool is the bank whose
|
||||
// id == kPoolBankId.
|
||||
struct Bank {
|
||||
std::string id; // stable, persisted; the pool's is kPoolBankId
|
||||
std::string displayName; // mutable for named banks; fixed "Pool" for the pool
|
||||
int ordinal = 0; // display order; pool is 0, named banks 1..N
|
||||
BankModel index; // this bank's samples
|
||||
SlotMap slots; // L7 display positions of this bank's samples (gap-preserving)
|
||||
SlotMap slots; // display positions of this bank's samples (gap-preserving)
|
||||
|
||||
bool isPool() const { return id == kPoolBankId; }
|
||||
|
||||
@@ -76,16 +44,12 @@ struct Bank {
|
||||
}
|
||||
};
|
||||
|
||||
// Outcome of a cross-bank sample move/copy. Mirrors AddResult's honesty: the op
|
||||
// reports what happened rather than silently mutating on a bad request.
|
||||
// - Moved / Copied: the sample was transferred to the destination as a new entry.
|
||||
// - Collapsed: the destination already held the hash; it collapsed onto the
|
||||
// existing entry (a no-op add on the destination side). For a
|
||||
// MOVE the source entry is STILL removed; for a COPY the source
|
||||
// entry is (as always) retained.
|
||||
// - RejectedUnknownBank: a source or destination id named no bank.
|
||||
// - RejectedSampleAbsent: the sample id was not in the source bank.
|
||||
// - RejectedSameBank: source and destination were the same bank (no-op).
|
||||
// Outcome of a cross-bank move/copy — reports what happened rather than mutating
|
||||
// silently on a bad request.
|
||||
// - Moved / Copied: transferred to the destination as a new entry.
|
||||
// - Collapsed: destination already held the hash, collapsed onto it (move
|
||||
// still removes the source; copy keeps it, as always).
|
||||
// - RejectedUnknownBank / RejectedSampleAbsent / RejectedSameBank: no-op guards.
|
||||
enum class TransferResult {
|
||||
Moved,
|
||||
Copied,
|
||||
@@ -95,21 +59,18 @@ enum class TransferResult {
|
||||
RejectedSameBank,
|
||||
};
|
||||
|
||||
// Scope of a sample-remove (fork R-A, settled 2026-07-24). ThisBank is the default
|
||||
// and the ONLY behavior surfaced in the UI/action layer; AllBanks is a latent seam —
|
||||
// live and tested at the model level, promotable later behind this parameter without
|
||||
// a rewrite, but never wired to an affordance in B5.
|
||||
// - ThisBank: drop the entry from the one named source bank only. A same-hash entry
|
||||
// in another bank survives (no cross-bank cascade — dedup is per-bank).
|
||||
// - AllBanks: drop the sample's entry from EVERY bank that holds the source id
|
||||
// ("purge from the library"). Latent; unsurfaced.
|
||||
// Scope of a sample-remove. ThisBank is the only behavior surfaced in the UI;
|
||||
// AllBanks is a tested latent seam, not wired to any affordance.
|
||||
// - ThisBank: drop the entry from the one named source bank only (no cross-bank
|
||||
// cascade — dedup is per-bank).
|
||||
// - AllBanks: drop the sample's entry from every bank holding it ("purge from
|
||||
// the library").
|
||||
enum class RemoveScope {
|
||||
ThisBank,
|
||||
AllBanks,
|
||||
};
|
||||
|
||||
// Outcome of BankBook::removeSample. Mirrors TransferResult's honesty: the op reports
|
||||
// what happened rather than silently mutating on a bad request.
|
||||
// Outcome of BankBook::removeSample — same honesty as TransferResult.
|
||||
// - Removed: at least one index entry was dropped.
|
||||
// - RejectedUnknownBank: the source bank id named no bank (ThisBank scope only).
|
||||
// - RejectedSampleAbsent: the sample id was in no bank in scope (nothing removed).
|
||||
@@ -120,8 +81,7 @@ enum class RemoveResult {
|
||||
};
|
||||
|
||||
// An ordered registry of banks with the pool seeded as bank-zero, per-bank sample
|
||||
// indices, an active-bank pointer, and lossless JSON round-trip. The heart of the
|
||||
// multi-bank phase — mirror of bank_model / view_mode_model.
|
||||
// indices, an active-bank pointer, and lossless JSON round-trip.
|
||||
class BankBook {
|
||||
public:
|
||||
BankBook(); // seeds the pool (id kPoolBankId, name kPoolBankName, ordinal 0);
|
||||
@@ -129,34 +89,29 @@ public:
|
||||
|
||||
// -- Bank lifecycle ------------------------------------------------------
|
||||
|
||||
// Creates a named bank with the caller-supplied stable id and display name,
|
||||
// assigning the next ordinal. Rejects (returns false, no mutation) an empty id,
|
||||
// a duplicate id, the reserved pool id, or a display name that duplicates an
|
||||
// existing bank's name (including the pool's "Pool"). Display-name uniqueness is
|
||||
// trimmed + case-insensitive (ASCII): "Drums", "drums", and " Drums " collide.
|
||||
// Creates a named bank with a caller-supplied id/display name (next ordinal
|
||||
// assigned automatically). Rejects (false, no mutation) an empty/duplicate id,
|
||||
// the reserved pool id, or a duplicate display name (trimmed + case-insensitive,
|
||||
// ASCII — "Drums"/"drums"/" Drums " collide, including against the pool's "Pool").
|
||||
bool createBank(const std::string& id, const std::string& displayName);
|
||||
|
||||
// Renames a named bank. Rejects (false, no mutation) an unknown id, the pool, or a
|
||||
// target name already used by a DIFFERENT bank (trimmed + case-insensitive, as
|
||||
// createBank). Renaming a bank to its own current name is a no-op success.
|
||||
// Renames a named bank. Rejects (false, no mutation) an unknown id, the pool, or
|
||||
// a name already used by another bank. Renaming to its own current name is a
|
||||
// no-op success.
|
||||
bool renameBank(const std::string& id, const std::string& displayName);
|
||||
|
||||
// Deletes a NAMED bank, removing it (and its member index entries) from the
|
||||
// registry. Files are a shell/prune concern and are NOT touched here. Rejects
|
||||
// (false, no mutation) an unknown id or the pool. Remaining banks' ordinals are
|
||||
// compacted so the pool stays 0 and named banks stay contiguous 1..N. If the
|
||||
// deleted bank was active, the active bank falls back to the pool.
|
||||
// Deletes a named bank and its member entries (files untouched — a shell/prune
|
||||
// concern). Rejects (false, no mutation) an unknown id or the pool. Remaining
|
||||
// ordinals compact after; if the deleted bank was active, falls back to the pool.
|
||||
bool deleteBank(const std::string& id);
|
||||
|
||||
// Reorders a NAMED bank to `newOrdinal` (clamped into the named-bank range),
|
||||
// shifting the others to keep ordinals contiguous. The pool is pinned at 0 and
|
||||
// cannot be reordered. Rejects (false, no mutation) an unknown id or the pool.
|
||||
// Reorders a named bank to newOrdinal (clamped into range, others shift to stay
|
||||
// contiguous). The pool is pinned at 0. Rejects an unknown id or the pool.
|
||||
bool reorderBank(const std::string& id, int newOrdinal);
|
||||
|
||||
// Moves EVERY member of a named bank into the pool (index-only, observing the
|
||||
// same destination-collapse-by-hash as a move), leaving the bank empty. Rejects
|
||||
// (false, no mutation) an unknown id or the pool (the pool is the destination,
|
||||
// never a source). Returns true on success even if the bank was already empty.
|
||||
// Moves every member of a named bank into the pool (index-only, same
|
||||
// destination-collapse-by-hash as a move). Rejects an unknown id or the pool
|
||||
// (the pool is only ever a destination). Returns true even if already empty.
|
||||
bool evacuate(const std::string& id);
|
||||
|
||||
// -- Active bank ---------------------------------------------------------
|
||||
@@ -164,123 +119,83 @@ public:
|
||||
// The active bank's id (the capture target). Defaults to the pool.
|
||||
const std::string& activeBankId() const { return activeBankId_; }
|
||||
|
||||
// Sets the active bank. Rejects (returns false, no change) an id that names no
|
||||
// bank — an invalid set never corrupts state.
|
||||
// Sets the active bank. Rejects (false, no change) an id that names no bank.
|
||||
bool setActiveBank(const std::string& id);
|
||||
|
||||
// The active bank's BankModel — the index the capture layer adds to. Always
|
||||
// valid (the active id always names a live bank; it falls back to the pool).
|
||||
// The active bank's BankModel — always valid (falls back to the pool).
|
||||
BankModel& activeIndex();
|
||||
const BankModel& activeIndex() const;
|
||||
|
||||
// -- Sample movement (index-only; files never relocate) ------------------
|
||||
|
||||
// Moves a sample by id from `fromBankId` to `toBankId`: removes it from the
|
||||
// source index and adds it to the destination (observing destination
|
||||
// collapse-by-hash). See TransferResult for the full outcome set.
|
||||
// Moves a sample by id between banks (destination collapse-by-hash observed).
|
||||
// See TransferResult for the full outcome set.
|
||||
TransferResult moveSample(const std::string& sampleId,
|
||||
const std::string& fromBankId,
|
||||
const std::string& toBankId);
|
||||
|
||||
// Copies a sample by id from `fromBankId` to `toBankId`: the source entry is
|
||||
// retained, the destination gains it (observing destination collapse-by-hash).
|
||||
// Same hash may then live in both banks — cross-bank dedup is NOT enforced.
|
||||
// Copies a sample by id between banks, source retained (destination
|
||||
// collapse-by-hash observed). Cross-bank dedup is NOT enforced — the same hash
|
||||
// may then live in both banks.
|
||||
TransferResult copySample(const std::string& sampleId,
|
||||
const std::string& fromBankId,
|
||||
const std::string& toBankId);
|
||||
|
||||
// -- Sample removal (index-only; the file is NEVER touched — orphaned until prune) --
|
||||
|
||||
// Drops a sample's index entry (the sample-level sibling of move/copy/evacuate).
|
||||
// Index-only and non-destructive to the file: a last-reference remove leaves the
|
||||
// file on disk, orphaned until Phase R prune — remove NEVER deletes bytes.
|
||||
//
|
||||
// Scope (fork R-A): ThisBank (default, the only surfaced verb) drops the entry from
|
||||
// `fromBankId` alone; AllBanks (latent seam) drops the sample id from every bank
|
||||
// that holds it. See RemoveResult for the outcome set.
|
||||
// * ThisBank: RejectedUnknownBank if `fromBankId` names no bank; RejectedSampleAbsent
|
||||
// if that bank does not hold the id; Removed on a drop.
|
||||
// * AllBanks: `fromBankId` is ignored (the id is purged book-wide);
|
||||
// RejectedSampleAbsent if NO bank held the id; Removed otherwise.
|
||||
// No mutation occurs on any Rejected outcome (no-op guardrail for the undo layer).
|
||||
// Drops a sample's index entry — non-destructive to the file (a last-reference
|
||||
// remove leaves it on disk, orphaned until prune reclaims it). See RemoveScope/
|
||||
// RemoveResult for scope and outcome. No mutation on any Rejected outcome.
|
||||
RemoveResult removeSample(const std::string& sampleId,
|
||||
const std::string& fromBankId,
|
||||
RemoveScope scope = RemoveScope::ThisBank);
|
||||
|
||||
// -- Sample display order (L7; index membership untouched) ---------------
|
||||
// -- Sample display order (index membership untouched) -------------------
|
||||
|
||||
// The bank's sample ids in DISPLAY (slot) order — the deterministic order the grid
|
||||
// iterates, sourced from the bank's SlotMap. Reconciles the map against live index
|
||||
// membership first (drops stale markers, appends unmapped samples densely), so a
|
||||
// freshly-migrated or out-of-band-mutated bank always yields a complete order. An
|
||||
// unknown bank id yields an empty vector. Const-logical but reconciles lazily, so
|
||||
// it is a non-const member.
|
||||
// The bank's sample ids in display (slot) order, reconciled against live index
|
||||
// membership first (drops stale markers, appends unmapped samples densely). An
|
||||
// unknown bank id yields an empty vector.
|
||||
std::vector<std::string> orderedSampleIds(const std::string& bankId);
|
||||
|
||||
// Ensures every bank's SlotMap is consistent with its index membership: seeds a
|
||||
// map that has NO overlap with its index from insertion order (the pre-L7 migration
|
||||
// default — dense, no gaps), and reconciles a partially-populated map (drop stale,
|
||||
// append unmapped). Idempotent. Called after deserialize and after any capture/
|
||||
// transfer that added samples out-of-band of the L7 reorder path.
|
||||
// Ensures every bank's SlotMap is consistent with its index membership (seeds a
|
||||
// dense order, or reconciles a partial map). Idempotent — call after deserialize
|
||||
// or any out-of-band membership change.
|
||||
void reconcileSlots();
|
||||
|
||||
// Reorders sample `id` within `bankId` to `targetSlot` (gap-preserving; see
|
||||
// SlotMap::reorder). INDEX-ONLY of positions — the sample's membership, file, and
|
||||
// metadata are untouched (capture != placement holds). Reconciles the bank's slots
|
||||
// first so the target space is complete. Returns false (no mutation) on an unknown
|
||||
// bank or an id the bank does not hold.
|
||||
// Reorders sample `id` within `bankId` to targetSlot (gap-preserving; index/file/
|
||||
// metadata untouched). Returns false (no mutation) on an unknown bank or id.
|
||||
bool reorderSample(const std::string& id, const std::string& bankId, int targetSlot);
|
||||
|
||||
// Alt-replace (L7 F3): the dragged sample `newId` (already a member of `bankId`)
|
||||
// takes the slot of the occupant `oldId`, and `oldId` is REMOVED from `bankId`'s
|
||||
// index (index-only, same semantics as removeSample ThisBank — the file stays on
|
||||
// disk; owned-manifest/prune govern bytes; hashReferencedElsewhere handles the
|
||||
// last-reference case). Position of the slot is preserved; only its occupant changes.
|
||||
// Alt-replace: the dragged `newId` (already a member of bankId) takes the slot of
|
||||
// `oldId`, and `oldId` is removed from the index (same semantics as removeSample
|
||||
// ThisBank). Position is preserved; only the occupant changes.
|
||||
//
|
||||
// POOL GUARD (settled): the index-removal of `oldId` passes the SAME guard the
|
||||
// remove verb applies — removeSample(oldId, bankId, ThisBank) must return Removed.
|
||||
// For the pool this is permitted whenever the occupant exists (per-sample removal
|
||||
// is not a pool privilege violation — the pool's guards are un-delete/rename/evacuate,
|
||||
// never per-sample remove). If the removal would be rejected (occupant absent), the
|
||||
// whole replace is rejected: false, NO mutation (neither the index nor the slots
|
||||
// change), so the shell can fall back to the default insert-shift or a no-op.
|
||||
// Rejects (false, no mutation) an unknown bank, a `newId`/`oldId` the bank does not
|
||||
// hold, or `newId == oldId`. NEVER touches disk; introduces no new deletion authority.
|
||||
// Applies the same pool guard as removeSample — per-sample removal from the pool
|
||||
// is allowed (the pool's guards are un-delete/rename/evacuate, never per-sample
|
||||
// remove). Rejects (false, no mutation of either index or slots) an unknown bank,
|
||||
// a newId/oldId the bank doesn't hold, or newId == oldId.
|
||||
bool replaceSample(const std::string& newId, const std::string& oldId,
|
||||
const std::string& bankId);
|
||||
|
||||
// Refreshes a sample IN PLACE wherever it lives in the book (M10 re-capture):
|
||||
// finds the bank holding `sampleId` and replaces its entry with `updated`
|
||||
// (order-preserving, no dedup — see BankModel::updateInPlace). Scans banks in
|
||||
// ordinal order and updates the FIRST holder (a sample id is unique within a
|
||||
// bank; the same id living in two banks via copy would update the earliest, which
|
||||
// is acceptable — re-capture operates on the panel's focused single selection).
|
||||
// Returns false (no mutation) if no bank holds the id or the replacement's path
|
||||
// is absolute. Index-only and non-destructive to the timeline.
|
||||
// Refreshes a sample in place wherever it lives (re-capture): finds the bank
|
||||
// holding sampleId and replaces its entry with `updated` (order-preserving, no
|
||||
// dedup). Updates the FIRST holder in ordinal order if the id lives in multiple
|
||||
// banks via copy. Returns false (no mutation) if no bank holds the id or the
|
||||
// replacement's path is absolute.
|
||||
bool updateSampleInPlace(const std::string& sampleId, const Sample& updated);
|
||||
|
||||
// Reference-count query backing the confirm-on-last-reference guardrail: does any
|
||||
// bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`?
|
||||
//
|
||||
// Identity is the CONTENT HASH, not the file path: hash is the canonical dedup key
|
||||
// the whole model already reasons in (findByHash / collapse-by-hash), and two
|
||||
// entries that share content share one file — so "some other bank still references
|
||||
// this hash" is exactly "removing here does not orphan the file." An EMPTY hash is
|
||||
// never matched (it does not participate in dedup, mirroring findByHash), so an
|
||||
// empty-hash sample reads as referenced-nowhere-else — the safe, confirm-eliciting
|
||||
// direction (we cannot prove another bank shares an unhashed file).
|
||||
// Does any bank other than exceptBankId still hold an entry whose contentHash
|
||||
// == hash? Backs the confirm-on-last-reference guardrail: two entries sharing a
|
||||
// hash share one file, so this answers "would removing here orphan the file."
|
||||
// An empty hash never matches (mirrors findByHash) — reads as
|
||||
// referenced-nowhere-else, the safe confirm-eliciting default.
|
||||
bool hashReferencedElsewhere(const std::string& hash,
|
||||
const std::string& exceptBankId) const;
|
||||
|
||||
// Every project-relative file path referenced by ANY bank in the book, pool
|
||||
// included — the union across the whole book (Phase R, prune). This is the
|
||||
// safety-critical referenced-set the prune core subtracts: a file referenced by
|
||||
// any bank (INCLUDING via a copy into a second bank) appears here, so prune never
|
||||
// reclaims it. Paths are returned VERBATIM (Sample.relativePath, exact strings —
|
||||
// no normalization), first-seen order across banks in ordinal order then sample
|
||||
// insertion order, and DE-DUPLICATED (one file referenced by N banks appears
|
||||
// once). An empty relativePath is skipped (it references no file). Additive
|
||||
// read-only query; adds no mutation and no coupling to Phase R.
|
||||
// Every project-relative path referenced by any bank (pool included) — the union
|
||||
// prune subtracts against. Paths are verbatim (no normalization), first-seen
|
||||
// order across banks in ordinal then insertion order, de-duplicated. An empty
|
||||
// relativePath is skipped.
|
||||
std::vector<std::string> referencedPaths() const;
|
||||
|
||||
// -- Query ---------------------------------------------------------------
|
||||
@@ -308,33 +223,25 @@ public:
|
||||
|
||||
// -- Persistence ---------------------------------------------------------
|
||||
|
||||
// Serializes the whole book to a JSON string (lossless round-trip): the pool
|
||||
// folded in as bank-zero + named banks + per-bank indices + ordinals + active
|
||||
// id. deserialize(serialize(x)) == x.
|
||||
// Serializes the whole book to JSON (lossless): pool as bank-zero + named banks
|
||||
// + per-bank indices + ordinals + active id. deserialize(serialize(x)) == x.
|
||||
std::string serialize() const;
|
||||
|
||||
// Parses a book JSON produced by serialize(). std::nullopt on malformed input.
|
||||
//
|
||||
// LEGACY MIGRATION: a bare legacy bank_index JSON (the pre-multi-bank shape, an
|
||||
// object with a "samples" array and no "banks" key) is promoted into the pool's
|
||||
// index, yielding a book of { pool } with zero named banks — one-way, lossless.
|
||||
// After migration the book blob is authoritative (the caller persists the book
|
||||
// shape going forward; the legacy key is retired by the B2 shell).
|
||||
// A bare legacy bank_index JSON (pre-multi-bank shape: a "samples" array, no
|
||||
// "banks" key) is promoted into the pool's index — one-way, lossless — yielding
|
||||
// a book of { pool } with zero named banks.
|
||||
static std::optional<BankBook> deserialize(const std::string& json);
|
||||
|
||||
// Resolve a BankBook from the two persisted ext-state values a project may carry:
|
||||
// the authoritative `banks` blob and the retired-but-possibly-present legacy
|
||||
// `bank_index` blob. The persist shell (B2) hands both raw strings straight here so
|
||||
// the load-source decision stays REAPER-free and unit-tested. Precedence:
|
||||
// 1. non-empty `banksJson` present -> deserialize it (authoritative). If it is
|
||||
// MALFORMED, do NOT silently fall back to the legacy blob — a corrupt `banks`
|
||||
// blob is an error, not an absence; return an empty book so a stale legacy key
|
||||
// can never resurrect a superseded single-bank state over a broken book.
|
||||
// 2. else non-empty `legacyJson` -> deserialize it (one-way pool migration).
|
||||
// 3. else (both absent/empty) -> a fresh empty book (pool only).
|
||||
// Never returns nullopt: an unloadable input degrades to the empty book (matching
|
||||
// the shell's existing "malformed -> ignore, start empty" behaviour), so the caller
|
||||
// has one branchless install path.
|
||||
// Resolves a BankBook from the two persisted ext-state values a project may
|
||||
// carry: the authoritative `banksJson` and the retired legacy `bank_index` blob.
|
||||
// 1. non-empty banksJson -> deserialize it. If malformed, do NOT fall back to
|
||||
// legacy — a corrupt banks blob is an error, not an absence; returns an
|
||||
// empty book so a stale legacy key can never resurrect superseded state.
|
||||
// 2. else non-empty legacyJson -> deserialize it (pool migration).
|
||||
// 3. else -> a fresh empty book (pool only).
|
||||
// Never returns nullopt — an unloadable input degrades to the empty book.
|
||||
static BankBook loadFromPersisted(const std::string& banksJson,
|
||||
const std::string& legacyJson);
|
||||
|
||||
@@ -343,41 +250,34 @@ private:
|
||||
std::string activeBankId_; // always names a live bank; defaults to pool
|
||||
|
||||
// Folds a display name to its uniqueness key: strip leading/trailing ASCII
|
||||
// whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share
|
||||
// one key and cannot coexist. ASCII-only by design — the pure core carries no
|
||||
// locale facility and must not grow one. A private STATIC member (Q-W5, settled)
|
||||
// because BOTH halves of the split implementation need the ONE folding rule: the
|
||||
// rules TU (bank_book.cpp, displayNameTaken) and the JSON TU (bank_book_json.cpp,
|
||||
// deserialize's duplicate-display-name coalesce) — a drifted second copy would let
|
||||
// a parsed book violate the create/rename uniqueness invariant.
|
||||
// whitespace, lower-case ASCII letters — so "Drums"/"drums"/" Drums " share one
|
||||
// key. ASCII-only by design — the pure core carries no locale facility. Private
|
||||
// static because both halves of the split implementation (rules + JSON) need the
|
||||
// one folding rule; a drifted second copy would let a parsed book violate the
|
||||
// create/rename uniqueness invariant.
|
||||
static std::string nameKey(const std::string& s);
|
||||
|
||||
// True if a bank OTHER than `exceptId` already carries `name`'s uniqueness key
|
||||
// (trimmed + case-insensitive, ASCII). Backs the create/rename uniqueness check;
|
||||
// pass exceptId=id to let a bank keep (or re-case/-space) its own name.
|
||||
// True if a bank other than exceptId already carries name's uniqueness key.
|
||||
// Backs the create/rename uniqueness check; pass exceptId=id to let a bank keep
|
||||
// (or re-case/-space) its own name.
|
||||
bool displayNameTaken(const std::string& name, const std::string& exceptId) const;
|
||||
|
||||
// Re-sorts banks_ by ordinal (pool pinned first) and rewrites ordinals to a
|
||||
// contiguous 0..N-1 so the pool is 0 and named banks are 1..N. Called after any
|
||||
// structural change (create / delete / reorder).
|
||||
// contiguous 0..N-1. Called after any structural change (create/delete/reorder).
|
||||
void normalizeOrdinals();
|
||||
|
||||
// Replaces the book's banks with a parsed set, normalizes ordinals, and resolves
|
||||
// the active bank (falling back to the pool if the id names no bank). Used only
|
||||
// by deserialize; kept private so the public surface stays create/rename/etc.
|
||||
// by deserialize.
|
||||
void adoptBanks(std::vector<Bank>&& banks, const std::string& activeBank);
|
||||
};
|
||||
|
||||
// The next bank id to activate when cycling the active bank forward, in ordinal
|
||||
// order (the ids arrive pool-first, named 1..N, matching banks()). Wraps: the id
|
||||
// after the last returns the first (pool → named → … → pool). This is the pure
|
||||
// decision behind the "cycle active bank" action — the shell reads the book's
|
||||
// ordered bank ids + current active id, asks for the next, and activates it.
|
||||
// order (pool -> named -> ... -> pool, wraps). Free function (not a member) so it's
|
||||
// unit-testable against a bare id vector without a full book.
|
||||
// * empty list -> "" (nothing to cycle to)
|
||||
// * single id (pool-only) -> that id (a one-bank book stays put)
|
||||
// * currentBankId not present -> the first id (a sane home to jump to)
|
||||
// Exposed as a free function (not a BankBook member) so it is unit-testable against
|
||||
// a bare id vector without a full book. Mirror of view_mode_model's nextModeId.
|
||||
std::string nextBankId(const std::vector<std::string>& orderedBankIds,
|
||||
const std::string& currentBankId);
|
||||
|
||||
|
||||
@@ -6,33 +6,25 @@
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
// bank_book JSON round-trip (Q-W5 extraction out of bank_book.cpp — same header,
|
||||
// compiled into the same bank_book target; the slot_map second-TU shape). The
|
||||
// registry RULES half stays in bank_book.cpp; the ONE shared symbol is the private
|
||||
// static BankBook::nameKey folding rule (declared in bank_book.h) — the parse-time
|
||||
// duplicate-display-name coalesce below must fold names EXACTLY as the create/rename
|
||||
// uniqueness check does, or a parsed book could violate the in-model invariant.
|
||||
// bank_book JSON round-trip — a sibling TU to bank_book.cpp, sharing its header
|
||||
// and target. The registry RULES half stays in bank_book.cpp; the one shared
|
||||
// symbol is the private static BankBook::nameKey folding rule — the parse-time
|
||||
// duplicate-display-name coalesce below must fold names EXACTLY as create/rename
|
||||
// uniqueness does, or a parsed book could violate the in-model invariant.
|
||||
//
|
||||
// JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model
|
||||
// and view_mode_model. The book blob nests one bank object per bank, each carrying that
|
||||
// bank's BankModel serialized by bank_model's OWN writer (BankModel::serialize),
|
||||
// so per-bank sample serialization stays owned by bank_model and is not duplicated
|
||||
// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a
|
||||
// raw "index" member whose value is the BankModel blob verbatim; the parser splits
|
||||
// the book envelope, then hands each nested index blob straight to
|
||||
// BankModel::deserialize. Ints use %d; strings are escaped by writeEscaped.
|
||||
// BYTE-IDENTICAL to the pre-extraction writer — the Q-W1 golden-literal test pins it.
|
||||
// The book blob nests one bank object per bank, each carrying that bank's
|
||||
// BankModel serialized by bank_model's OWN writer, so per-bank sample
|
||||
// serialization stays owned by bank_model and is not duplicated here. The book
|
||||
// writer emits the bank envelope (id / displayName / ordinal) plus a raw "index"
|
||||
// member whose value is the BankModel blob verbatim; the parser splits the book
|
||||
// envelope, then hands each nested index blob straight to BankModel::deserialize.
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// ===========================================================================
|
||||
// JSON — writer
|
||||
// ===========================================================================
|
||||
// -- JSON — writer ----------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
// Shared core/json emit helpers (Q-W1): the same escape set + %d rendering the
|
||||
// prior file-local writer carried, so the emitted blob is byte-identical.
|
||||
std::string intToStr(int v) { return json::numToStr(v); }
|
||||
using ObjWriter = json::Writer;
|
||||
|
||||
@@ -58,8 +50,8 @@ std::string BankBook::serialize() const {
|
||||
// The nested index is bank_model's own JSON, emitted verbatim so the
|
||||
// per-sample shape stays owned by BankModel::serialize (not duplicated).
|
||||
b.keyRaw("index", banks_[i].index.serialize());
|
||||
// L7 display positions (gap-preserving). Absent on a pre-L7 blob; the
|
||||
// parser defaults such a bank's slots from insertion order on load.
|
||||
// Display positions (gap-preserving). Absent on a pre-existing blob;
|
||||
// the parser defaults such a bank's slots from insertion order on load.
|
||||
b.keyRaw("slots", banks_[i].slots.serialize());
|
||||
}
|
||||
out += ']';
|
||||
@@ -67,13 +59,10 @@ std::string BankBook::serialize() const {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// JSON — parser (recursive descent; std::nullopt on any malformed input, never UB)
|
||||
// ===========================================================================
|
||||
// -- JSON — parser (recursive descent; std::nullopt on malformed input, never UB) --
|
||||
|
||||
namespace {
|
||||
|
||||
// The book DOMAIN grammar over the shared core/json lexical layer (Q-W1).
|
||||
// parseBank parses one bank object; parseSlots the "slots" array ([{id, slot},
|
||||
// ...]) into (id, slot) pairs (empty array valid; the pair-level defensive
|
||||
// repair — dupes/conflicts — lives in SlotMap::fromEntries); parseBook the root
|
||||
@@ -112,9 +101,9 @@ bool parseBank(json::Reader& r, Bank& b) {
|
||||
b.index = std::move(*idx);
|
||||
haveIndex = true;
|
||||
} else if (key == "slots") {
|
||||
// L7 display positions. Absent on a pre-L7 blob (the else-branch skips
|
||||
// nothing because the key never appears); when present it drives the
|
||||
// bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership.
|
||||
// Display positions. Absent on a pre-existing blob; when present it
|
||||
// drives the bank's SlotMap. reconcileSlots() (post-adopt) squares it
|
||||
// with membership.
|
||||
std::vector<std::pair<std::string, int>> pairs;
|
||||
if (!parseSlots(r, pairs)) return false;
|
||||
b.slots = SlotMap::fromEntries(pairs);
|
||||
@@ -186,9 +175,7 @@ bool parseBook(json::Reader& r, const std::string& raw, std::vector<Bank>& banks
|
||||
} else if (key == "activeBank") {
|
||||
if (!r.parseString(activeBank)) return false;
|
||||
} else if (key == "samples") {
|
||||
// Legacy marker. The legacy index is re-parsed from the whole input below
|
||||
// (BankModel::deserialize owns that shape); here we only skip the value to
|
||||
// keep the scan well-formed and note that we saw it.
|
||||
// Legacy marker; the legacy index is re-parsed from the whole input below.
|
||||
sawSamples = true;
|
||||
if (!r.skipValue()) return false;
|
||||
} else {
|
||||
@@ -253,23 +240,20 @@ std::optional<BankBook> BankBook::deserialize(const std::string& blob) {
|
||||
json::Reader r(blob);
|
||||
if (!parseBook(r, blob, banks, activeBank)) return std::nullopt;
|
||||
|
||||
// --- Coalesce duplicate folded display names (B4 re-review fold-in). --------
|
||||
// --- Coalesce duplicate folded display names. --------------------------
|
||||
// The in-model create/rename path enforces unique display names under nameKey,
|
||||
// but a hand-edited .rpp blob can smuggle in two banks whose names fold to the
|
||||
// same key ("Drums" and " drums "). Rejecting the whole book over one collision
|
||||
// would degrade the user's entire library to empty, so instead we AUTO-
|
||||
// DISAMBIGUATE the later duplicate deterministically: scan in parse order, and
|
||||
// the first time a folded key repeats, suffix that bank's display name (" 2",
|
||||
// " 3", …) until its folded key is unique among all names seen so far. The FIRST
|
||||
// bank to carry a key keeps its name verbatim; only subsequent collisions are
|
||||
// renamed. No bank or sample is lost, and ids are untouched. The pool is included
|
||||
// in the seen-set (its "Pool" key is reserved) so a named bank folding to "pool"
|
||||
// DISAMBIGUATE the later duplicate: scan in parse order, and the first time a
|
||||
// folded key repeats, suffix that bank's display name (" 2", " 3", …) until its
|
||||
// folded key is unique among names seen so far — the first bank to carry a key
|
||||
// keeps its name verbatim. No bank or sample is lost, ids are untouched, and the
|
||||
// pool's reserved "Pool" key is seeded first so a named bank folding to "pool"
|
||||
// is disambiguated away from it, never the reverse.
|
||||
//
|
||||
// Hosted HERE (a static member, Q-W5) rather than in the free parseBook because
|
||||
// it folds through the PRIVATE BankBook::nameKey — the same rule the
|
||||
// create/rename uniqueness check applies. Runs after parseBook on BOTH shapes;
|
||||
// the legacy path yields { pool } alone, where the scan is a trivial no-op.
|
||||
// Hosted here (not in the free parseBook) because it folds through the PRIVATE
|
||||
// BankBook::nameKey the create/rename uniqueness check also uses.
|
||||
{
|
||||
std::vector<std::string> seenKeys;
|
||||
seenKeys.reserve(banks.size());
|
||||
|
||||
@@ -4,21 +4,14 @@
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
// bank_model implementation.
|
||||
//
|
||||
// JSON rides on the shared core/json lexical layer (Q-W1: one reader/writer,
|
||||
// no per-module Parser copy). The field set is a flat struct of primitives,
|
||||
// strings, one enum, a small string array, and a few optionals, so a compact
|
||||
// writer + recursive-descent DOMAIN parser over json::Reader is the simplest
|
||||
// thing that works. Doubles are emitted with 17 significant digits (%.17g), the
|
||||
// shortest form that round-trips every IEEE-754 double exactly, so the
|
||||
// deserialize(serialize(x)) == x invariant holds bit-for-bit.
|
||||
// bank_model implementation. JSON rides on the shared core/json lexical layer;
|
||||
// only the Sample/index DOMAIN grammar lives here. Doubles are emitted with 17
|
||||
// significant digits (%.17g), the shortest form that round-trips every
|
||||
// IEEE-754 double exactly, so deserialize(serialize(x)) == x holds bit-for-bit.
|
||||
|
||||
namespace reasampler::model {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// equality
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- equality -----------------------------------------------------------
|
||||
|
||||
bool SourceRange::operator==(const SourceRange& o) const {
|
||||
return startSeconds == o.startSeconds && endSeconds == o.endSeconds &&
|
||||
@@ -51,20 +44,15 @@ bool Sample::operator==(const Sample& o) const {
|
||||
provenance == o.provenance && createdTimestamp == o.createdTimestamp;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// path invariant
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- path invariant -------------------------------------------------------
|
||||
|
||||
// DECISION: reject absolute paths rather than normalize them. The pure model has
|
||||
// no knowledge of the project root, so it cannot correctly relativize an absolute
|
||||
// path — any "normalization" would be a guess that could point at the wrong file.
|
||||
// Rejecting at the boundary is honest and deterministic; the capture backend (M3)
|
||||
// is responsible for handing us an already-relative path. Covers POSIX ("/x"),
|
||||
// Windows drive ("C:\x", "C:/x", "C:foo" drive-relative), and UNC ("\\host\share")
|
||||
// forms. Any leading <alpha>: is rejected regardless of the character that follows —
|
||||
// drive-relative paths ("C:foo.wav") resolve against the drive's current directory,
|
||||
// not the project root, so they violate the relative-paths-only invariant just as
|
||||
// much as "C:\foo.wav" does.
|
||||
// Rejects absolute paths rather than normalizing them: the pure model has no
|
||||
// knowledge of the project root, so any "normalization" would be a guess that
|
||||
// could point at the wrong file. Covers POSIX ("/x"), Windows drive ("C:\x",
|
||||
// "C:/x", "C:foo" drive-relative), and UNC ("\\host\share") forms. Any leading
|
||||
// <alpha>: is rejected regardless of what follows — drive-relative paths
|
||||
// ("C:foo.wav") resolve against the drive's current directory, not the project
|
||||
// root, so they violate relative-paths-only just as much as "C:\foo.wav" does.
|
||||
static bool isAbsolutePath(const std::string& p) {
|
||||
if (p.empty()) return false;
|
||||
if (p[0] == '/' || p[0] == '\\') return true; // POSIX root or UNC
|
||||
@@ -73,9 +61,7 @@ static bool isAbsolutePath(const std::string& p) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BankModel
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- BankModel ------------------------------------------------------------
|
||||
|
||||
AddResult BankModel::add(const Sample& sample) {
|
||||
if (sample.id.empty()) return AddResult::RejectedEmptyId;
|
||||
@@ -139,9 +125,7 @@ std::vector<Sample> BankModel::byTier(Tier tier) const {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON writer
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- JSON writer ------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -182,9 +166,9 @@ void writeSample(std::string& out, const Sample& s) {
|
||||
w.keyBegin("key");
|
||||
if (s.key) writeEscaped(out, *s.key); else out += "null";
|
||||
|
||||
// Phase S seam fields (D-B). Emitted as null when absent (same shape as `key`
|
||||
// and `provenance`) so pre-Phase-S JSON — which lacks these keys entirely —
|
||||
// parses to empty optionals and re-serializes without invention.
|
||||
// Emitted as null when absent (same shape as `key`/`provenance`) so JSON that
|
||||
// lacks these keys entirely parses to empty optionals and re-serializes
|
||||
// without invention.
|
||||
w.keyBegin("rootNote");
|
||||
if (s.rootNote) out += numToStr(*s.rootNote); else out += "null";
|
||||
|
||||
@@ -240,12 +224,9 @@ std::string BankModel::serialize() const {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON parser (recursive descent over the shared json::Reader). Returns false
|
||||
// on any malformed input; never reads out of bounds. Only supports the subset
|
||||
// our writer emits. The lexical layer (strings, numbers, skip) lives in
|
||||
// core/json; only the Sample/index DOMAIN grammar lives here.
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- JSON parser (recursive descent over the shared json::Reader) -----------
|
||||
// Returns false on any malformed input; never reads out of bounds. Only
|
||||
// supports the subset our writer emits.
|
||||
|
||||
namespace {
|
||||
|
||||
|
||||
+35
-44
@@ -1,11 +1,7 @@
|
||||
#pragma once
|
||||
// bank_model — the HEART of ReaSampler, deliberately free of any REAPER type so
|
||||
// it compiles and unit-tests OUTSIDE the DAW. It owns the per-project sample
|
||||
// bank: the `Sample` metadata struct and the `BankModel` (add / remove / query /
|
||||
// tier moves / dedup-by-hash + JSON round-trip to/from std::string).
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only.
|
||||
// bank_model — the HEART of ReaSampler: the per-project sample bank. `Sample`
|
||||
// metadata struct + `BankModel` (add/remove/query/tier moves/dedup-by-hash + JSON
|
||||
// round-trip to/from std::string).
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
@@ -14,8 +10,8 @@
|
||||
|
||||
namespace reasampler::model {
|
||||
|
||||
// How the source audio was obtained. Kept in the pure core (no REAPER coupling);
|
||||
// the capture backends (M3/M8) map their own notion onto these.
|
||||
// How the source audio was obtained; the capture backends map their own notion
|
||||
// onto these.
|
||||
enum class SourceMode {
|
||||
MasterMix, // offline render of the master output
|
||||
SelectedTracks, // offline render of selected tracks
|
||||
@@ -31,9 +27,8 @@ enum class Tier {
|
||||
Archive,
|
||||
};
|
||||
|
||||
// Sample-accurate source bounds, in both project seconds and PPQ (ticks). Both
|
||||
// are stored because capture needs seconds and musical placement needs PPQ; we
|
||||
// refuse to re-derive one from the other and risk rounding (precision invariant).
|
||||
// Sample-accurate source bounds, in both project seconds and PPQ (ticks) — both
|
||||
// stored so capture doesn't re-derive one from the other and risk rounding.
|
||||
struct SourceRange {
|
||||
double startSeconds = 0.0;
|
||||
double endSeconds = 0.0;
|
||||
@@ -44,9 +39,9 @@ struct SourceRange {
|
||||
};
|
||||
|
||||
// Present only when a sample was resampled FROM another sample. Carries the
|
||||
// parent's id and the FX-chain snapshot string (a thin drift fingerprint, NOT a
|
||||
// restorable chunk) captured at resample time; the re-capture-from-source action
|
||||
// (M10) uses it to detect chain drift and replay the original capture request.
|
||||
// parent's id and an FX-chain snapshot (a thin drift fingerprint, NOT a restorable
|
||||
// chunk) — re-capture-from-source uses it to detect chain drift and replay the
|
||||
// original capture request.
|
||||
struct Provenance {
|
||||
std::string parentSampleId;
|
||||
std::string fxChainSnapshot;
|
||||
@@ -63,15 +58,13 @@ struct Levels {
|
||||
bool operator==(const Levels& o) const;
|
||||
};
|
||||
|
||||
// Sample-accurate sustain-loop bounds, as frame indices into the captured file
|
||||
// (Phase S seam field, D-B). A bank intrinsic — a fact about the file, like
|
||||
// sampleRate or length — consumed by the future MIDI-playback instrument to hold
|
||||
// notes past the recorded length. Modeled as one optional struct (not two loose
|
||||
// optionals) so "both points or neither" is a structural invariant, not a rule to
|
||||
// re-check at every boundary. Frame indices, not seconds, because the loop is a
|
||||
// per-sample-frame contract; the instrument reads the file's sample rate to relate
|
||||
// them to time. Invariant (enforced at the deserialize boundary): 0 <= start <= end.
|
||||
// start == end is a valid zero-length loop marker.
|
||||
// Sample-accurate sustain-loop bounds, as frame indices into the captured file — a
|
||||
// bank intrinsic (like sampleRate or length) the MIDI-playback instrument uses to
|
||||
// hold notes past the recorded length. One optional struct (not two loose
|
||||
// optionals) so "both points or neither" is structural, not a rule to re-check at
|
||||
// every boundary. Frame indices, not seconds — the instrument relates them to time
|
||||
// via the file's sample rate. Invariant (enforced at deserialize): 0 <= start <=
|
||||
// end; start == end is a valid zero-length loop marker.
|
||||
struct LoopPoints {
|
||||
std::int64_t start = 0;
|
||||
std::int64_t end = 0;
|
||||
@@ -102,23 +95,23 @@ struct Sample {
|
||||
double lengthBeats = 0.0;
|
||||
double captureTempo = 0.0; // project tempo (BPM) at capture time
|
||||
|
||||
// Time signature at capture time (L7 F1 — stamped alongside captureTempo so the
|
||||
// bars.beats.subdivisions read-out is stable under later project meter changes).
|
||||
// 0/0 means UNSTAMPED (pre-L7 sample, or a capture that could not read the meter);
|
||||
// the metadata formatter renders a blank musical read-out for 0/0 and keeps s.ms.
|
||||
// Time signature at capture time, stamped alongside captureTempo so the
|
||||
// bars.beats.subdivisions read-out is stable under later project meter changes.
|
||||
// 0/0 means UNSTAMPED (pre-existing sample, or a capture that could not read the
|
||||
// meter); the metadata formatter renders a blank musical read-out then, keeping s.ms.
|
||||
int captureTimeSigNum = 0; // meter numerator (e.g. 4 in 4/4); 0 = unstamped
|
||||
int captureTimeSigDenom = 0; // meter denominator (e.g. 4 in 4/4); 0 = unstamped
|
||||
|
||||
std::optional<std::string> key; // musical key, when known
|
||||
|
||||
// Phase S seam fields (D-B) — bank intrinsics for the MIDI-playback instrument,
|
||||
// additive like `provenance` (M1). Both default cleanly empty: pre-Phase-S
|
||||
// samples deserialize without them and re-serialize without inventing values.
|
||||
// Bank intrinsics for the MIDI-playback instrument, additive like `provenance`.
|
||||
// Both default cleanly empty: pre-existing samples deserialize without them and
|
||||
// re-serialize without inventing values.
|
||||
// - rootNote: MIDI note (0..127) the sample was recorded at, so the instrument
|
||||
// can repitch it across the keyboard. DISTINCT from the musical `key` above:
|
||||
// `key` is a human label ("F#m"); `rootNote` is the exact pitch for repitch.
|
||||
// Populated at/after capture only where derivable — left empty (never guessed)
|
||||
// when the source is not a single played note.
|
||||
// Populated only where derivable — never guessed when the source isn't a
|
||||
// single played note.
|
||||
// - loop: sustain-loop bounds, populated only where explicitly set.
|
||||
std::optional<int> rootNote;
|
||||
std::optional<LoopPoints> loop;
|
||||
@@ -156,7 +149,7 @@ enum class AddResult {
|
||||
|
||||
// An ordered, id-keyed collection of Samples with content-hash dedup, tier
|
||||
// moves/filtering, and lossless JSON round-trip. Insertion order is preserved
|
||||
// so a future panel (M5) can iterate in stable order.
|
||||
// so a panel can iterate in stable order.
|
||||
class BankModel {
|
||||
public:
|
||||
// Adds a sample. Enforces the relative-paths-only invariant and dedups by
|
||||
@@ -168,16 +161,14 @@ public:
|
||||
bool remove(const std::string& id);
|
||||
|
||||
// Replaces the sample carrying `id` IN PLACE (preserving its position in
|
||||
// insertion order), with `updated`. Used by M10 re-capture-from-source: a
|
||||
// provenanced sample's file is regenerated and its metadata (relativePath,
|
||||
// contentHash, levels, timestamp, ...) refreshed while its identity (id) and
|
||||
// slot are kept, so the bank panel shows the same tile updated rather than a
|
||||
// reordered new entry. `updated.id` should equal `id` (the caller keeps the id
|
||||
// stable); a differing id is written through as given (the caller's contract).
|
||||
// Does NOT dedup — an in-place refresh of one entry is not a new insert, so the
|
||||
// collapse-by-hash rule (which guards NEW inserts) does not apply. Returns false
|
||||
// (no mutation) if `id` is absent or `updated.relativePath` is absolute
|
||||
// (the relative-paths-only invariant still holds for the replacement).
|
||||
// insertion order) with `updated`. Used by re-capture-from-source: a
|
||||
// provenanced sample's file is regenerated and its metadata refreshed while
|
||||
// its identity (id) and slot are kept, so the panel shows the same tile
|
||||
// updated rather than a reordered new entry. `updated.id` should equal `id`;
|
||||
// a differing id is written through as given. Does NOT dedup — an in-place
|
||||
// refresh is not a new insert, so collapse-by-hash (which guards inserts)
|
||||
// does not apply. Returns false (no mutation) if `id` is absent or
|
||||
// `updated.relativePath` is absolute (relative-paths-only still holds here).
|
||||
bool updateInPlace(const std::string& id, const Sample& updated);
|
||||
|
||||
// Returns the sample with `id`, or nullptr if absent. The pointer is
|
||||
|
||||
@@ -4,27 +4,21 @@
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
// owned_manifest implementation.
|
||||
//
|
||||
// JSON rides on the shared core/json lexical layer (Q-W1, mirror of bank_model /
|
||||
// bank_book / tail_control). The shape is a single object with one string array:
|
||||
// owned_manifest implementation. JSON shape is a single object with one string
|
||||
// array:
|
||||
//
|
||||
// {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]}
|
||||
//
|
||||
// so a compact writer + a focused string-array domain parse is all it needs.
|
||||
|
||||
namespace reasampler::model {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// path invariant (mirror of bank_model's isAbsolutePath)
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- path invariant (mirror of bank_model's isAbsolutePath) ----------------
|
||||
|
||||
namespace {
|
||||
|
||||
// Any leading '/' or '\' (POSIX root / UNC), or a leading <alpha>: (Windows drive,
|
||||
// incl. drive-relative "C:foo") is absolute. Same rejection bank_model applies to
|
||||
// Sample.relativePath — the manifest holds the SAME kind of path, so the invariant
|
||||
// must match exactly (a path the index accepts must be recordable, and vice versa).
|
||||
// incl. drive-relative "C:foo") is absolute — same rejection bank_model applies to
|
||||
// Sample.relativePath; the manifest holds the same kind of path, so the invariant
|
||||
// must match exactly.
|
||||
bool isAbsolutePath(const std::string& p) {
|
||||
if (p.empty()) return false;
|
||||
if (p[0] == '/' || p[0] == '\\') return true;
|
||||
@@ -35,9 +29,7 @@ bool isAbsolutePath(const std::string& p) {
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mutation / query
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- mutation / query -------------------------------------------------------
|
||||
|
||||
ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) {
|
||||
if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath;
|
||||
@@ -53,9 +45,7 @@ bool OwnedFileManifest::contains(const std::string& relativePath) const {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON writer (shared core/json escape — byte-identical to the prior local one)
|
||||
// ---------------------------------------------------------------------------
|
||||
// -- JSON writer --------------------------------------------------------
|
||||
|
||||
std::string OwnedFileManifest::serialize() const {
|
||||
std::string out = "{\"owned\":[";
|
||||
@@ -67,11 +57,8 @@ std::string OwnedFileManifest::serialize() const {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON parser (string-array-only DOMAIN grammar over the shared core/json
|
||||
// lexical layer). Tolerates unknown keys (forward-compat) and requires the
|
||||
// "owned" value to be an array of strings.
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON parser: string-array-only grammar. Tolerates unknown keys and requires
|
||||
// the "owned" value to be an array of strings.
|
||||
|
||||
namespace {
|
||||
|
||||
|
||||
@@ -1,31 +1,16 @@
|
||||
#pragma once
|
||||
// owned_manifest — the pure core of the owned-file manifest seam (Phase B, B-cap).
|
||||
// owned_manifest — the set of files the bank system ITSELF created; every file the
|
||||
// capture path writes gets recorded here so prune can tell the system's own orphans
|
||||
// (owned ∩ present − referenced) apart from hand-dropped files. Writes and persists
|
||||
// the manifest only — no prune logic lives here.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the same
|
||||
// "small pure type + JSON round-trip" pattern as wav_codec / tab_strip.
|
||||
// NOT a mirror of the bank index: removing/moving an index entry does NOT remove
|
||||
// the file's manifest record (the manifest tracks files *created*; prune reconciles
|
||||
// manifest-vs-index later). Only the capture add-path adds to it — no remove verb.
|
||||
//
|
||||
// -- What it is --------------------------------------------------------------
|
||||
//
|
||||
// The set of files the bank system ITSELF created — every file the capture path
|
||||
// writes gets recorded here. Phase R prune consumes it to tell the system's own
|
||||
// orphans (owned ∩ present − referenced) apart from hand-dropped files. B-cap only
|
||||
// WRITES and PERSISTS the manifest; no prune logic lives here (fork R-D, settled
|
||||
// 2026-07-24: "defer the feature, design the seam").
|
||||
//
|
||||
// -- What it is NOT ----------------------------------------------------------
|
||||
//
|
||||
// It is NOT a mirror of the bank index. Removing or moving an index entry does NOT
|
||||
// remove the file's manifest record: the manifest tracks files *created*, and prune
|
||||
// (Phase R) reconciles manifest-vs-index later. The ONLY thing that adds to it is
|
||||
// the capture add-path. There is deliberately no remove verb here.
|
||||
//
|
||||
// -- The relative-paths-only invariant ---------------------------------------
|
||||
//
|
||||
// A manifest path is ALWAYS project-relative (same invariant as Sample.relativePath
|
||||
// and the persisted BankModel). add() rejects an absolute path rather than guess a
|
||||
// relativization — the pure model has no project root, so a "normalization" would be
|
||||
// a guess that could point at the wrong file (mirror of BankModel::add's rejection).
|
||||
// Paths are ALWAYS project-relative (same invariant as Sample.relativePath). add()
|
||||
// rejects an absolute path rather than guess a relativization — the pure model has
|
||||
// no project root, so "normalizing" could point at the wrong file.
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -58,12 +43,12 @@ public:
|
||||
// capture of an identical request does not double-record.
|
||||
ManifestAddResult add(const std::string& relativePath);
|
||||
|
||||
// True iff the exact path string is recorded. Phase R uses this to attribute a
|
||||
// present file to the bank system. Exact string match — path normalization (if any)
|
||||
// is the caller's concern, consistent across add and query.
|
||||
// True iff the exact path string is recorded. Prune uses this to attribute a
|
||||
// present file to the bank system. Exact string match — path normalization (if
|
||||
// any) is the caller's concern, consistent across add and query.
|
||||
bool contains(const std::string& relativePath) const;
|
||||
|
||||
// The owned paths in insertion order. Phase R unions this with the on-disk file
|
||||
// The owned paths in insertion order. Prune unions this with the on-disk file
|
||||
// set; here it is the round-trip + query surface.
|
||||
const std::vector<std::string>& paths() const { return paths_; }
|
||||
|
||||
|
||||
@@ -4,26 +4,16 @@
|
||||
|
||||
#include "core/wire/wire.h"
|
||||
|
||||
// provenance implementation — pure, self-contained (no third-party lib, mirror of
|
||||
// bank_model's hand-rolled encoding discipline).
|
||||
// provenance implementation — pure, self-contained encoding.
|
||||
//
|
||||
// ENCODING (the fingerprint string): a length-prefixed, field-ordered format so it
|
||||
// is unambiguous and forge-proof (a value containing the separator cannot shift
|
||||
// the parse). Grammar:
|
||||
//
|
||||
// "rsprov1" -- magic + version tag
|
||||
// then, in fixed order, each field as <len>':'<bytes>
|
||||
//
|
||||
// Every field — including numbers — is emitted as its decimal / %.17g text then
|
||||
// length-prefixed, so the parser never has to guess a field boundary. A trailing
|
||||
// field is the track-GUID count followed by that many length-prefixed GUIDs, then
|
||||
// the folded fxChainIdentity. Numbers use the SAME %.17g the bank model uses so a
|
||||
// double round-trips bit-for-bit. Any deviation (wrong magic, short read, bad
|
||||
// number) -> parseFingerprint returns nullopt.
|
||||
//
|
||||
// The fxChainIdentity fold is itself length-prefixed per entry field, so it is
|
||||
// injection-proof on its own and can be embedded whole as one more length-prefixed
|
||||
// field of the fingerprint.
|
||||
// Fingerprint grammar: magic "rsprov1" + fixed-order length-prefixed fields
|
||||
// (<len>':'<bytes>), so a value containing the separator can never shift the
|
||||
// parse. Numbers render as decimal/%.17g text before prefixing (same %.17g the
|
||||
// bank model uses, so doubles round-trip bit-for-bit). Trailing fields: the
|
||||
// track-GUID count + that many GUIDs, then the folded fxChainIdentity — itself
|
||||
// length-prefixed per entry field, so it nests safely as one more field. Any
|
||||
// deviation (bad magic, short read, bad number) -> parseFingerprint returns
|
||||
// nullopt.
|
||||
|
||||
namespace reasampler::model {
|
||||
|
||||
@@ -39,10 +29,9 @@ namespace {
|
||||
|
||||
constexpr const char* kMagic = "rsprov1";
|
||||
|
||||
// The shared core/wire codec (Q-W1, T2-01b) carries the field grammar + the full
|
||||
// hardening (incl. the fixed fieldInt range check that closes the old strtol
|
||||
// silent-narrowing TODO). Only the %.17g double rendering stays local — it is
|
||||
// this writer's convention, shared with the bank model's JSON doubles.
|
||||
// The shared core/wire codec carries the field grammar + range-checked fieldInt.
|
||||
// Only the %.17g double rendering stays local — this writer's convention, shared
|
||||
// with the bank model's JSON doubles.
|
||||
using wire::putField;
|
||||
using Cursor = wire::Cursor;
|
||||
|
||||
@@ -112,10 +101,9 @@ std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint) {
|
||||
|
||||
std::size_t guidCount = 0;
|
||||
if (!c.fieldSizeT(guidCount)) return std::nullopt;
|
||||
// Q-W0 T2-01a (the sample_usage count-sanity pattern): each GUID field costs at least
|
||||
// 2 wire bytes ("0:"), so a count past size/2 is provably bogus — reject BEFORE the
|
||||
// reserve, so a corrupt/crafted persisted fingerprint can never drive reserve(huge)
|
||||
// into std::length_error / bad_alloc through the shell.
|
||||
// Each GUID field costs at least 2 wire bytes ("0:"), so a count past size/2 is
|
||||
// provably bogus — reject BEFORE the reserve, so a corrupt/crafted persisted
|
||||
// fingerprint can never drive reserve(huge) into std::length_error / bad_alloc.
|
||||
if (guidCount > fingerprint.size() / 2u + 1u) return std::nullopt;
|
||||
r.trackGuids.reserve(guidCount);
|
||||
for (std::size_t i = 0; i < guidCount; ++i) {
|
||||
@@ -139,7 +127,6 @@ std::optional<std::string> detectParent(
|
||||
|
||||
std::optional<std::string> parent; // the single bank sample all sources point at
|
||||
for (const std::string& src : sourceItemFiles) {
|
||||
// Resolve this source file against the bank by exact normalized path.
|
||||
const std::string* matchedId = nullptr;
|
||||
for (const BankFileRef& ref : bankFiles) {
|
||||
if (!ref.absolutePath.empty() && ref.absolutePath == src) {
|
||||
|
||||
+23
-47
@@ -1,35 +1,17 @@
|
||||
#pragma once
|
||||
// provenance — the REAPER-free core behind Milestone 10 (re-capture from source).
|
||||
// provenance — the REAPER-free core behind re-capture-from-source. The shell
|
||||
// gathers raw inputs from REAPER (source media-file names, FX-chain identity,
|
||||
// capture range/scope/tail) and hands plain strings/values here. Owns:
|
||||
// * CaptureRecipe — the recorded request + source FX-chain identity, so
|
||||
// re-capture can re-run the same request and detect drift.
|
||||
// * fingerprint codec — encodes/decodes a recipe into the single
|
||||
// Provenance.fxChainSnapshot string (no schema change).
|
||||
// * fxChainIdentity — folds FX-chain rows into one drift-detection string.
|
||||
// * detectParent — pure parent-detection: resolved file path only, no
|
||||
// fuzzy match, no false parentage.
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. The shell (main.cpp / the action families)
|
||||
// gathers the raw inputs from REAPER — the source item media-file names, the
|
||||
// source track FX-chain identity (names / GUIDs / enabled flags), the exact
|
||||
// capture range, scope, tail — and hands plain strings/values here. This module
|
||||
// owns:
|
||||
//
|
||||
// * CaptureRecipe — the recorded capture request PLUS the source FX-chain
|
||||
// identity at capture time. Everything "re-capture from
|
||||
// source" needs to re-run the SAME request against the
|
||||
// source's CURRENT state, and to tell whether the source
|
||||
// drifted since capture.
|
||||
// * the ENCODING of a recipe into the single `Provenance.fxChainSnapshot`
|
||||
// string (M1's field already JSON-round-trips one string,
|
||||
// so the whole thin fingerprint rides in it — no schema
|
||||
// change to Sample).
|
||||
// * fxChainIdentity — folds the shell-gathered FX-chain rows into one identity
|
||||
// string (the drift-detection component of the fingerprint).
|
||||
// * detectParent — the pure parent-detection decision: given the resolved
|
||||
// absolute media-file path(s) of the capture's source item(s)
|
||||
// and the bank's path->sampleId map, decide whether this
|
||||
// capture genuinely derives from a bank sample (P1: identity
|
||||
// by resolved file path only — no fuzzy match, no false
|
||||
// parentage).
|
||||
//
|
||||
// Fork picks (docs/product/provenance.md, settled 2026-07-23): P1 = a THIN
|
||||
// reproducibility fingerprint (drift-detect + re-run the same request), NOT a
|
||||
// serialized FX chunk to restore. P2 = bank-only re-capture. So nothing here
|
||||
// stores a restorable chain, and nothing here reaches into view_mode_model.
|
||||
// A THIN reproducibility fingerprint (drift-detect + re-run) — NOT a serialized FX
|
||||
// chunk to restore; nothing here stores a restorable chain.
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -111,7 +93,7 @@ std::string buildFingerprint(const CaptureRecipe& recipe);
|
||||
// mis-driving a re-capture.
|
||||
std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint);
|
||||
|
||||
// --- Parent detection (P1: identity by resolved file path) -------------------
|
||||
// -- Parent detection: identity by resolved file path only --------------------
|
||||
|
||||
// One bank sample as the detector sees it: its stable id and the ABSOLUTE,
|
||||
// normalized path its file resolves to (the shell resolves relativePath against
|
||||
@@ -122,26 +104,20 @@ struct BankFileRef {
|
||||
std::string absolutePath; // normalized (forward-slash, no trailing slash)
|
||||
};
|
||||
|
||||
// Decides whether a capture derives from a bank sample.
|
||||
//
|
||||
// RULE (stated for the handoff, honest — no false parentage): a capture derives
|
||||
// from a bank sample iff EVERY source item whose media file could be resolved
|
||||
// points at the SAME bank sample's file (by exact normalized absolute path). If
|
||||
// the source items resolve to files not in the bank, or to MORE THAN ONE distinct
|
||||
// bank sample (ambiguous parentage), no parent is recorded. An empty source-file
|
||||
// set (nothing resolvable) yields no parent.
|
||||
// Decides whether a capture derives from a bank sample. No false parentage: a
|
||||
// capture derives from a bank sample iff EVERY source item whose media file could
|
||||
// be resolved points at the SAME bank sample's file (exact normalized absolute
|
||||
// path). Files not in the bank, or matching MORE THAN ONE distinct bank sample
|
||||
// (ambiguous), yield no parent. An empty source-file set yields no parent.
|
||||
//
|
||||
// sourceItemFiles : normalized absolute paths of the capture's source items'
|
||||
// take media files (the shell gathers + normalizes them). A
|
||||
// file that could not be resolved is simply omitted by the
|
||||
// shell — it never becomes an empty string here.
|
||||
// take media files, gathered by the shell. A file that could
|
||||
// not be resolved is simply omitted — never an empty string.
|
||||
// bankFiles : the active book's samples as BankFileRefs (path -> id).
|
||||
//
|
||||
// Returns the parent sample id, or nullopt when the capture is not a genuine
|
||||
// resample-from-sample. Comparison is exact path identity; the caller normalizes
|
||||
// both sides identically via normalizeSlashes (which lowercases on Windows) so a
|
||||
// slash/case difference never spuriously matches or misses. On Windows both sides
|
||||
// are lowercased before they reach here; on macOS/Linux they are case-exact.
|
||||
// Returns the parent sample id, or nullopt otherwise. Both sides are normalized
|
||||
// identically via normalizeSlashes (lowercased on Windows) so a slash/case
|
||||
// difference never spuriously matches or misses.
|
||||
std::optional<std::string> detectParent(
|
||||
const std::vector<std::string>& sourceItemFiles,
|
||||
const std::vector<BankFileRef>& bankFiles);
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
|
||||
#include "core/json/json.h"
|
||||
|
||||
// slot_map implementation (extracted from bank_book, Q-W1 T4-05).
|
||||
// slot_map implementation.
|
||||
//
|
||||
// The invariant: entries_ is kept sorted ascending by slot, one id per slot, one
|
||||
// slot per id. Every mutator restores it; queries assume it. serialize rides the
|
||||
// shared core/json emit helpers — the emitted fragment is byte-identical to the
|
||||
// pre-extraction bank_book writer.
|
||||
// slot per id. Every mutator restores it; queries assume it.
|
||||
|
||||
namespace reasampler::model {
|
||||
|
||||
@@ -128,8 +126,6 @@ SlotMap SlotMap::fromEntries(const std::vector<std::pair<std::string, int>>& pai
|
||||
|
||||
std::string SlotMap::serialize() const {
|
||||
// Array of {id, slot} objects in ascending slot order (entries_ is kept sorted).
|
||||
// json::Writer + numToStr are the same emit path the pre-extraction writer used,
|
||||
// so the fragment is byte-identical.
|
||||
std::string out;
|
||||
out += '[';
|
||||
for (std::size_t i = 0; i < entries_.size(); ++i) {
|
||||
|
||||
+12
-20
@@ -1,21 +1,13 @@
|
||||
#pragma once
|
||||
// slot_map — the L7 gap-preserving display-position carrier for ONE bank (F2 settled:
|
||||
// plain interchangeable slots, NOT M9 fixed/addressable slots). A slot is just a
|
||||
// display position a sample id occupies; the map is sample id -> slot (>= 0). Gaps
|
||||
// are first-class: a bank may have a sample at slot 1 with slot 0 empty (an empty
|
||||
// first row above an occupied second row). At most one id per slot (a slot is never
|
||||
// double-occupied) and at most one slot per id (an id sits in exactly one place).
|
||||
// slot_map — the gap-preserving display-position carrier for ONE bank: plain
|
||||
// interchangeable slots, not fixed/addressable ones. A slot is a display position a
|
||||
// sample id occupies; the map is sample id -> slot (>= 0). Gaps are first-class (a
|
||||
// bank may have slot 1 occupied with slot 0 empty). At most one id per slot, at
|
||||
// most one slot per id.
|
||||
//
|
||||
// Position lives HERE, not on Sample (CLAUDE.md wrapping discipline): a copy of one
|
||||
// sample into two banks may sit at different slots, so position is a per-bank display
|
||||
// concern owned by the bank's membership. bank_model / Sample stay untouched.
|
||||
//
|
||||
// Extracted from bank_book (Q-W1, T4-05): a self-contained ordered-slot container
|
||||
// with its own serialize, distinct from the multi-bank registry that carries it.
|
||||
// Behavior covered by bank_book_tests (the round-trip + reorder/reconcile suites);
|
||||
// a dedicated slot_map_tests target is a welcome follow-up, not a Q-W1 requirement.
|
||||
//
|
||||
// PURE: standard library + core/json (serialize) only.
|
||||
// Position lives HERE, not on Sample: a copy of one sample into two banks may sit
|
||||
// at different slots, so position is a per-bank display concern owned by the
|
||||
// bank's membership. bank_model / Sample stay untouched.
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
@@ -50,7 +42,7 @@ public:
|
||||
// keeps its position. Returns true if the id was mapped.
|
||||
bool remove(const std::string& id);
|
||||
|
||||
// Moves `id` to `targetSlot`, gap-preserving (F3 reorder semantics):
|
||||
// Moves `id` to `targetSlot`, gap-preserving:
|
||||
// * target slot EMPTY -> `id` moves there; its old slot is left empty.
|
||||
// * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and
|
||||
// every occupant at slot >= targetSlot (except `id` itself) shifts up by one,
|
||||
@@ -62,9 +54,9 @@ public:
|
||||
bool reorder(const std::string& id, int targetSlot);
|
||||
|
||||
// Rebuilds the map densely from `ids` in the given order (slot i = ids[i]),
|
||||
// dropping any prior state. The migration path: a pre-L7 bank with no persisted
|
||||
// slot data is seeded from its BankModel insertion order, densely packed (no gaps),
|
||||
// so it is visually identical on first post-L7 load. Empty/duplicate ids skipped.
|
||||
// dropping any prior state. The migration path: a bank with no persisted slot
|
||||
// data is seeded from its BankModel insertion order, densely packed (no gaps),
|
||||
// so it is visually identical on first load. Empty/duplicate ids skipped.
|
||||
void resetDense(const std::vector<std::string>& ids);
|
||||
|
||||
// Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user