Compare commits
57 Commits
1.0.0
..
7bd911d58b
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bd911d58b | |||
| 413967a205 | |||
| 9b1a49c640 | |||
| 0cfd9b6236 | |||
| 39389c1183 | |||
| 67215509cb | |||
| c9c708a338 | |||
| 4bef71b05a | |||
| 5420550ff3 | |||
| 68765da031 | |||
| d04045be69 | |||
| 25c63fb807 | |||
| b9d85161e5 | |||
| 1aea481688 | |||
| 3cb6b9de21 | |||
| d2364eb5ac | |||
| f12700c997 | |||
| 902030bfba | |||
| 7d42d7ed29 | |||
| 7af3c0c630 | |||
| 78e112d1f9 | |||
| e89568c1a8 | |||
| 9812690b96 | |||
| ae23ee0882 | |||
| ea52b14f2a | |||
| dfed1c77bb | |||
| fb12c53522 | |||
| 99fab6a4b6 | |||
| 2a0d10fab5 | |||
| 81f5861ba4 | |||
| 98c07d768f | |||
| e304f2b031 | |||
| 8d4ccbf841 | |||
| c91bf03ef4 | |||
| 875d5b4632 | |||
| 0800760833 | |||
| cd704cb0ad | |||
| 8c620f88e4 | |||
| a689fb75eb | |||
| be37192fe9 | |||
| 949dc3af7c | |||
| 47ccadf726 | |||
| 9d037fb233 | |||
| 58c6d49261 | |||
| 8dac5b4a54 | |||
| 80df142605 | |||
| 12ffe377e5 | |||
| 65ca1e1f9d | |||
| 3d3415f943 | |||
| 54f5f24506 | |||
| cc36dd59c7 | |||
| d4d29146c7 | |||
| ccd9968be1 | |||
| 354192ae27 | |||
| 3599d97836 | |||
| 85cc340e58 | |||
| 1f24c4b095 |
+45
-1289
File diff suppressed because it is too large
Load Diff
-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.
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# The two shapes that repeat across src/: a pure static library and its CTest target.
|
||||||
|
# Both are thin pass-throughs — LINK is forwarded to target_link_libraries verbatim, so
|
||||||
|
# PUBLIC/PRIVATE keywords and link order stay visible at the call site rather than being
|
||||||
|
# invented by the helper. Targets that genuinely deviate are written out longhand.
|
||||||
|
|
||||||
|
# Every pure library carries src/ as a PUBLIC include dir: headers are included rooted
|
||||||
|
# there ("core/json/json.h"), so a consumer needs only the link edge.
|
||||||
|
function(reasampler_pure_library name)
|
||||||
|
cmake_parse_arguments(ARG "" "" "SOURCES;LINK" ${ARGN})
|
||||||
|
add_library(${name} STATIC ${ARG_SOURCES})
|
||||||
|
target_include_directories(${name} PUBLIC ${REASAMPLER_SRC_DIR})
|
||||||
|
if(ARG_LINK)
|
||||||
|
target_link_libraries(${name} ${ARG_LINK})
|
||||||
|
endif()
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
# Test naming is exceptionless: target <name>_tests is built from tests/test_<name>.cpp
|
||||||
|
# and registered under its own target name.
|
||||||
|
function(reasampler_test name)
|
||||||
|
cmake_parse_arguments(ARG "" "" "LINK" ${ARGN})
|
||||||
|
add_executable(${name}_tests ${REASAMPLER_TESTS_DIR}/test_${name}.cpp)
|
||||||
|
target_link_libraries(${name}_tests PRIVATE ${ARG_LINK})
|
||||||
|
add_test(NAME ${name}_tests COMMAND ${name}_tests)
|
||||||
|
endfunction()
|
||||||
+1065
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,230 @@
|
|||||||
|
# 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."*
|
||||||
|
|
||||||
|
### CMake build-system split (ad-hoc, Daniel's request)
|
||||||
|
|
||||||
|
Split the 1423-line root `CMakeLists.txt` into a 91-line root plus 18 per-directory
|
||||||
|
`CMakeLists.txt` files under `src/`, with two shared declaration helpers
|
||||||
|
(`reasampler_pure_library`, `reasampler_test`) factored into a new
|
||||||
|
`cmake/reasampler_targets.cmake`. The root now keeps only repo-global concerns:
|
||||||
|
version/channel single-source-of-truth, `configure_file`, vendor path vars,
|
||||||
|
`LICE_SRC`, `enable_testing()`, and the `add_subdirectory` calls. Comments throughout
|
||||||
|
were rewritten to the project's comment conventions — phase/wave/ticket IDs removed,
|
||||||
|
module semantics already owned by `src/**/CLAUDE.md` deleted, load-bearing build
|
||||||
|
facts kept.
|
||||||
|
|
||||||
|
Also fixed two duplicate-object-code defects surfaced by the split: 18 `core/`
|
||||||
|
translation units were previously compiled directly into the `reaper_reasampler`
|
||||||
|
module *while also* being linked in as static libraries — those 18 source entries
|
||||||
|
were removed from the module's source list and 3 missing link edges
|
||||||
|
(`view_tree`, `guid_diff`, `lane_keys`) added so every `core/` TU now enters through
|
||||||
|
exactly one static-library link edge. A dead `bridge_marshal` link edge was also
|
||||||
|
dropped from `reaper_reasampler`, and two inaccurate comments in
|
||||||
|
`src/app/CMakeLists.txt` were corrected.
|
||||||
|
|
||||||
|
No `.cpp`, `.h`, `tests/`, or `vendor/` file was touched. Behaviour is unchanged and
|
||||||
|
was verified mechanically: same 130 targets, same 65 tests all passing,
|
||||||
|
`reaper_reasampler.dll` byte-identical at 3,477,504 bytes, both channels
|
||||||
|
(stable/beta) building to the same artifact names and locations as before.
|
||||||
|
|
||||||
|
### Θ-W1-T1 — zone-retirement
|
||||||
|
|
||||||
|
ReaSampler 9000's zone-mapping system is retired: one loaded capture, one parameter
|
||||||
|
set, playing across the full keyboard repitched from root with key-tracking — no zones,
|
||||||
|
no per-zone divergence, no keymap of captures. The dedicated zone-editing face and its
|
||||||
|
authoring affordances (add/delete zone, per-zone parameter panel, Low/High/Root zone
|
||||||
|
legend) are gone; the root note survives as a first-class parameter. `sampler_core`
|
||||||
|
split along the note-routing/per-voice-render responsibility seam (no virtual `tick()`
|
||||||
|
on the per-voice path), and the Sample face split into chrome/waveform/decks bands with
|
||||||
|
a shared band-stack allocator, discharging the wave's two structural deliverables.
|
||||||
|
Migration adopts a saved multi-zone instance's first zone; single-zone instances lift
|
||||||
|
losslessly.
|
||||||
|
|
||||||
|
**Deviations from spec:**
|
||||||
|
- The key-range open question (**[propose]**) is answered outright rather than left
|
||||||
|
open: no key-range concept survives at all — `KeyZone`/`lowNote`/`highNote` are gone
|
||||||
|
from the engine, the write format, and the strip. A low/high pair remains re-addable
|
||||||
|
later as two ordinary parameters. Θ-W2-T3 consumes this decision.
|
||||||
|
- **"Which zone is first" (**[verify]**) confirmed:** `PerformanceMap::zones` was an
|
||||||
|
ordered vector and `Keymap::resolve` was first-match-in-order, so index 0 was the
|
||||||
|
audible zone. Migration adopts index 0, and that zone's `sampleId` supersedes the
|
||||||
|
envelope's stored `selectionId`.
|
||||||
|
- The control row (root strip, preview, velocity knob, curve button, Mono|Stereo) moved
|
||||||
|
into the chrome band, directly under the title rather than above the deck —
|
||||||
|
user-visible and deliberate; it's what makes Θ-W2's band-disjointness real.
|
||||||
|
- Loading a capture now clears the three capture-anchored overrides (root, loop span,
|
||||||
|
start frame) while keeping the shaping parameters — strictly less destructive than the
|
||||||
|
previous whole-zone drop.
|
||||||
|
- The embed strip became a read-only readout; it lost its click handler since with no
|
||||||
|
zones there is nothing to select.
|
||||||
|
- **Migration side-effect:** a previously-zoned instance with implicit channel mode and
|
||||||
|
a stereo capture persisted as Mono will reopen as **Stereo**. It converges on the
|
||||||
|
documented rule and is reachable only for old blobs, but "sounds identical" was an
|
||||||
|
acceptance bar, so it's a real deviation.
|
||||||
|
- `sampler_core.{h,cpp}`'s 956-line documented hot-path exception is retired, not
|
||||||
|
relocated — the tree now carries no over-ceiling exception at all.
|
||||||
|
- `note_entry` was deleted as dead code (its only consumer was the removed zone editor).
|
||||||
|
- **Still unverified:** a real pre-change project reopening through REAPER's `setState`
|
||||||
|
has not been exercised in the DAW; migration is proven only in the pure domain against
|
||||||
|
hand-laid legacy bytes.
|
||||||
|
|
||||||
|
### Θ-W1-T2 — capture-handoff-bugs
|
||||||
|
|
||||||
|
Fixed both extension-side capture-handoff defects: drag-out now delivers the capture's
|
||||||
|
audio at the drop target every time (previously intermittent, retry-fixable); dropping a
|
||||||
|
capture onto an FX container now loads the instrument with the capture, matching the
|
||||||
|
FX-button drop path.
|
||||||
|
|
||||||
|
**Deviations from spec:**
|
||||||
|
- Item 5's root cause was three defects, not one: non-atomic COM refcounts racing a drop
|
||||||
|
target's background copy; an unverified assumption that REAPER had already called
|
||||||
|
`OleInitialize` on the calling thread; and a teardown-before-payload-check ordering bug
|
||||||
|
that let an unresolvable payload consume the gesture.
|
||||||
|
- Item 6's fix rests on an unconfirmed hypothesis — that a bare `instantiate = -1` left
|
||||||
|
placement to REAPER's ambient FX-chain insert point, which a container-focused chain
|
||||||
|
window moves. It is now pinned to an explicit top-level position; nobody could confirm
|
||||||
|
the mechanism without the DAW.
|
||||||
|
- The opportunistic rider was partly taken: `src/ingest.{h,cpp}` re-homed to
|
||||||
|
`src/shell/actions/`. `ext_keys.h` was declined (most consumers sit in another track's
|
||||||
|
exclusive surface); `resource.h` was declined (it's a build input paired with
|
||||||
|
`src/resource.rc` and the SWELL resgen step).
|
||||||
|
- **Neither acceptance criterion has actually been met yet.** Item 5's gate is
|
||||||
|
explicitly a soak ("a single pass is not a gate") and item 6 needs a live container
|
||||||
|
drop; both require REAPER and are outstanding. The code is merged; the acceptance
|
||||||
|
gates are not closed.
|
||||||
|
|
||||||
|
### Θ-W1-T3 — filter-dsp-port
|
||||||
|
|
||||||
|
Lands the per-voice resonant filter as a standalone pure module
|
||||||
|
(`core/instrument/engine/filter/`, five files: `filter_params`, `filter_coeffs`,
|
||||||
|
`filter_morph`, `filter_saturate`, `voice_filter`) — concrete `VoiceFilter` type, no
|
||||||
|
vtable, no allocation in `process()`. **No call site**; Θ-W2-T1 wires it into the voice
|
||||||
|
path.
|
||||||
|
|
||||||
|
**Deviations from spec — the spec itself changed mid-flight, headline first:**
|
||||||
|
- **The Cortex-M4 biquad port was superseded entirely by a TPT/SVF topology, Daniel's
|
||||||
|
call.** Measurement found the firmware's high-pass resonance feedback tap vestigial:
|
||||||
|
its stated rationale was inverted (the HP numerator approaches 1 as cutoff falls, not
|
||||||
|
zero — it's the LP numerator that collapses), it *reduced* HP resonance everywhere it
|
||||||
|
ran, and it carried unwanted sample-rate and input-level dependence. It was a Q15
|
||||||
|
fixed-point workaround for a ~17-bit cancellation float32 doesn't suffer. Daniel's
|
||||||
|
ruling on the resulting level-dependent resonance bloom: *"was a feature on the
|
||||||
|
hardware (one knob colorful HP for master FX), wrong choice for this approach."*
|
||||||
|
- **Two discrete modes (HP/LP) became a continuous morph, with two selectable morph
|
||||||
|
laws** — HP→BP→LP (default) and HP→notch→LP (Oberheim SEM) — chosen at `prepare()`
|
||||||
|
via a `MorphLaw` enum on `FilterSettings`. Zero per-sample cost, verified by diffing
|
||||||
|
emitted assembly (byte-identical between laws).
|
||||||
|
- **A configurable drive stage was added:** an in-loop soft limiter on the band-pass
|
||||||
|
integrator state, normalized 0..1, with a radial dial planned for Θ-W2. Drive at 0 is
|
||||||
|
bit-exact linear.
|
||||||
|
- **`Biquad1PoleLP` was struck (Daniel's call) and never ported.**
|
||||||
|
- **Q spans the full 0.1–10 with √2 at the control centre**, replacing the firmware's
|
||||||
|
0.707-floored mapping — as originally specified.
|
||||||
|
- **No reference sample rate exists anywhere in the module** — rate reaches the DSP
|
||||||
|
only via `g = tan(π·fc/sr)`. An interim fix that anchored a feedback tap to a
|
||||||
|
`1/48000` constant was superseded by the rewrite.
|
||||||
|
- **The rewrite fixed a float32 conditioning defect the biquad carried:** Direct Form I
|
||||||
|
measured −27% peak error at 20 Hz / 192 kHz; TPT measures +0.034%.
|
||||||
|
- **Still open, deliberately:** drive's maximum depth (4.0, set by measurement — at 64
|
||||||
|
the resonant peak inverted below passband) and the absence of makeup gain both await
|
||||||
|
an ear pass against the real dial.
|
||||||
|
- **The module has no call site** — integration is Θ-W2-T1, which also carries three
|
||||||
|
recorded decisions of its own: the envelope-order choice (drive is level-dependent, so
|
||||||
|
pre- vs post-envelope placement is sound-defining), the drive dial's calibration, and
|
||||||
|
the fact that drive authority varies ~11 dB across the morph sweep.
|
||||||
|
|
||||||
|
### Θ-W2-T1 — filter-voice-path
|
||||||
|
|
||||||
|
Wires the pure filter module into ReaSampler 9000's per-voice signal path as a new fixed
|
||||||
|
processing point between the pitch envelope and the amp stage, gives it its own
|
||||||
|
knob-deck group, and relays the deck row in signal-flow order (pitch → filter → amp).
|
||||||
|
Each `Voice` owns its own `VoiceFilter` and a second `AdsrEnvelope` instance — per-voice,
|
||||||
|
never shared — and neither adds allocation or virtual dispatch to the per-sample path.
|
||||||
|
Parameters — morph position, cutoff, Q, drive, mod amount (bipolar ±100%, targeting
|
||||||
|
cutoff), velocity and key-tracking modulation, then AHDSR — live in the one parameter
|
||||||
|
set; `FilterParams` stores the filter module's own `FilterSettings` by value rather than
|
||||||
|
a parallel copy of the normalized positions. The filter is off by default and bit-exact
|
||||||
|
off — a project saved before the change reopens sounding identical, pinned by a
|
||||||
|
bit-equality test. `ComponentState`'s params payload moves v8 → v9, appending the filter
|
||||||
|
tail; a v8 blob is a strict prefix and lifts to the off/neutral filter default, with
|
||||||
|
non-finite filter fields falling back to neutral, pinned by a golden byte-literal
|
||||||
|
fixture. Deck composition was extracted into a new pure module
|
||||||
|
`core/instrument/ui/deck_groups`, with the pitch → filter → amp row order pinned by
|
||||||
|
test; the filter's AHDSR ships as its own `FILTER ENV` group sibling to `FILTER`,
|
||||||
|
mirroring the existing `PITCH` / `PITCH ENV` split, and a morph-law toggle (`Band` |
|
||||||
|
`Notch`) ships as the FILTER group's row toggle.
|
||||||
|
|
||||||
|
**Deviations from spec:**
|
||||||
|
- **An initial mod-quantizer was added, then rejected and removed.** A
|
||||||
|
`kFilterModSteps = 2048` step gate on the coefficient re-solve stair-stepped the
|
||||||
|
corner (~5.8 cents per step); Daniel rejected it. Replaced by a cutoff-only re-solve
|
||||||
|
(`VoiceFilter::setCutoffNorm`) that re-derives only `g = tan(π·fc/sr)` — Q's parabola,
|
||||||
|
the morph's cos/sin, and the folded mix are all cutoff-independent and stay cached
|
||||||
|
from `prepare()`; `filter_params` hoists its constant logs. Measured at 48 kHz,
|
||||||
|
Release, net of the sweep generator: kernel alone 2.8 ns/frame, full `prepare()` 56.9
|
||||||
|
ns, cutoff-only re-solve 15.5 ns — about 1.2% of a core for 16 continuously-swept
|
||||||
|
voices. The corner now sweeps continuously rather than stair-stepping.
|
||||||
|
- **The editor floor was raised to 840×620**, which is also its new default size, up
|
||||||
|
from a 560×460 floor at which the grown deck wrapped to four rows and pushed
|
||||||
|
`FILTER ENV` / `AMP ENVELOPE` / `VOICE` / `MASTER` off-screen with no scroll.
|
||||||
|
`kEditorMinWidth`/`kEditorMinHeight` now live in `sample_bands`, read by both the
|
||||||
|
shell's `checkSizeConstraint` and the opening `ViewRect`. Consequence worth
|
||||||
|
recording: a host with a saved editor rect below 840×620 is clamped up on reopen.
|
||||||
|
|
||||||
|
### Θ-W2-T2 — stereo-waveform-lanes
|
||||||
|
|
||||||
|
Delivered as specified: two stacked lanes, L above R, in stereo mode; one lane in mono;
|
||||||
|
overlays draw once at full stacked height.
|
||||||
|
|
||||||
|
**Deviations from spec:**
|
||||||
|
- **A mono source under stereo mode draws one lane**, not two — lane count keys off
|
||||||
|
`stereoMode && sourceChannels >= 2`, not channel mode alone, since a mono capture in
|
||||||
|
stereo mode is dual-mono and a second lane would be the redundant duplicate the spec
|
||||||
|
forbids. Review confirmed this is the only reading consistent with the decode path.
|
||||||
|
- The full-height overlay contract Θ-W3 and Θ-W4 consume is **type-enforced, not
|
||||||
|
merely documented**: an `OverlayArea` wrapper type that lane rects cannot satisfy.
|
||||||
|
- A single-slot per-channel PCM cache was added to the editor session, invalidating
|
||||||
|
alongside the existing PCM cache.
|
||||||
|
|
||||||
|
### Θ-W2-T3 — toolbar-and-piano-strip
|
||||||
|
|
||||||
|
Delivered as specified: one toolbar font, no zone-count label, full-width piano strip,
|
||||||
|
uniform key widths, note-name tooltips, root displayed and settable.
|
||||||
|
|
||||||
|
**Deviations from spec:**
|
||||||
|
- **The non-uniform key widths were integer quantisation, not aliasing** — the old
|
||||||
|
`keyEdgeToX` truncated an exact rational, alternating 6px/7px. Θ-W6's general
|
||||||
|
antialiasing audit inherits nothing on key *widths* as a result, though key *edges*
|
||||||
|
are still drawn unantialiased.
|
||||||
|
- **Uniform integer key widths and gap-free edge-to-edge tiling are mutually
|
||||||
|
exclusive** — 75 white keys do not divide an arbitrary width. The residue now lands
|
||||||
|
in symmetric end gutters, 37px each side at the shipped 840px default — the maximum
|
||||||
|
of a sawtooth with period 75px of window width. Daniel accepted this provisionally,
|
||||||
|
pending how it looks in REAPER.
|
||||||
|
- The whole control run moved into the toolbar row, not just preview and mono/stereo —
|
||||||
|
the full-width strip left the velocity knob and curve button nowhere else to go.
|
||||||
|
- Root drag became absolute-tracking rather than pixel-delta, since black keys
|
||||||
|
overlaying white give no single pixels-per-semitone rate.
|
||||||
|
- **Width uniformity is guaranteed in client pixels only.** Nothing in the instrument
|
||||||
|
consumes a DPI scale factor, so host-side scaling is unverified — Θ-W6 already
|
||||||
|
carries a "confirm the fix survives DPI scaling" item and now genuinely inherits it.
|
||||||
|
- A stale-hover latch was fixed across **all** drag kinds and both drag-termination
|
||||||
|
paths, wider than the strip work that surfaced it.
|
||||||
+1478
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,14 @@
|
|||||||
# TODO-1.0
|
# TODO-1.0
|
||||||
|
|
||||||
|
> **`docs/PLAN.md` is now the roadmap.** All seventeen items below have been
|
||||||
|
> consolidated into areas and sequenced into the Phase → Wave → Track hierarchy in
|
||||||
|
> `docs/PLAN.md`; that file is what implementation specialists are dispatched against,
|
||||||
|
> and each of its tracks is self-sufficient for a brief. **This file is retained as the
|
||||||
|
> verbatim-provenance spec appendix** — Daniel's raw asks and every answer round,
|
||||||
|
> unedited, are the source of truth behind PLAN.md's compressed behavior bullets. Its
|
||||||
|
> traceability table maps each item number below onto the track that owns it. Nothing in
|
||||||
|
> this file changes as work lands; PLAN.md points move to `docs/COMPLETED.md`.
|
||||||
|
|
||||||
Post-1.0 queue for ReaSampler — chiefly the 9000 instrument, plus two
|
Post-1.0 queue for ReaSampler — chiefly the 9000 instrument, plus two
|
||||||
extension-side bugs. Items 1–3 are the first batch, in Daniel's ordering
|
extension-side bugs. Items 1–3 are the first batch, in Daniel's ordering
|
||||||
(2026-07-28); items 4–13 are a second batch (2026-07-28, later the same day);
|
(2026-07-28); items 4–13 are a second batch (2026-07-28, later the same day);
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
Forward-looking follow-ups. Deferred by decision, not oversight — each entry records why it was deferred and what "done" looks like.
|
||||||
|
|
||||||
|
## The per-voice filter is solved against the WAV's sample rate, not the render rate
|
||||||
|
|
||||||
|
**Context (what shipped — Θ-W2-T1, the filter in the voice path).** `Voice::start` sets `filterRate_ = sample.sampleRate` — the rate read off the **decoded WAV header** — and hands it to `VoiceFilter::prepare()` and every later `setCutoffNorm()`. But the voice emits exactly one frame per **host** frame, so the rate the corner should be solved against is the project/render rate the processor already latches in `setupProcessing` (`ReaSamplerProcessor::sampleRate_`), not the file's. The rate enters the DSP only through `g = tan(pi*fc/sr)` (`engine/filter/CLAUDE.md`), so a wrong `sr` scales the realized corner by exactly the ratio of the two rates.
|
||||||
|
|
||||||
|
**The wart.** When capture rate ≠ project rate, the corner lands at the wrong frequency, by that ratio. A 44.1 kHz capture in a 48 kHz project puts the corner roughly **1.5 semitones sharp** (48000/44100 ≈ 1.088×); the same capture in a 96 kHz project is roughly **13.5 semitones off**. The Nyquist clamp (`kFilterNyquistFraction`) measures against the wrong Nyquist for the same reason. This falsifies the guarantee `filter_params.h` states in its own words — that the persisted value is a normalized knob position precisely so one preset does not sound different at 44.1k and 96k. The control law honors that; the solve defeats it.
|
||||||
|
|
||||||
|
**Intended fix.** Thread the host render rate onto `SampleData` and set `filterRate_` from it. The processor already holds `sampleRate_` from `setupProcessing` and already guards on it being non-zero before building, so the value is available at exactly the point `SampleData` is constructed — this is a plumbing change, not a new mechanism.
|
||||||
|
|
||||||
|
**The constraint the fix MUST handle.** The engine **already conflates the two rates everywhere** — `sample_map` resolves the AHDSR's stored seconds at the WAV's own rate, and nothing resamples the source — so a cross-rate capture already plays back sharp *and* short by the same ratio. This is an inherited assumption, not a defect introduced by the filter; the filter is simply the first module where it lands as an audible **frequency** error rather than a timing one. A fix that corrects only the filter leaves the filter rate-correct while envelope timing stays rate-wrong. That is strictly less wrong and defensible, but it splits one assumption into two, and the split must be a deliberate choice rather than a side effect of fixing the loudest symptom. Second constraint: `filterRate_ <= 0` must keep meaning **bypass** — the filter module forbids a reference, calibration, or fallback rate anywhere in itself, and a plumbing fix must not smuggle one in as a default.
|
||||||
|
|
||||||
|
**Priority / risk.** Deferred by ruling — Daniel, 2026-07-30: *"record this and proceed."* Inaudible whenever capture rate == project rate, which is the common case for captures this tool made in the project they belong to. Audible and large on an imported or cross-rate capture, and worse the further the two rates diverge.
|
||||||
|
|
||||||
|
**Done looks like.** The realized filter corner matches `filterCutoffHzFromNorm(pos)` within measurement tolerance at every combination of capture rate and project rate; the Nyquist clamp measures against the render rate; and the decision about whether envelope timing follows the same correction is recorded rather than left implicit.
|
||||||
|
|
||||||
|
## Filter ring-out is truncated on the source-exhaustion path
|
||||||
|
|
||||||
|
**Context (what shipped — Θ-W2-T1).** The per-voice filter runs between the pitch stage and the amp multiply. When `readPos_` runs past the end of the sample with no usable loop, `Voice::advanceFrame` latches `active_ = false` and returns 0 — the voice stops feeding, and whatever energy remains in the filter's two integrators is discarded rather than rung out.
|
||||||
|
|
||||||
|
**The wart.** The filter's tail is cut at source exhaustion instead of decaying to the filter's own denormal floor.
|
||||||
|
|
||||||
|
**Why the common case is unaffected.** A released Gate note's filter tail is shaped to silence by the **amp release** before the read head reaches the end — that is the pipeline ordering (pitch → filter → amp) working exactly as designed. Trigger's fade-out has already taken the amp to ~0 at `playEnd`, so the discarded state is multiplied by ~0 regardless. The exposed case is a voice that reaches source exhaustion with the amp envelope still open.
|
||||||
|
|
||||||
|
**Intended fix.** Let a voice keep rendering the filter past source exhaustion — zero input, filter ringing — until `VoiceFilter::isSilent()`.
|
||||||
|
|
||||||
|
**The constraint the fix MUST handle (why deferred).** Extending a voice past source exhaustion changes `active()` and `soundingNote()`, and those two predicates feed `VoiceEngine`'s oldest-first stealing policy and the Preserve-voice tally. A ring-out voice would hold an allocation slot and could suppress or be stolen by a note-on that today would be routed differently — a materially larger blast radius than the track that found the defect, which is why it is deferred rather than patched at the call site. The existing takeover declick already carves out an `active() && !soundingNote()` ring-out state; a filter ring-out would be a second occupant of that state and must compose with it rather than fight it.
|
||||||
|
|
||||||
|
**The caveat both reviewers recorded.** The discarded state can be roughly `2Q` larger than the source that produced it, so at high Q the cut **amplifies** the step that already existed at source exhaustion rather than merely preserving it. The defect gets worse the more resonance is dialled in — it is not a uniformly small residual.
|
||||||
|
|
||||||
|
**Priority / risk.** Low / deferred. Recorded during Θ-W2-T1 review and left for a track that can own the voice-lifetime predicates.
|
||||||
|
|
||||||
|
**Done looks like.** A high-Q filtered voice that reaches source exhaustion with the amp envelope still open decays to the filter's denormal floor rather than cutting, with no change to voice-stealing behavior, the Preserve tally, or the takeover-declick ring-out state.
|
||||||
|
|
||||||
|
## 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.
|
is because the functional mechanism is new.
|
||||||
|
|
||||||
Every claim below was verified by grep/read of the actual tree. Line numbers are as of this
|
Every claim below was verified by grep/read of the actual tree. Line numbers are as of this
|
||||||
audit's snapshot. Wave assignments reference PLAN.md §Q-W1..Q-W6.
|
audit's snapshot. Wave assignments reference the Q-W1..Q-W6 waves (landed history in
|
||||||
|
`docs/ARCHIVE.md`; sequencing in `docs/product/code-organization.md` §5).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Static analysis, 2026-07-28, branch `pq-w0-audit`. Scope: any value stored in an
|
Static analysis, 2026-07-28, branch `pq-w0-audit`. Scope: any value stored in an
|
||||||
environment-coupled domain — frames, sample rate, DPI, pixels, tick cadence — that should be
|
environment-coupled domain — frames, sample rate, DPI, pixels, tick cadence — that should be
|
||||||
stored **rate-free / device-free and resolved at the point of use** (PLAN.md §Q-W0 env-coupled
|
stored **rate-free / device-free and resolved at the point of use** (`docs/product/code-organization.md`
|
||||||
bullet; `docs/product/code-organization.md` §2c.3; the load-bearing `sample_map` seconds
|
§2c.3 env-coupled bullet; the load-bearing `sample_map` seconds invariant, documented in
|
||||||
invariant). Findings are domain-modeling calls, not "rescale by rate" patches. The judgment bar
|
`src/core/instrument/CLAUDE.md`). Findings are domain-modeling calls, not "rescale by rate" patches. The judgment bar
|
||||||
applied: a finding requires (a) an env-coupled *stored* domain AND (b) an environment that can
|
applied: a finding requires (a) an env-coupled *stored* domain AND (b) an environment that can
|
||||||
actually change under it. Frame counts computed transiently from seconds at the use site are
|
actually change under it. Frame counts computed transiently from seconds at the use site are
|
||||||
correct and are not reported.
|
correct and are not reported.
|
||||||
@@ -63,7 +63,7 @@ fix-now findings in those files must be remediated in Q-W0 itself.
|
|||||||
normalize the Trigger fade-in/out knobs.
|
normalize the Trigger fade-in/out knobs.
|
||||||
- **Stored vs. correct domain:** the fade **storage** domain (int64 SOURCE frames, persisted in
|
- **Stored vs. correct domain:** the fade **storage** domain (int64 SOURCE frames, persisted in
|
||||||
the zones payload) is settled and correct — a source-timeline fact, invariant under project-
|
the zones payload) is settled and correct — a source-timeline fact, invariant under project-
|
||||||
rate change (PLAN.md §S15). The *UI ceiling*, however, encodes a wall-clock intent ("2-second
|
rate change (`docs/ARCHIVE.md` §S15). The *UI ceiling*, however, encodes a wall-clock intent ("2-second
|
||||||
max fade throw") as a frame count at an assumed 44.1 kHz source. `88200` is a rate-derived
|
max fade throw") as a frame count at an assumed 44.1 kHz source. `88200` is a rate-derived
|
||||||
literal in `src/`, brushing the no-hardcoded-rate ruling even though it never touches disk.
|
literal in `src/`, brushing the no-hardcoded-rate ruling even though it never touches disk.
|
||||||
- **What breaks when the environment shifts:** the environment here is the **source file's
|
- **What breaks when the environment shifts:** the environment here is the **source file's
|
||||||
@@ -165,7 +165,7 @@ fix-now findings in those files must be remediated in Q-W0 itself.
|
|||||||
`SampleRefs` (paths + root/loop/channels intrinsics), `instanceGuid` — all rate-free or
|
`SampleRefs` (paths + root/loop/channels intrinsics), `instanceGuid` — all rate-free or
|
||||||
file-fact domains. Clean.
|
file-fact domains. Clean.
|
||||||
- **Trigger `fadeInFrames`/`fadeOutFrames`/`startPoint`/`SampleLoop.start/end` persisted as
|
- **Trigger `fadeInFrames`/`fadeOutFrames`/`startPoint`/`SampleLoop.start/end` persisted as
|
||||||
int64 SOURCE frames:** deliberate, settled source-timeline facts (PLAN.md §S15;
|
int64 SOURCE frames:** deliberate, settled source-timeline facts (`docs/ARCHIVE.md` §S15;
|
||||||
`bank_model.h:66-72` documents the loop rationale) — frames *of the file* are invariant under
|
`bank_model.h:66-72` documents the loop rationale) — frames *of the file* are invariant under
|
||||||
project-rate change; the file's own rate is stored alongside and resolved at decode. Correct
|
project-rate change; the file's own rate is stored alongside and resolved at decode. Correct
|
||||||
domain, not a finding.
|
domain, not a finding.
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
# Capture tail — spec
|
# Capture tail — spec
|
||||||
|
|
||||||
Authoritative spec for the **capture-tail** feature: preserving reverb/release
|
Authoritative spec for the **capture-tail** feature: preserving reverb/release
|
||||||
tails that decay past the end of a capture range. The tickable milestone lives in
|
tails that decay past the end of a capture range. The tickable milestone's landed
|
||||||
`PLAN.md` (Milestone T); this doc holds the full technical detail **and** the
|
history is in `docs/ARCHIVE.md` (Milestone T); this doc holds the full technical
|
||||||
product framing.
|
detail **and** the product framing.
|
||||||
|
|
||||||
> **Why this doc carries the technical spec (not `CONTEXT.md`).** Every other
|
> **Why this doc carries the technical spec (not the architecture docs).** Every
|
||||||
> pillar (capture M0–M11, Design View, Multi-bank) keeps its authoritative
|
> 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/`
|
> technical spec as a per-directory `src/**/CLAUDE.md` section and its *why* in a
|
||||||
> note. Capture-tail is a rider on the already-shipped offline-render path
|
> `docs/product/` note. Capture-tail is a rider on the already-shipped
|
||||||
> (M3/M7), not a standalone pillar, and it is being specced without reopening
|
> offline-render path (M3/M7), not a standalone pillar, and it was specced without
|
||||||
> `CONTEXT.md`. So the authoritative detail lands **here**, house-styled to match
|
> reopening the architecture spec. So the authoritative detail lands **here**,
|
||||||
> the CONTEXT specs; when the tail work lands, doc-keeper may fold the invariant
|
> house-styled to match those specs; the landed invariant deltas are folded into
|
||||||
> deltas into `CONTEXT.md §Precision invariants` as landed history. Same standing
|
> root `CLAUDE.md` §Precision invariants and `src/core/capture/CLAUDE.md` /
|
||||||
> discipline applies: **verify every REAPER API name/flag against
|
> `src/shell/capture/CLAUDE.md` as landed history. Same standing discipline
|
||||||
|
> applies: **verify every REAPER API name/flag against
|
||||||
> `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use** — the flag values
|
> `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use** — the flag values
|
||||||
> below are transcribed from that header (line numbers cited) and are not guesses.
|
> below are transcribed from that header (line numbers cited) and are not guesses.
|
||||||
|
|
||||||
@@ -252,7 +253,7 @@ held to bit-identical repeats.
|
|||||||
- **Opt-in beyond the region.** The tail only ever adds audio past the range end
|
- **Opt-in beyond the region.** The tail only ever adds audio past the range end
|
||||||
when a tail is explicitly requested (Auto or Manual). This is exactly the existing
|
when a tail is explicitly requested (Auto or Manual). This is exactly the existing
|
||||||
invariant: *"no added silence unless a tail is explicitly requested"*
|
invariant: *"no added silence unless a tail is explicitly requested"*
|
||||||
(`CONTEXT.md §Precision invariants`, `CLAUDE.md §Exact bounds`). Auto-trim
|
(`CLAUDE.md §Precision invariants §Exact bounds`). Auto-trim
|
||||||
strengthens it — the tail added is decay, not silence, and the silence past the
|
strengthens it — the tail added is decay, not silence, and the silence past the
|
||||||
decay is trimmed off.
|
decay is trimmed off.
|
||||||
- **The null test uses NO tail.** The null-test / verify capture (M10) and any
|
- **The null test uses NO tail.** The null-test / verify capture (M10) and any
|
||||||
@@ -274,8 +275,8 @@ held to bit-identical repeats.
|
|||||||
the tail is **the track's own reverb/delay decay**, not the parent bus's. A track
|
the tail is **the track's own reverb/delay decay**, not the parent bus's. A track
|
||||||
with a reverb plugin captures that reverb's tail; a track feeding a folder reverb
|
with a reverb plugin captures that reverb's tail; a track feeding a folder reverb
|
||||||
does **not** capture the folder reverb's tail (that send is out of scope — and
|
does **not** capture the folder reverb's tail (that send is out of scope — and
|
||||||
note the pre-existing send-isolation caveat in `PLAN.md §Open questions`, which
|
note the pre-existing send-isolation caveat, which the tail inherits
|
||||||
the tail inherits unchanged, does not worsen). Correct and consistent.
|
unchanged, does not worsen). Correct and consistent.
|
||||||
- **Item scope:** item/take FX only, self-track + ancestors + master bypassed →
|
- **Item scope:** item/take FX only, self-track + ancestors + master bypassed →
|
||||||
the tail is the **item/take FX decay only**. An item with a take reverb captures
|
the tail is the **item/take FX decay only**. An item with a take reverb captures
|
||||||
its tail; the track's reverb does not ring into it. Correct.
|
its tail; the track's reverb does not ring into it. Correct.
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ subdirectories) **without sacrificing runtime performance**, against a stated qu
|
|||||||
>
|
>
|
||||||
> Bring the codebase "**into the realm of something I can stand to look at.**"
|
> Bring the codebase "**into the realm of something I can stand to look at.**"
|
||||||
|
|
||||||
Its build roadmap lives in **PLAN.md §Phase Q** and its authoritative spec in
|
Its build roadmap's landed history lives in **`docs/ARCHIVE.md`** ("Phase Q — Quality:
|
||||||
**CONTEXT.md §Phase Q**. This doc holds the *why* — the quality bar, the evidence base
|
the decision record") and its authoritative spec now lives in root **`CLAUDE.md`**
|
||||||
|
(§Directory and namespace layout, §Performance guardrails, §Structural heuristics)
|
||||||
|
plus the per-directory `src/**/CLAUDE.md` files. This doc holds the *why* — the quality bar, the evidence base
|
||||||
(a grep-verified SOLID audit), the target directory/namespace shape grounded in the Vital
|
(a grep-verified SOLID audit), the target directory/namespace shape grounded in the Vital
|
||||||
reference, and the numbered fork decisions.
|
reference, and the numbered fork decisions.
|
||||||
|
|
||||||
@@ -321,10 +323,10 @@ re-namespaced, and split, not carried forward untouched into a tidier tree. "Som
|
|||||||
look at" is partly shape (§2/§2b) and partly *functional soundness* (§2c).
|
look at" is partly shape (§2/§2b) and partly *functional soundness* (§2c).
|
||||||
|
|
||||||
Q-W0 is **not executed by product-designer** — this doc *defines the wave and its scope*; the audit
|
Q-W0 is **not executed by product-designer** — this doc *defines the wave and its scope*; the audit
|
||||||
itself is staff-engineer/DSP work. The authoritative wave definition lives in **PLAN.md §Q-W0** and
|
itself is staff-engineer/DSP work. The wave's landed history lives in **`docs/ARCHIVE.md`**
|
||||||
its spec framing in **CONTEXT.md §"The pre-restructure audit wave (Q-W0)"**; this section is the
|
("Q-W0 fix-now remediations" and "Phase Q — Quality: the decision record"), and its findings live in
|
||||||
evidence-doc framing (the *why* and the smell taxonomy), matching how §2/§2b frame the structural
|
**`docs/product/code-quality-audit.md`**; this section is the evidence-doc framing (the *why* and
|
||||||
audits.
|
the smell taxonomy), matching how §2/§2b frame the structural audits.
|
||||||
|
|
||||||
### 2c.1 Audit surfaces (the named targets)
|
### 2c.1 Audit surfaces (the named targets)
|
||||||
|
|
||||||
@@ -451,7 +453,7 @@ namespace of every header, splitting the four largest TUs). Meanwhile:
|
|||||||
- **Phase L** has **L3** (VST editor + embed-strip restyle, gated on Phase S landing on dev)
|
- **Phase L** has **L3** (VST editor + embed-strip restyle, gated on Phase S landing on dev)
|
||||||
still to land — it touches the Phase S draw shells (`reasampler_editor` / `reasampler_embed`),
|
still to land — it touches the Phase S draw shells (`reasampler_editor` / `reasampler_embed`),
|
||||||
which arrive on dev with Phase S. (L1/L2/L4/L5/L6/L7 have **already landed** — see
|
which arrive on dev with Phase S. (L1/L2/L4/L5/L6/L7 have **already landed** — see
|
||||||
`COMPLETED.md`; the once-listed "L2 pending" is stale and has been corrected here.)
|
`docs/ARCHIVE.md`; the once-listed "L2 pending" is stale and has been corrected here.)
|
||||||
- **D2** is **functionally complete** (D2-W1..W3-B landed; the only open item — a per-track
|
- **D2** is **functionally complete** (D2-W1..W3-B landed; the only open item — a per-track
|
||||||
lane-split panel indicator — is *explicitly deferred*, not a blocking residual). **M9** (slots)
|
lane-split panel indicator — is *explicitly deferred*, not a blocking residual). **M9** (slots)
|
||||||
is **abandoned** (Daniel, 2026-07-27) — will not be built. D2 is named in the gate only so a
|
is **abandoned** (Daniel, 2026-07-27) — will not be built. D2 is named in the gate only so a
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
Date: 2026-07-28 · Branch: `pq-w0-audit` · Static analysis only; no code changed by the audit.
|
Date: 2026-07-28 · Branch: `pq-w0-audit` · Static analysis only; no code changed by the audit.
|
||||||
|
|
||||||
This is the committed Q-W0 findings report (Q-10 SETTLED: a committed doc beside the SOLID/naming
|
This is the committed Q-W0 findings report (Q-10 SETTLED: a committed doc beside the SOLID/naming
|
||||||
audit — `docs/product/code-organization.md` §2c.3; deliverable contract in PLAN.md §Q-W0). It
|
audit — `docs/product/code-organization.md` §2c.3; deliverable contract landed per `docs/ARCHIVE.md`
|
||||||
|
§Phase Q). It
|
||||||
synthesizes four parallel audit tracks; the full track notes remain in the tree as appendices and
|
synthesizes four parallel audit tracks; the full track notes remain in the tree as appendices and
|
||||||
are the evidence base for every claim here — this report cites finding IDs and does not restate
|
are the evidence base for every claim here — this report cites finding IDs and does not restate
|
||||||
mechanisms in full:
|
mechanisms in full:
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# Design View — product notes
|
# Design View — product notes
|
||||||
|
|
||||||
Framing, rationale, and design-direction calls behind the **Design View** phase.
|
Framing, rationale, and design-direction calls behind the **Design View** phase.
|
||||||
The tickable spec lives in `PLAN.md` (Phase D) and the authoritative technical
|
The tickable spec's landed history lives in `docs/ARCHIVE.md` (Phase D) and the
|
||||||
detail in `CONTEXT.md` (§Design View). This doc holds the *why* — the workflow
|
architecture detail in `src/core/view/CLAUDE.md` + `src/shell/view/CLAUDE.md`
|
||||||
|
(§Design View). This doc holds the *why* — the workflow
|
||||||
narrative, the N-mode reasoning, the screenset differentiation, and the
|
narrative, the N-mode reasoning, the screenset differentiation, and the
|
||||||
design-direction recommendations — so those don't clutter the build docs.
|
design-direction recommendations — so those don't clutter the build docs.
|
||||||
|
|
||||||
@@ -339,8 +340,7 @@ tooltip on the switch.
|
|||||||
`docs/design/`), say so and it moves. Flagging because establishing the
|
`docs/design/`), say so and it moves. Flagging because establishing the
|
||||||
directory is a project convention, not mine to assume silently.
|
directory is a project convention, not mine to assume silently.
|
||||||
2. **Phase namespace.** Proposed **Phase D** (a lettered namespace) rather than
|
2. **Phase namespace.** Proposed **Phase D** (a lettered namespace) rather than
|
||||||
M12 — see PLAN.md rationale. Confirm you're happy with letters for parallel,
|
M12. Confirm you're happy with letters for parallel, non-capture phases.
|
||||||
non-capture phases.
|
|
||||||
3. **SDK name correction.** The brief cited `B_SHOWINMCP`; the verified SDK flag
|
3. **SDK name correction.** The brief cited `B_SHOWINMCP`; the verified SDK flag
|
||||||
for mixer-panel visibility is **`B_SHOWINMIXER`** (SDK header line 2235). The
|
for mixer-panel visibility is **`B_SHOWINMIXER`** (SDK header line 2235). The
|
||||||
spec uses the verified name. Just flagging the discrepancy so it isn't a
|
spec uses the verified name. Just flagging the discrepancy so it isn't a
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
Framing for a **MIDI-triggered audio sampler** that plays back ReaSampler's captured
|
Framing for a **MIDI-triggered audio sampler** that plays back ReaSampler's captured
|
||||||
banks. This began as a discussion-shaping doc; with all forks now settled it has become
|
banks. This began as a discussion-shaping doc; with all forks now settled it has become
|
||||||
the **product framing behind a scoped phase**. Its build roadmap lives in **PLAN.md
|
the **product framing behind a scoped phase**. Its build roadmap lives in
|
||||||
§Phase S** and its authoritative spec in **CONTEXT.md §Phase S** — this doc holds the
|
**`docs/ARCHIVE.md` §Phase S** and its authoritative spec in
|
||||||
*why* (the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, the settled
|
**`src/core/instrument/CLAUDE.md`** and **`src/shell/instrument/CLAUDE.md`** — this
|
||||||
decision record).
|
doc holds the *why* (the plugin-format reasoning, the bare-VST3-vs-JUCE assessment, the
|
||||||
|
settled decision record).
|
||||||
|
|
||||||
Status: framed by product-designer (2026-07-26), **revised 2026-07-27 (r11)**. r11 records the
|
Status: framed by product-designer (2026-07-26), **revised 2026-07-27 (r11)**. r11 records the
|
||||||
**Sample-face recomposition** (Daniel's post-landing DAW pass, 2026-07-27): all linear sliders →
|
**Sample-face recomposition** (Daniel's post-landing DAW pass, 2026-07-27): all linear sliders →
|
||||||
@@ -42,10 +43,11 @@ demoted to an opt-in Zones panel — see the r6 Addendum in §4. r6 also settles
|
|||||||
S1–S6 instrument: the product name **ReaSampler 9000** and the **"better than RS5K" UX
|
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
|
overhaul** (Phase S points S10–S13) — see the r5 Addendum in §4. r4 (below) settled the four
|
||||||
residual forks D-A..D-D. The
|
residual forks D-A..D-D. The
|
||||||
"no PLAN.md footprint" era is **over** — with D-A through D-D settled (below), the
|
"no landed-roadmap footprint" era is **over** — with D-A through D-D settled (below), the
|
||||||
instrument was scoped into **Phase S** (codename Daniel's: "S" for Sampler, because "D"
|
instrument was scoped into **Phase S** (codename Daniel's: "S" for Sampler, because "D"
|
||||||
collides with the existing Design View phase). **PLAN.md §Phase S is now the
|
collides with the existing Design View phase). **`docs/ARCHIVE.md` §Phase S now records
|
||||||
authoritative roadmap; CONTEXT.md §Phase S is the authoritative spec.** This doc is the
|
the landed roadmap; `src/core/instrument/CLAUDE.md` and `src/shell/instrument/CLAUDE.md`
|
||||||
|
are the authoritative spec.** This doc is the
|
||||||
framing/decision record they point back to. Prior revisions (a) established that a REAPER
|
framing/decision record they point back to. Prior revisions (a) established that a REAPER
|
||||||
*extension* cannot be a MIDI instrument, (b) corrected a material omission — REAPER's
|
*extension* cannot be a MIDI instrument, (b) corrected a material omission — REAPER's
|
||||||
**VST-host bridge**, which lets a VST3 plugin *hosted inside REAPER* call back into
|
**VST-host bridge**, which lets a VST3 plugin *hosted inside REAPER* call back into
|
||||||
@@ -410,8 +412,8 @@ it doesn't carry" — is unchanged. What the bridge settles is *where that mappi
|
|||||||
between extension and instrument as **live shared `"reasampler"` state**, not a file one
|
between extension and instrument as **live shared `"reasampler"` state**, not a file one
|
||||||
writes and the other re-parses.
|
writes and the other re-parses.
|
||||||
|
|
||||||
**What the current index carries** (from `bank_model`'s `Sample`, per CONTEXT.md §Data
|
**What the current index carries** (from `bank_model`'s `Sample`): id, display name,
|
||||||
model): id, display name, relative path, source range, channel count, sample rate,
|
relative path, source range, channel count, sample rate,
|
||||||
length, capture tempo, an **optional key**, peak/RMS/LUFS, content hash, tier,
|
length, capture tempo, an **optional key**, peak/RMS/LUFS, content hash, tier,
|
||||||
provenance, timestamp. Notably it *already* has an optional key field and capture
|
provenance, timestamp. Notably it *already* has an optional key field and capture
|
||||||
tempo — the seeds of pitch-mapping are there.
|
tempo — the seeds of pitch-mapping are there.
|
||||||
@@ -426,8 +428,8 @@ tempo — the seeds of pitch-mapping are there.
|
|||||||
to velocity zones).
|
to velocity zones).
|
||||||
- **Round-robin groups** (cycle through N samples on repeated same-note hits).
|
- **Round-robin groups** (cycle through N samples on repeated same-note hits).
|
||||||
- **Loop points** (sustain loop start/end for held notes; sample-accurate,
|
- **Loop points** (sustain loop start/end for held notes; sample-accurate,
|
||||||
zero-crossing-aware — CONTEXT already flags loop/zero-crossing handling as
|
zero-crossing-aware — loop/zero-crossing handling is day-one-relevant for
|
||||||
day-one-relevant for wavetable material).
|
wavetable material).
|
||||||
- **Amplitude envelope** (ADSR) and optionally filter/pitch envelopes.
|
- **Amplitude envelope** (ADSR) and optionally filter/pitch envelopes.
|
||||||
- **Tuning/gain trim** per sample.
|
- **Tuning/gain trim** per sample.
|
||||||
|
|
||||||
@@ -559,7 +561,7 @@ ReaSampler-native way to build it and it's assumed, not debated, going forward.
|
|||||||
|
|
||||||
All four residual decisions are now called. Each is marked **SETTLED** with Daniel's
|
All four residual decisions are now called. Each is marked **SETTLED** with Daniel's
|
||||||
choice and the reasoning kept as the record of *why* — do not re-litigate. They are
|
choice and the reasoning kept as the record of *why* — do not re-litigate. They are
|
||||||
scoped into **PLAN.md §Phase S** / **CONTEXT.md §Phase S**.
|
scoped into **`docs/ARCHIVE.md` §Phase S** / **`src/core/instrument/CLAUDE.md`**.
|
||||||
|
|
||||||
**D-A — SETTLED: bare Steinberg VST3 SDK + LICE editor (no JUCE).** *(The central fork.
|
**D-A — SETTLED: bare Steinberg VST3 SDK + LICE editor (no JUCE).** *(The central fork.
|
||||||
§1a is the assessment that fed it. The sub-question — who draws the editor? — was the
|
§1a is the assessment that fed it. The sub-question — who draws the editor? — was the
|
||||||
@@ -649,7 +651,7 @@ After Phase S was scoped (D-A..D-D), Daniel set two further directions. These ar
|
|||||||
**settled directions**, not open forks — specced as new Phase S points (S7–S9), not
|
**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.
|
re-litigated. Recorded here per the doc's settled-decisions convention.
|
||||||
|
|
||||||
**D-E — Channel mode: mono | stereo, per-instance, bus-negotiated (→ PLAN.md S7).**
|
**D-E — Channel mode: mono | stereo, per-instance, bus-negotiated (→ `docs/ARCHIVE.md` §S7).**
|
||||||
Captures are often stereo; the current mono downmix is a Tier-0 simplification. The
|
Captures are often stereo; the current mono downmix is a Tier-0 simplification. The
|
||||||
engine gets a **per-instance channel-mode toggle (1 mono / 2 stereo)** that "works with
|
engine gets a **per-instance channel-mode toggle (1 mono / 2 stereo)** that "works with
|
||||||
the REAPER audio bus automatically" — the VST3 declares/negotiates its output bus
|
the REAPER audio bus automatically" — the VST3 declares/negotiates its output bus
|
||||||
@@ -662,8 +664,8 @@ Cross-mode policy: mono-source-in-stereo → dual-mono; stereo-source-in-mono
|
|||||||
never a bank fact). Sequenced **first after the editor/embed work** because it touches the
|
never a bank fact). Sequenced **first after the editor/embed work** because it touches the
|
||||||
engine Daniel smoke-tests.
|
engine Daniel smoke-tests.
|
||||||
|
|
||||||
**Ingest routes through the bank — "option 1"; the extension owns ingest (→ PLAN.md
|
**Ingest routes through the bank — "option 1"; the extension owns ingest (→
|
||||||
S8 + S9).** Loading a sample into the sampler is **one gesture**: capture/import-into-bank
|
`docs/ARCHIVE.md` §S8 + §S9).** Loading a sample into the sampler is **one gesture**: capture/import-into-bank
|
||||||
+ auto-assign to the active instance. The **extension owns ingest** (it has arrange
|
+ auto-assign to the active instance. The **extension owns ingest** (it has arrange
|
||||||
access, Media-Explorer access, and the drop-target surface on its own panels); the
|
access, Media-Explorer access, and the drop-target surface on its own panels); the
|
||||||
**instrument stays a read-only bank consumer** — it never captures or imports. Sub-parts,
|
**instrument stays a read-only bank consumer** — it never captures or imports. Sub-parts,
|
||||||
@@ -690,7 +692,7 @@ with the honest SDK reality verified against the vendored headers:
|
|||||||
|
|
||||||
*The genuine spikes flagged (not decisions Daniel owes, just build-time unknowns):* the
|
*The genuine spikes flagged (not decisions Daniel owes, just build-time unknowns):* the
|
||||||
ME merely-selected-file read (b), and the drop-onto-editor cross-artifact relay (c). Both
|
ME merely-selected-file read (b), and the drop-onto-editor cross-artifact relay (c). Both
|
||||||
are honestly-flagged as spikes in PLAN.md S8, not promised.
|
are honestly-flagged as spikes in `docs/ARCHIVE.md` §S8, not promised.
|
||||||
|
|
||||||
### Addendum — product name + UX overhaul (Daniel, 2026-07-26, post-S1–S6 DAW test)
|
### 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**
|
**The instrument's product name is `ReaSampler 9000`.** The extension stays **ReaSampler**
|
||||||
(capture + organization); the instrument is **ReaSampler 9000** (playback). Propagation is
|
(capture + organization); the instrument is **ReaSampler 9000** (playback). Propagation is
|
||||||
a checklist item (PLAN.md §Phase S — product name; CONTEXT.md §Product name): the VST3
|
recorded in `docs/ARCHIVE.md` §Phase S — product name: the VST3
|
||||||
class **display name** string, the `IPlugView` editor title band (today "ReaSampler
|
class **display name** string, the `IPlugView` editor title band (today "ReaSampler
|
||||||
Instrument"), the S6 embed-strip label, and the docs. **Compat guard (load-bearing):** the
|
Instrument"), the S6 embed-strip label, and the docs. **Compat guard (load-bearing):** the
|
||||||
**VST3 class UID must NOT change** — instances in saved projects key off it; a UID change
|
**VST3 class UID must NOT change** — instances in saved projects key off it; a UID change
|
||||||
@@ -747,8 +749,9 @@ names the editor as the wound.
|
|||||||
|
|
||||||
After the r5 UX-overhaul directive was specced (keymap-first S10), Daniel reframed the
|
After the r5 UX-overhaul directive was specced (keymap-first S10), Daniel reframed the
|
||||||
workflow before S10 was implemented. This **revises S10** and settles S-NAME-1. Settled
|
workflow before S10 was implemented. This **revises S10** and settles S-NAME-1. Settled
|
||||||
directions, not open forks — recorded here per the doc's settled-decisions convention; PLAN.md
|
directions, not open forks — recorded here per the doc's settled-decisions convention;
|
||||||
§S10 and CONTEXT.md §Phase S (workflow hierarchy) carry the spec.
|
`docs/ARCHIVE.md` §S10 records what landed and `src/core/instrument/CLAUDE.md` (the
|
||||||
|
editor `ui/` modules) documents the current architecture.
|
||||||
|
|
||||||
**The reframe, verbatim (Daniel, 2026-07-26):** *"We need to think hard about the workflow
|
**The reframe, verbatim (Daniel, 2026-07-26):** *"We need to think hard about the workflow
|
||||||
with this plugin. Have a giant list of 'item' blocks is visually useless. When the plugin is
|
with this plugin. Have a giant list of 'item' blocks is visually useless. When the plugin is
|
||||||
@@ -806,8 +809,8 @@ partly on filename, fall back to keeping the filename and record that as shipped
|
|||||||
|
|
||||||
Daniel directed a set of engine features for the sampler, specced as **new Phase S points
|
Daniel directed a set of engine features for the sampler, specced as **new Phase S points
|
||||||
S15 (Trigger vs Gate) and S16 (pitch envelope)**. **The feature set is settled** — recorded
|
S15 (Trigger vs Gate) and S16 (pitch envelope)**. **The feature set is settled** — recorded
|
||||||
here per the doc's settled-decisions convention; PLAN.md §S15/S16 and CONTEXT.md §Sampling
|
here per the doc's settled-decisions convention; `docs/ARCHIVE.md` §S15 / §S16 records what
|
||||||
modes carry the spec. Two forks are flagged with leans (S15-F1 choke, S15-F2 param
|
landed and `src/core/instrument/CLAUDE.md` §Sampling modes documents the current spec. Two forks are flagged with leans (S15-F1 choke, S15-F2 param
|
||||||
granularity); the WDL question was resolved by inspection.
|
granularity); the WDL question was resolved by inspection.
|
||||||
|
|
||||||
**Directive, verbatim (Daniel, 2026-07-26):** *"let's have product spec out some features
|
**Directive, verbatim (Daniel, 2026-07-26):** *"let's have product spec out some features
|
||||||
@@ -927,9 +930,10 @@ This reshapes S16 and **flips the r7 WDL verdict** on `WDL_SimplePitchShifter`.
|
|||||||
`process` allocation; measure per-voice CPU + onset latency against the polyphony cap. Treat
|
`process` allocation; measure per-voice CPU + onset latency against the polyphony cap. Treat
|
||||||
S16's Preserve-engine point as the phase's next real DSP spike, not a thin envelope add-on.
|
S16's Preserve-engine point as the phase's next real DSP spike, not a thin envelope add-on.
|
||||||
|
|
||||||
**Where the spec lives:** PLAN.md §S16 (reshaped to "pitch engine modes + pitch envelope",
|
**Where the spec lives:** `docs/ARCHIVE.md` §S16 (reshaped to "pitch engine modes + pitch
|
||||||
with forks S16-F1/F2 and the corrected WDL finding) and the S15 × S16 interaction note;
|
envelope", with forks S16-F1/F2 and the corrected WDL finding) and the S15 × S16
|
||||||
CONTEXT.md §Pitch engine modes — Varispeed vs Preserve + the corrected WDL surface finding.
|
interaction note; `src/core/instrument/CLAUDE.md` §Sampling modes — Varispeed vs Preserve
|
||||||
|
+ the WDL surface finding.
|
||||||
|
|
||||||
### Addendum — VST channel isolation (Daniel, 2026-07-26)
|
### Addendum — VST channel isolation (Daniel, 2026-07-26)
|
||||||
|
|
||||||
@@ -972,9 +976,9 @@ with or right after the in-flight waves (S9 ext_keys, S15/S16 processor/editor)
|
|||||||
channel's banks; stable-project + beta-VST = clean empty (not error); the S-NAME-1
|
channel's banks; stable-project + beta-VST = clean empty (not error); the S-NAME-1
|
||||||
rename/rebind test extends to the beta UID.
|
rename/rebind test extends to the beta UID.
|
||||||
|
|
||||||
**Where the spec lives:** PLAN.md §S18; CONTEXT.md §VST3 channel identity — the UID pair + the
|
**Where the spec lives:** `docs/ARCHIVE.md` §S18; `src/shell/instrument/CLAUDE.md` §VST3
|
||||||
pairing surface. The pairing surface's data half is already load-bearing V4 machinery; S18
|
channel identity — the UID pair + the pairing surface. The pairing surface's data half is
|
||||||
adds only the identity fork on top.
|
already load-bearing V4 machinery; S18 adds only the identity fork on top.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -986,8 +990,9 @@ good — but the two-view editor (today's "Browser" + "Zones" toggle) misallocat
|
|||||||
the default window is undersized for a 1080p world, and the drop-a-capture-onto-FX gesture is
|
the default window is undersized for a 1080p world, and the drop-a-capture-onto-FX gesture is
|
||||||
broken in practice. The directive: **make the one job — pick a capture, tune it, play it —
|
broken in practice. The directive: **make the one job — pick a capture, tune it, play it —
|
||||||
fast, easy, and fun. Style is a critical ingredient. No spreadsheet aesthetics.** These are the
|
fast, easy, and fun. Style is a critical ingredient. No spreadsheet aesthetics.** These are the
|
||||||
`r9` calls. Authoritative spec: **CONTEXT.md §Phase S — editor view-model redesign (S-VIEW)**;
|
`r9` calls. Current architecture: **`src/core/instrument/CLAUDE.md`** and
|
||||||
build roadmap: **PLAN.md §Phase S — editor view-model redesign**.
|
**`src/shell/instrument/CLAUDE.md`**; landed record: **`docs/ARCHIVE.md` §Phase S — editor
|
||||||
|
view-model redesign**.
|
||||||
|
|
||||||
**The reference devices (the north star for control density).** Daniel named Ableton **Simpler**
|
**The reference devices (the north star for control density).** Daniel named Ableton **Simpler**
|
||||||
and a Kilohearts/Phase-Plant **sampler group** as the composition targets. Both share one
|
and a Kilohearts/Phase-Plant **sampler group** as the composition targets. Both share one
|
||||||
@@ -1021,7 +1026,7 @@ grammar, and it is the grammar the redesign adopts:
|
|||||||
loading a new one is a distinct act), not a three-way radio. *Why the reframe matters:* it
|
loading a new one is a distinct act), not a three-way radio. *Why the reframe matters:* it
|
||||||
makes "I just want to play this capture" the zero-click default, and "I want a different one"
|
makes "I just want to play this capture" the zero-click default, and "I want a different one"
|
||||||
a single deliberate gesture, instead of making the user re-choose their whole stance every
|
a single deliberate gesture, instead of making the user re-choose their whole stance every
|
||||||
time. See CONTEXT.md §S-VIEW for the precise navigation model.
|
time. See `docs/ARCHIVE.md` §S-VIEW-1 for the precise navigation model as landed.
|
||||||
|
|
||||||
2. **The Sample view earns the hero treatment; Browse gets ruthlessly cut.** Browse today
|
2. **The Sample view earns the hero treatment; Browse gets ruthlessly cut.** Browse today
|
||||||
carries a waveform preview, root-note piano-roll, loop-point labels, a track-root message, and
|
carries a waveform preview, root-note piano-roll, loop-point labels, a track-root message, and
|
||||||
@@ -1037,10 +1042,10 @@ grammar, and it is the grammar the redesign adopts:
|
|||||||
3. **Two engineering prerequisites, framed but routed to implementation.** The **drop-to-FX bug**
|
3. **Two engineering prerequisites, framed but routed to implementation.** The **drop-to-FX bug**
|
||||||
(dropping a capture onto a track's FX chain does not instantiate + init ReaSampler 9000) and
|
(dropping a capture onto a track's FX chain does not instantiate + init ReaSampler 9000) and
|
||||||
the **undersized default window** are not design decisions — they are a bug and a one-line
|
the **undersized default window** are not design decisions — they are a bug and a one-line
|
||||||
default. Both are framed in CONTEXT.md §S-VIEW with the SDK reality swept (drop-to-FX: the S17
|
default. Both are recorded landed in `docs/ARCHIVE.md` §S-VIEW-BUG-1 (drop-to-FX: the S17
|
||||||
machinery is SDK-correct, so this is a *diagnosis* task, not a redesign; window size: the
|
machinery is SDK-correct, so this is a *diagnosis* task, not a redesign) and §S-VIEW-SIZE-1
|
||||||
`getSize`/`checkSizeConstraint` mechanism is verified), and both are flagged for
|
(window size: the `getSize`/`checkSizeConstraint` mechanism is verified), and both were
|
||||||
staff-engineer, not for a product fork.
|
flagged for staff-engineer, not for a product fork.
|
||||||
|
|
||||||
**New parameters this introduces (both instrument performance state, D-B — never bank facts):**
|
**New parameters this introduces (both instrument performance state, D-B — never bank facts):**
|
||||||
|
|
||||||
@@ -1064,7 +1069,7 @@ grammar, and it is the grammar the redesign adopts:
|
|||||||
new top-level `previewVelocity` field), **not** the extension's `persist` project ext-state —
|
new top-level `previewVelocity` field), **not** the extension's `persist` project ext-state —
|
||||||
that module is REAPER-project-scoped and extension-owned, so it would make the level
|
that module is REAPER-project-scoped and extension-owned, so it would make the level
|
||||||
project-global instead of per-instance and route an instrument concern through a bank-read-only
|
project-global instead of per-instance and route an instrument concern through a bank-read-only
|
||||||
seam. See CONTEXT.md §S-VIEW for the round-trip and back-compat lift. This is what makes the
|
seam. See `docs/ARCHIVE.md` §S-VIEW-4 for the round-trip and back-compat lift as landed. This is what makes the
|
||||||
preview button *fun*: tap it hard or soft without reaching for a controller — and it remembers.
|
preview button *fun*: tap it hard or soft without reaching for a controller — and it remembers.
|
||||||
|
|
||||||
**Two visual components the redesign commits to:**
|
**Two visual components the redesign commits to:**
|
||||||
@@ -1101,8 +1106,10 @@ persisted fields is not a compat event; saved instances rebind and restore. And
|
|||||||
(extended additively — `keyTrack` per-zone, `previewVelocity` per-instance via an envelope bump to
|
(extended additively — `keyTrack` per-zone, `previewVelocity` per-instance via an envelope bump to
|
||||||
v6, both with back-compat defaults on read) are the same load-bearing core.
|
v6, both with back-compat defaults on read) are the same load-bearing core.
|
||||||
|
|
||||||
**Where the spec lives:** CONTEXT.md §Phase S — editor view-model redesign (S-VIEW); PLAN.md
|
**Where the spec lives:** `src/core/instrument/CLAUDE.md` (envelope overlay, key-tracking,
|
||||||
§Phase S — editor view-model redesign. This Addendum is the *why*; those are the *what/how*.
|
preview-velocity ownership) documents the current architecture; `docs/ARCHIVE.md` §Phase S
|
||||||
|
— editor view-model redesign records what landed (S-VIEW-1 through S-VIEW-10). This
|
||||||
|
Addendum is the *why*; those are the *what/how*.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1217,8 +1224,9 @@ for preview velocity — a different struct on a different version axis). Concre
|
|||||||
the L1 kit, routing mouse through `velocity_curve`), gated on the foundation track and composing
|
the L1 kit, routing mouse through `velocity_curve`), gated on the foundation track and composing
|
||||||
with the S-VIEW-2 Sample face + S-VIEW-3 envelope-overlay work.
|
with the S-VIEW-2 Sample face + S-VIEW-3 envelope-overlay work.
|
||||||
|
|
||||||
**Where the spec lives:** CONTEXT.md §Phase S — editor view-model redesign (S-VIEW), velocity-curve
|
**Where the spec lives:** `src/core/instrument/CLAUDE.md` (the `velocity_curve` module, its
|
||||||
sub-section; PLAN.md §Phase S — editor view-model redesign (S-VIEW-9/S-VIEW-10 + fork R10-F1). This
|
engine application point, and its ownership rules) documents the current architecture;
|
||||||
|
`docs/ARCHIVE.md` §S-VIEW-9 / §S-VIEW-10 records what landed (fork R10-F1 resolved). This
|
||||||
Addendum is the *why*; those are the *what/how*.
|
Addendum is the *why*; those are the *what/how*.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1273,16 +1281,20 @@ small labelled knobs in functional clusters** — and drives the **Wave B editor
|
|||||||
same knob deck + button/popup — Sample and Zone already edit one storage site (S15-F2); the lean
|
same knob deck + button/popup — Sample and Zone already edit one storage site (S15-F2); the lean
|
||||||
is that they should speak one control grammar too. Daniel's call (it grows Wave B scope).
|
is that they should speak one control grammar too. Daniel's call (it grows Wave B scope).
|
||||||
|
|
||||||
**What does NOT change (guardrails).** Zero engine/state/persistence change — this is a view
|
**What does NOT change (guardrails).** Zero engine-behavior change — this is a view
|
||||||
recomposition of *existing* controls; no new params, no component-state bump, VST3 class UID
|
recomposition of *existing* controls, with one additive exception: the new post-mixer master
|
||||||
unchanged. All drawing through the L1 kit by palette role; all layout/hit-test in new pure modules
|
gain persists as `masterGainLinear`, bumping `ComponentState` v7→v8 (back-compat: pre-v8 blobs
|
||||||
|
lift to unity gain); VST3 class UID unchanged. All drawing through the L1 kit by palette role;
|
||||||
|
all layout/hit-test in new pure modules
|
||||||
(`knob_deck`, `curve_popup` — mirrors of `action_bar`/`overflow_menu`); the knobs and the hero's
|
(`knob_deck`, `curve_popup` — mirrors of `action_bar`/`overflow_menu`); the knobs and the hero's
|
||||||
envelope nodes remain two surfaces on one param model (S-VIEW-F2's structural sync, untouched).
|
envelope nodes remain two surfaces on one param model (S-VIEW-F2's structural sync, untouched).
|
||||||
The full inventory contract (every landed element → its r11 home) is in the CONTEXT.md spec.
|
The full inventory of what landed (every element → its r11 home) is recorded in
|
||||||
|
`docs/ARCHIVE.md` §FB1 and §FB2.
|
||||||
|
|
||||||
**Where the spec lives:** CONTEXT.md §Phase S — editor view-model redesign (S-VIEW) → "The
|
**Where the spec lives:** `src/core/instrument/CLAUDE.md` (the `knob_deck`/`curve_popup`/
|
||||||
Sample-face recomposition (r11)"; PLAN.md §Phase S — editor Wave B (S-VIEW-11/12/13 + forks
|
`master_gain` modules) documents the current architecture; `docs/ARCHIVE.md` §FB1 and §FB2
|
||||||
R11-F1/R11-F2). This Addendum is the *why*; those are the *what/how*.
|
record what landed (S-VIEW-11/12/13 + forks R11-F1/R11-F2 resolved). This Addendum is the
|
||||||
|
*why*; those are the *what/how*.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1329,7 +1341,8 @@ Post-DAW-test directives (2026-07-26; see the "product name + UX overhaul" Adden
|
|||||||
frameworks), DS-2 (Direction B "Neon Console" + Direction C's spectral keyboard strip), and
|
frameworks), DS-2 (Direction B "Neon Console" + Direction C's spectral keyboard strip), and
|
||||||
DS-3 (thorough panel layout) are all **SETTLED (2026-07-26)**. Framing + palette + the three
|
DS-3 (thorough panel layout) are all **SETTLED (2026-07-26)**. Framing + palette + the three
|
||||||
visual directions + forks: `docs/product/visual-design-language.md` (on `dev`); roadmap +
|
visual directions + forks: `docs/product/visual-design-language.md` (on `dev`); roadmap +
|
||||||
spec: **PLAN.md §Phase L + CONTEXT.md §Phase L** (on `dev`). **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
|
current drawing and adopt the L1 kit when it lands — not gated on Phase L.** Answers
|
||||||
Daniel's "the VST is dogshit / temple os / does Cockos have a toolkit" (2026-07-26,
|
Daniel's "the VST is dogshit / temple os / does Cockos have a toolkit" (2026-07-26,
|
||||||
post-S1–S6 DAW test).
|
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;
|
S15-F1 (choke, held) / S15-F2 (param granularity, lean per-zone). Feature set settled;
|
||||||
the engine default is Daniel's fork.
|
the engine default is Daniel's fork.
|
||||||
|
|
||||||
**Authoritative from here:** **PLAN.md §Phase S** is the roadmap (S1–S6 the original
|
**Authoritative from here:** **`docs/ARCHIVE.md` §Phase S** is the landed roadmap (S1–S6
|
||||||
dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier 1 → embedded
|
the original dependency chain: spike → `Sample` fields → pure sampler core → Tier 0 → Tier
|
||||||
UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10–S13** the ReaSampler
|
1 → embedded UI; then **S7** stereo, **S8** ingest, **S9** change-detection, **S10–S13** the
|
||||||
9000 UX overhaul, **S15/S16** the Trigger-vs-Gate + pitch-engine-modes engine features);
|
ReaSampler 9000 UX overhaul, **S15/S16** the Trigger-vs-Gate + pitch-engine-modes engine
|
||||||
**CONTEXT.md §Phase S** is the spec (seam-field semantics, scope contracts, the channel-mode
|
features); **`src/core/instrument/CLAUDE.md`**, **`src/shell/instrument/CLAUDE.md`**, and
|
||||||
/ ingest / bank-generation / sampling-mode / pitch-engine contracts, the UX-overhaul spec,
|
**`src/core/wire/CLAUDE.md`** are the current spec (seam-field semantics, scope contracts,
|
||||||
the product-name convention, the pure/shell split, the WDL finding, the must-verify
|
the channel-mode / ingest / bank-generation / sampling-mode / pitch-engine contracts, the
|
||||||
SDK/bridge surfaces). This doc is the framing/decision record they point back to. The "no
|
UX-overhaul spec, the product-name convention, the pure/shell split, the WDL finding, the
|
||||||
PLAN.md footprint" era is over.
|
must-verify SDK/bridge surfaces). This doc is the framing/decision record they point back
|
||||||
|
to. The "no landed-roadmap footprint" era is over.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# Multi-bank — product notes
|
# Multi-bank — product notes
|
||||||
|
|
||||||
Framing, rationale, and design-direction calls behind the **Multi-bank** phase.
|
Framing, rationale, and design-direction calls behind the **Multi-bank** phase.
|
||||||
The tickable spec lives in `PLAN.md` (Phase B) and the authoritative technical
|
The tickable spec's landed history lives in `docs/ARCHIVE.md` (Phase B) and the
|
||||||
detail in `CONTEXT.md` (§Multi-bank). This doc holds the *why* — the workflow
|
architecture detail in `src/core/model/CLAUDE.md` + `src/shell/bank_ops/CLAUDE.md`
|
||||||
|
(§Multi-bank). This doc holds the *why* — the workflow
|
||||||
narrative, the pool-privilege reasoning, the movement semantics, and the
|
narrative, the pool-privilege reasoning, the movement semantics, and the
|
||||||
design-direction recommendations — so those don't clutter the build docs.
|
design-direction recommendations — so those don't clutter the build docs.
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Provenance — product notes
|
# Provenance — product notes
|
||||||
|
|
||||||
Framing, rationale, and the dual-canvas reconciliation behind the reshaped
|
Framing, rationale, and the dual-canvas reconciliation behind the reshaped
|
||||||
**Milestone 10 (provenance)**. The tickable spec lives in `PLAN.md` (M10); the
|
**Milestone 10 (provenance)**. The tickable spec's landed history is in
|
||||||
authoritative technical detail is `CONTEXT.md` (§Data model, §capture) plus this
|
`docs/ARCHIVE.md` (M10); the architecture detail is in `src/core/model/CLAUDE.md`
|
||||||
note for the reconciliation calls. This doc holds the *why* and the open forks so
|
and `src/shell/capture/CLAUDE.md` plus this note for the reconciliation calls.
|
||||||
they don't clutter the build docs.
|
This doc holds the *why* and the open forks so they don't clutter the build docs.
|
||||||
|
|
||||||
Status: **IMPLEMENTED (2026-07-26).** Settled 2026-07-23; landed 2026-07-26.
|
Status: **IMPLEMENTED (2026-07-26).** Settled 2026-07-23; landed 2026-07-26.
|
||||||
Reshaped from the old "provenance + null-test verify" M10. Two decisions were fixed
|
Reshaped from the old "provenance + null-test verify" M10. Two decisions were fixed
|
||||||
|
|||||||
@@ -2,24 +2,26 @@
|
|||||||
|
|
||||||
Framing, rationale, and open forks behind the two missing removal capabilities:
|
Framing, rationale, and open forks behind the two missing removal capabilities:
|
||||||
**sample-remove** (a sample-level index verb) and **prune** (the file-lifecycle
|
**sample-remove** (a sample-level index verb) and **prune** (the file-lifecycle
|
||||||
path CONTEXT.md keeps forward-referencing but never scoped). The tickable spec
|
path the spec kept forward-referencing but never scoped). The tickable spec's
|
||||||
lives in `PLAN.md` (Phase B point B5 for remove; **Phase R** for prune) and the
|
landed history is in `docs/ARCHIVE.md` (Phase B point B5 for remove; **Phase R**
|
||||||
authoritative technical detail in `CONTEXT.md` (§Sample removal, §Prune — file
|
for prune) and the architecture detail lives in `src/core/model/CLAUDE.md` +
|
||||||
lifecycle). This doc holds the *why* — the workflow, the guardrails, the
|
`src/shell/bank_ops/CLAUDE.md` (§Sample removal) and `src/core/reclaim/CLAUDE.md`
|
||||||
index-vs-file boundary, and the forks that need a Daniel decision.
|
+ `src/shell/persist/CLAUDE.md` (§Prune — file lifecycle). This doc holds the
|
||||||
|
*why* — the workflow, the guardrails, the index-vs-file boundary, and the forks
|
||||||
|
that need a Daniel decision.
|
||||||
|
|
||||||
Status: framed by product-designer (2026-07-23); **all five forks settled by Daniel
|
Status: framed by product-designer (2026-07-23); **all five forks settled by Daniel
|
||||||
(2026-07-24)** — R-A this-bank-primary, R-B batched REAPER undo points
|
(2026-07-24)** — R-A this-bank-primary, R-B batched REAPER undo points
|
||||||
(Phase-B-wide), R-C trash-preferred-with-unlink-fallback, R-D owned-file manifest
|
(Phase-B-wide), R-C trash-preferred-with-unlink-fallback, R-D owned-file manifest
|
||||||
(seam lands early in Phase B / capture), R-E manual action + panel button. The
|
(seam lands early in Phase B / capture), R-E manual action + panel button. The
|
||||||
decisions are folded into the fork sections below and into the B5 / Phase R spec
|
decisions are folded into the fork sections below and into the B5 / Phase R
|
||||||
prose in CONTEXT.md and the tickable points in PLAN.md.
|
history in `docs/ARCHIVE.md` and the architecture docs above.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The one boundary that governs everything: index vs. file
|
## The one boundary that governs everything: index vs. file
|
||||||
|
|
||||||
ReaSampler already draws a hard line, stated repeatedly in CONTEXT.md: **a bank
|
ReaSampler already draws a hard line: **a bank
|
||||||
operation touches the *index*, never the *file*.** Move, copy, evacuate, and
|
operation touches the *index*, never the *file*.** Move, copy, evacuate, and
|
||||||
delete-bank are all index-only; files persist on disk "until prune." Every
|
delete-bank are all index-only; files persist on disk "until prune." Every
|
||||||
removal capability below sits on exactly one side of that line, and keeping the
|
removal capability below sits on exactly one side of that line, and keeping the
|
||||||
@@ -120,7 +122,7 @@ line. It is the verb Phase B forgot, not a new pillar.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Prune — the file-lifecycle path CONTEXT.md kept promising
|
## Prune — the file-lifecycle path the spec kept promising
|
||||||
|
|
||||||
### What the user is doing
|
### What the user is doing
|
||||||
|
|
||||||
@@ -131,7 +133,7 @@ purpose. Over a long project the bank folder accumulates dead `.wav` files that
|
|||||||
cost disk and clutter. **Prune is the reclaim pass**: "sweep the bank folder,
|
cost disk and clutter. **Prune is the reclaim pass**: "sweep the bank folder,
|
||||||
delete the files nothing references, tell me what you reclaimed."
|
delete the files nothing references, tell me what you reclaimed."
|
||||||
|
|
||||||
This is the path CONTEXT.md forward-references in at least four places ("files
|
This is the path the spec forward-references in at least four places ("files
|
||||||
persist on disk until prune," "the capture/prune path reclaims it") but never
|
persist on disk until prune," "the capture/prune path reclaims it") but never
|
||||||
scopes. It is a real, promised capability with **no phase, no module, no point**
|
scopes. It is a real, promised capability with **no phase, no module, no point**
|
||||||
— a dangling reference the plan has to make good on.
|
— a dangling reference the plan has to make good on.
|
||||||
@@ -141,8 +143,8 @@ scopes. It is a real, promised capability with **no phase, no module, no point**
|
|||||||
ReaSampler already shipped this exact shape once. Design View's `view_mode_model`
|
ReaSampler already shipped this exact shape once. Design View's `view_mode_model`
|
||||||
has **`ViewModeModel::reconcile(liveGuids)`** — a pure function fed the live set
|
has **`ViewModeModel::reconcile(liveGuids)`** — a pure function fed the live set
|
||||||
(the tracks that still exist), returning the residual membership entries to drop
|
(the tracks that still exist), returning the residual membership entries to drop
|
||||||
(CONTEXT.md §Design View: "prunes orphaned snapshots on every toggle/load;
|
(`src/core/view/CLAUDE.md`: "tolerates unknown/stale GUIDs (pruned on reconcile
|
||||||
tolerates unknown/stale GUIDs (prune on reconcile)"). Prune is the **file-pool
|
via `ViewModeModel::reconcile(liveGuids)`)"). Prune is the **file-pool
|
||||||
mirror of that pure pattern**:
|
mirror of that pure pattern**:
|
||||||
|
|
||||||
> `reconcile(liveGuids)` reconciles *membership entries* against *live tracks*.
|
> `reconcile(liveGuids)` reconciles *membership entries* against *live tracks*.
|
||||||
@@ -224,7 +226,7 @@ Prune is **not** a Phase B point. Three reasons it earns its own lettered phase
|
|||||||
|
|
||||||
1. **It is a different pillar.** Phase B is the *bank container* pillar
|
1. **It is a different pillar.** Phase B is the *bank container* pillar
|
||||||
(index-only, non-destructive, above the file). Prune is the *file lifecycle*
|
(index-only, non-destructive, above the file). Prune is the *file lifecycle*
|
||||||
pillar (the one path that deletes files). CONTEXT.md already names it as a
|
pillar (the one path that deletes files). The spec already named it as a
|
||||||
separate concern every time it says "the capture/**prune** path" — file
|
separate concern every time it says "the capture/**prune** path" — file
|
||||||
lifecycle is spoken of as its own thing, owned by neither the capture nor the
|
lifecycle is spoken of as its own thing, owned by neither the capture nor the
|
||||||
bank layer. Giving it its own phase matches how the spec already talks about it.
|
bank layer. Giving it its own phase matches how the spec already talks about it.
|
||||||
@@ -337,7 +339,8 @@ capture).** The book tracks the set of files it has created; prune reclaims
|
|||||||
ext-state**, and Phase R's R1/R2 *consume* that manifest. The exact persistence
|
ext-state**, and Phase R's R1/R2 *consume* that manifest. The exact persistence
|
||||||
shape — a sibling ext-state key vs. folded into the `banks` blob — is a small
|
shape — a sibling ext-state key vs. folded into the `banks` blob — is a small
|
||||||
residual to settle at build; the **manifest-now decision is firm**. (Settled
|
residual to settle at build; the **manifest-now decision is firm**. (Settled
|
||||||
2026-07-24; the up-front point is added to Phase B / the capture path in PLAN.md.)
|
2026-07-24; the up-front point is recorded in `docs/ARCHIVE.md` under Phase B /
|
||||||
|
the capture path.)
|
||||||
|
|
||||||
**Fork R-E — prune trigger: manual-only vs. offer-on-orphaning vs. periodic.
|
**Fork R-E — prune trigger: manual-only vs. offer-on-orphaning vs. periodic.
|
||||||
SETTLED: MANUAL ACTION + PANEL BUTTON.** Prune runs via a bindable manual action
|
SETTLED: MANUAL ACTION + PANEL BUTTON.** Prune runs via a bindable manual action
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ concrete and decidable:
|
|||||||
2. **A beta side-channel** — so development can continue and a beta build run
|
2. **A beta side-channel** — so development can continue and a beta build run
|
||||||
*alongside* the stable one without the beta clobbering the release.
|
*alongside* the stable one without the beta clobbering the release.
|
||||||
|
|
||||||
This doc holds the *why*, the forks, and a recommendation. When Daniel picks, the
|
This doc holds the *why*, the forks, and a recommendation. The tickable points'
|
||||||
tickable points land in `PLAN.md` and the deploy/build wiring hands off to dev-ops.
|
landed history is in `docs/ARCHIVE.md`; the deploy/build wiring hands off to dev-ops.
|
||||||
This is a framing note; it changes no source or CMake.
|
This is a framing note; it changes no source or CMake.
|
||||||
|
|
||||||
Status: framed by product-designer (2026-07-26); **all four forks settled by Daniel
|
Status: framed by product-designer (2026-07-26); **all four forks settled by Daniel
|
||||||
@@ -54,7 +54,7 @@ Two sharp edges follow directly and recur throughout this note:
|
|||||||
Both binaries reading/writing the same namespace on the same open project means a
|
Both binaries reading/writing the same namespace on the same open project means a
|
||||||
**beta can read — and rewrite — a stable project's saved bank/view state.** Given
|
**beta can read — and rewrite — a stable project's saved bank/view state.** Given
|
||||||
the forward-only migrations already in the design (legacy `bank_index` retired
|
the forward-only migrations already in the design (legacy `bank_index` retired
|
||||||
after promotion; `banks` authoritative thereafter — CONTEXT.md §Multi-bank), a
|
after promotion; `banks` authoritative thereafter — `docs/ARCHIVE.md` §Phase B), a
|
||||||
beta that writes a newer schema into a project a user then reopens in stable is a
|
beta that writes a newer schema into a project a user then reopens in stable is a
|
||||||
real corruption path, not a theoretical one.
|
real corruption path, not a theoretical one.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
revised 2026-07-26 (Daniel)** from the single electric-cyan accent to a **three-accent
|
||||||
pastel system** (primary/secondary/tertiary = pastel lime green / pastel teal / pastel
|
pastel system** (primary/secondary/tertiary = pastel lime green / pastel teal / pastel
|
||||||
purple) — see §6. (A stylish-font upgrade was considered and **declined** by Daniel — no
|
purple) — see §6. (A stylish-font upgrade was considered and **declined** by Daniel — no
|
||||||
font bundling/redistribution; the kit keeps its current cached-font face.) The build points it feeds live in
|
font bundling/redistribution; the kit keeps its current cached-font face.) The build points it
|
||||||
**PLAN.md §Phase L** (**L1** the shared LICE drawing kit, **L2** the dock-panel layout
|
fed have landed; their history is in **`docs/ARCHIVE.md`** ("Phase L — Look-and-feel", **L1** the
|
||||||
redesign, **L3** the VST editor + embed-strip restyle) and **CONTEXT.md §Phase L** (the
|
shared LICE drawing kit, **L2** the dock-panel layout redesign, **L3** the VST editor +
|
||||||
design-system spec). Toolkit facts below are **verified against the vendored `vendor/WDL`
|
embed-strip restyle) and the architecture now lives in **`src/core/ui/CLAUDE.md`** /
|
||||||
tree**, not lore.
|
**`src/shell/panel/CLAUDE.md`** (the design-system implementation). Toolkit facts below are
|
||||||
|
**verified against the vendored `vendor/WDL` tree**, not lore.
|
||||||
|
|
||||||
> **Phase-boundary note (2026-07-26).** This look-and-feel effort was originally drafted
|
> **Phase-boundary note (2026-07-26).** This look-and-feel effort was originally drafted
|
||||||
> as Phase S points **S0-DS** (the shared kit) and **S14** (the panel refresh). It was
|
> as Phase S points **S0-DS** (the shared kit) and **S14** (the panel refresh). It was
|
||||||
@@ -497,7 +498,7 @@ within the pastel intent — is one file.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. The build shape (feeds PLAN.md / CONTEXT.md §Phase L)
|
## 5. The build shape (landed history in `docs/ARCHIVE.md` §Phase L)
|
||||||
|
|
||||||
Three points and their sequencing.
|
Three points and their sequencing.
|
||||||
|
|
||||||
@@ -547,7 +548,7 @@ draw it.
|
|||||||
affordances, per-selection **move / copy / remove** sample menu.
|
affordances, per-selection **move / copy / remove** sample menu.
|
||||||
- **Prune** button (R-E) — the byte-deleting action, `warn`-colored.
|
- **Prune** button (R-E) — the byte-deleting action, `warn`-colored.
|
||||||
|
|
||||||
*M11 adds (dev PLAN.md M11 — merging now):*
|
*M11 adds (landed — `docs/ARCHIVE.md` Milestone 11):*
|
||||||
- **Action trigger buttons** — clickable buttons that fire the capture + provenance
|
- **Action trigger buttons** — clickable buttons that fire the capture + provenance
|
||||||
action family directly (capture item / capture track scopes, re-capture from source,
|
action family directly (capture item / capture track scopes, re-capture from source,
|
||||||
resample-and-mute-source, batch capture, conform-on-insert, insert-at-cursor, drag-out,
|
resample-and-mute-source, batch capture, conform-on-insert, insert-at-cursor, drag-out,
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# The REAPER extension — a loadable module REAPER dlopen()s, never linked against. Paths
|
||||||
|
# below are rooted at src/, not relative to this directory.
|
||||||
|
# No core/ TU is ever compiled into this source list; every core/ TU enters through a link edge
|
||||||
|
# instead. Compiling one here too would give it its own copy, built under this target's own
|
||||||
|
# compile definitions and include dirs — free to diverge from the library copy every other
|
||||||
|
# consumer (the <module>_tests targets, reasampler_vst) links, with nothing to detect it.
|
||||||
|
|
||||||
|
add_library(reaper_reasampler MODULE
|
||||||
|
${REASAMPLER_SRC_DIR}/app/main.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/capture/capture.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/capture/capture_orchestrator.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/capture/capture_batch.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/capture/scope_resolve.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/capture/realtime_lifecycle.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_shell.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_finalize.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/persist/session.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/persist/ext_state_io.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/persist/prune_fs.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/bank_ops/bank_ops.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/panel/panel_audition.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/panel/panel_bank_ops.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/panel/panel_drag.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/panel/panel_input.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/panel/panel_layout.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/panel/panel_render.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/panel/panel_thumbnails.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/panel/panel_window.cpp
|
||||||
|
# draw_kit is compiled into each module rather than being a static library — see root
|
||||||
|
# CMakeLists.txt's LICE_SRC comment for why.
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/panel/draw_kit.cpp
|
||||||
|
${LICE_SRC}
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/view/view.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/capture/track_guid.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/capture/provenance_shell.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/capture/item_read.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/actions/action_registry.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/actions/design_view_actions.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/actions/bank_actions.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/actions/prune_action.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/actions/ingest.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/actions/drag_out_win.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp
|
||||||
|
)
|
||||||
|
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage)
|
||||||
|
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||||
|
|
||||||
|
# OUTPUT_NAME is channel-derived; the CMake target name stays "reaper_reasampler" for both
|
||||||
|
# configs, since REAPER dlopen's any reaper_* module and the two channels' artifacts load
|
||||||
|
# side-by-side. LIBRARY_OUTPUT_DIRECTORY pins the module to the top of the build tree even
|
||||||
|
# though this target is declared in a subdirectory — the install step copies it from there.
|
||||||
|
# ARCHIVE_OUTPUT_DIRECTORY pins the same for MODULE targets: CMake emits an import-lib
|
||||||
|
# sidecar (.lib/.exp on MSVC) keyed off ARCHIVE_OUTPUT_DIRECTORY, not LIBRARY_OUTPUT_DIRECTORY,
|
||||||
|
# so it needs pinning too even though nothing links against this import lib.
|
||||||
|
set_target_properties(reaper_reasampler PROPERTIES
|
||||||
|
PREFIX ""
|
||||||
|
OUTPUT_NAME "${REASAMPLER_OUTPUT_NAME}"
|
||||||
|
LIBRARY_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}"
|
||||||
|
ARCHIVE_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}")
|
||||||
|
|
||||||
|
if(WIN32)
|
||||||
|
# Native Win32; REAPER provides nothing extra to link. The bank-panel dialog template
|
||||||
|
# is compiled from resource.rc by the platform RC compiler.
|
||||||
|
target_sources(reaper_reasampler PRIVATE ${REASAMPLER_SRC_DIR}/resource.rc)
|
||||||
|
|
||||||
|
elseif(APPLE)
|
||||||
|
# Use REAPER's OWN SWELL at runtime via the modstub. Do NOT build full SWELL —
|
||||||
|
# SWELL_PROVIDED_BY_APP routes calls to the host.
|
||||||
|
target_sources(reaper_reasampler PRIVATE ${SWELL}/swell-modstub.mm)
|
||||||
|
target_compile_definitions(reaper_reasampler PRIVATE SWELL_PROVIDED_BY_APP)
|
||||||
|
target_link_libraries(reaper_reasampler PRIVATE "-framework AppKit")
|
||||||
|
set_target_properties(reaper_reasampler PROPERTIES SUFFIX ".dylib")
|
||||||
|
# SWELL can't read a Win32 .rc directly. Run resgen once to turn resource.rc into a
|
||||||
|
# C++ source, then add it here:
|
||||||
|
# php ${WDL_INC}/swell/mac_resgen.php src/resource.rc
|
||||||
|
# target_sources(reaper_reasampler PRIVATE ${REASAMPLER_SRC_DIR}/resource.rc_mac_dlg.h)
|
||||||
|
|
||||||
|
else()
|
||||||
|
# Linux: REAPER's libSwell.so is used at runtime via the generic modstub. With
|
||||||
|
# SWELL_PROVIDED_BY_APP you can drop pkg-config / -lX11 entirely.
|
||||||
|
target_sources(reaper_reasampler PRIVATE ${SWELL}/swell-modstub-generic.cpp)
|
||||||
|
target_compile_definitions(reaper_reasampler PRIVATE SWELL_PROVIDED_BY_APP)
|
||||||
|
set_target_properties(reaper_reasampler PROPERTIES SUFFIX ".so")
|
||||||
|
# Reuse the macOS resgen output (see CLAUDE.md, SWELL dialog resources), then add the
|
||||||
|
# generated source:
|
||||||
|
# php ${WDL_INC}/swell/mac_resgen.php src/resource.rc
|
||||||
|
# target_sources(reaper_reasampler PRIVATE ${REASAMPLER_SRC_DIR}/resource.rc_mac_dlg.h)
|
||||||
|
endif()
|
||||||
+91
-182
@@ -1,25 +1,16 @@
|
|||||||
// main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers.
|
// main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers.
|
||||||
//
|
//
|
||||||
// This file is the entire contract between REAPER and the extension:
|
// REAPER dlopen()s reaper_*.dll|dylib|so from UserPlugins/ and calls the exported
|
||||||
// * At startup REAPER scans UserPlugins/ for reaper_*.dll|dylib|so and
|
// ReaperPluginEntry, handing over `rec` (rec->GetFunc resolves API pointers,
|
||||||
// dlopen()s each one, then looks up ONE exported symbol: ReaperPluginEntry
|
// rec->Register plugs our callbacks in). Exactly ONE .cpp defines
|
||||||
// (that name is produced by the REAPER_PLUGIN_ENTRYPOINT macro).
|
// REAPERAPI_IMPLEMENT (this one) — that allocates storage for the global API
|
||||||
// * REAPER calls it, handing over `rec` — a small dispatch struct.
|
// pointers every other TU gets `extern`. Never let a second TU define it.
|
||||||
// - rec->GetFunc(name) resolves any REAPER API function to a pointer
|
|
||||||
// - rec->Register(what,ptr) plugs OUR callbacks into REAPER
|
|
||||||
// * REAPERAPI_LoadAPI(rec->GetFunc) walks reaper_plugin_functions.h and
|
|
||||||
// fills in every global function pointer (ShowConsoleMsg, InsertMedia...).
|
|
||||||
//
|
//
|
||||||
// Exactly ONE .cpp defines REAPERAPI_IMPLEMENT (this one) — that allocates
|
// This TU is ONLY pointers + entry + dispatch. Its own action family registers
|
||||||
// storage for those global pointers. Every other .cpp includes
|
// through the data-driven table below (buildMainActionTable + action_registry) —
|
||||||
// reaper_plugin_functions.h WITHOUT the define and gets `extern` declarations.
|
// adding a bindable action means adding ONE row and its handler function (OCP). The
|
||||||
//
|
// design_view / bank / ingest families keep their own register/handle/unregister
|
||||||
// Since Q-W3 this TU is ONLY pointers + entry + dispatch; since Q-W6 its own
|
// triples, called from entry.
|
||||||
// action family registers through the DATA-DRIVEN TABLE below (kMainActionRows +
|
|
||||||
// action_registry's registerActionTable/actionTableHandleCommand/
|
|
||||||
// unregisterActionTable) — adding a bindable action here means adding ONE row and
|
|
||||||
// its handler function, nothing else (OCP). The design_view / bank / ingest
|
|
||||||
// families keep their own register/handle/unregister triples, called from entry.
|
|
||||||
|
|
||||||
#define REAPERAPI_IMPLEMENT
|
#define REAPERAPI_IMPLEMENT
|
||||||
#include "reaper_plugin.h"
|
#include "reaper_plugin.h"
|
||||||
@@ -31,10 +22,10 @@
|
|||||||
|
|
||||||
#include "core/capture/render_settings.h" // captureActionTable
|
#include "core/capture/render_settings.h" // captureActionTable
|
||||||
#include "core/version/app_version.h" // appVersion
|
#include "core/version/app_version.h" // appVersion
|
||||||
#include "ingest.h"
|
#include "shell/actions/ingest.h"
|
||||||
#include "shell/actions/action_registry.h" // the Q-W6 registration table
|
#include "shell/actions/action_registry.h" // the registration table
|
||||||
#include "shell/actions/bank_actions.h" // multi-bank action family (B3; Q-W4 home)
|
#include "shell/actions/bank_actions.h" // multi-bank action family
|
||||||
#include "shell/actions/design_view_actions.h" // Design View action family (D4; Q-W4 home)
|
#include "shell/actions/design_view_actions.h" // Design View action family
|
||||||
#include "shell/capture/capture_batch.h" // batch + recapture action bodies
|
#include "shell/capture/capture_batch.h" // batch + recapture action bodies
|
||||||
#include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies
|
#include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies
|
||||||
#include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver
|
#include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver
|
||||||
@@ -46,20 +37,13 @@
|
|||||||
namespace capture = reasampler::capture;
|
namespace capture = reasampler::capture;
|
||||||
|
|
||||||
// Globals other files reference via `extern`.
|
// Globals other files reference via `extern`.
|
||||||
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle
|
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr;
|
||||||
reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct
|
reaper_plugin_info_t* g_rec = nullptr;
|
||||||
|
|
||||||
// Retired command-id SUFFIXES. Kept ONLY to mirror-unregister them on unload so a
|
// Retired command-id SUFFIXES: kept ONLY to mirror-unregister on unload so a user's
|
||||||
// user's stale keybindings are cleaned up. Never re-register these. Composed through
|
// stale keybindings are cleaned up. Never re-register these. The four-mode WET ids,
|
||||||
// the channel prefix at unload (channelIdFor) so a beta unload clears beta-qualified
|
// the removed master scope/realtime actions, and the removed per-action tail variants
|
||||||
// retired ids and a stable unload clears stable's — each channel cleans up only its
|
// (tail is now a panel toggle, not a paired action).
|
||||||
// own family.
|
|
||||||
// * The M7 four-mode ids (tracks/items/razor WET).
|
|
||||||
// * CAPTURE_MASTER and CAPTURE_MASTER_REALTIME — the master offline scope and the
|
|
||||||
// master realtime action are REMOVED (capture is now item + track only; realtime
|
|
||||||
// taps the selected track). Their shipped ids are retired so old keybindings clear.
|
|
||||||
// * CAPTURE_ITEM_TAIL and CAPTURE_TRACK_TAIL — the former per-action tail variants
|
|
||||||
// are REMOVED; tail is now a panel-setting toggle, not a paired action.
|
|
||||||
static const char* const kRetiredCaptureCmdSuffixes[] = {
|
static const char* const kRetiredCaptureCmdSuffixes[] = {
|
||||||
"CAPTURE_TRACKS_WET",
|
"CAPTURE_TRACKS_WET",
|
||||||
"CAPTURE_ITEMS_WET",
|
"CAPTURE_ITEMS_WET",
|
||||||
@@ -70,35 +54,30 @@ static const char* const kRetiredCaptureCmdSuffixes[] = {
|
|||||||
"CAPTURE_TRACK_TAIL",
|
"CAPTURE_TRACK_TAIL",
|
||||||
};
|
};
|
||||||
|
|
||||||
// The persistence session (M4): owns the in-memory BankModel and bridges it to
|
// Owns the in-memory BankModel and bridges it to project ext state. A timer tick
|
||||||
// project ext state. A timer tick drives g_session.poll() to detect project
|
// drives g_session.poll() to detect project load / Save-As; capture adds Samples to
|
||||||
// load / Save-As; capture adds Samples to g_session.bank() — which (B2) resolves to
|
// g_session.bank() (resolves to the active bank's index), and we serialize the book
|
||||||
// the ACTIVE bank's index inside the session's BankBook; after a capture we serialize
|
// back into the active project's ext state (the `banks` key) so it travels with the .rpp.
|
||||||
// the book back into the active project's ext state (the `banks` key) so it travels
|
|
||||||
// with the .rpp. Replaces the M3 session-only g_bank.
|
|
||||||
static reasampler::ReaSamplerSession g_session;
|
static reasampler::ReaSamplerSession g_session;
|
||||||
|
|
||||||
// Command id of the TOGGLE_BANK_PANEL row, resolved from the table once at load so
|
// Command id of the TOGGLE_BANK_PANEL row, resolved from the table once at load so
|
||||||
// OnToggleAction's checked-state poll is a single int compare (no per-poll lookup).
|
// OnToggleAction's checked-state poll is a single int compare (no per-poll lookup).
|
||||||
static int g_cmdToggleBankPanel = 0;
|
static int g_cmdToggleBankPanel = 0;
|
||||||
|
|
||||||
// --- Action handlers (the table's function pointers) --------------------------
|
// Each handler is a thin stateless routing shim: (session, per-row arg) -> the
|
||||||
//
|
// action body in shell/capture/ or shell/panel/, existing only so table rows can be
|
||||||
// Each is a thin stateless routing shim: (session, per-row arg) -> the action body
|
// plain data with flat function pointers.
|
||||||
// hoisted in Q-W3/Q-W4 (shell/capture/, shell/panel/). The bodies own all behavior;
|
|
||||||
// these exist only so the table rows can be plain data with flat function pointers.
|
|
||||||
|
|
||||||
// Capture scope family: `arg` is the captureActionTable() row index — the table rows
|
// `arg` is the captureActionTable() row index — the table rows below are built by
|
||||||
// below are built by iterating that pure taxonomy, so the routing stays 1:1 by
|
// iterating that pure taxonomy, so the routing stays 1:1 by construction.
|
||||||
// construction (never a hand-kept parallel list).
|
|
||||||
static void RunCaptureScopeRow(int arg) {
|
static void RunCaptureScopeRow(int arg) {
|
||||||
capture::RunCapture(g_session,
|
capture::RunCapture(g_session,
|
||||||
capture::captureActionTable()[static_cast<std::size_t>(arg)]);
|
capture::captureActionTable()[static_cast<std::size_t>(arg)]);
|
||||||
}
|
}
|
||||||
static void RunToggleBankPanel(int) { reasampler::bankPanelToggle(); }
|
static void RunToggleBankPanel(int) { reasampler::bankPanelToggle(); }
|
||||||
static void RunCaptureItemAssign(int) { capture::RunCaptureItemAssign(g_session); }
|
static void RunCaptureItemAssign(int) { capture::RunCaptureItemAssign(g_session); }
|
||||||
// Insert: `arg` != 0 is the EXPLICIT conform-to-project-tempo opt-in (CONTEXT.md
|
// `arg` != 0 is the EXPLICIT conform-to-project-tempo opt-in (never silent); 0
|
||||||
// §insert: conform is opt-in, never silent); 0 inserts at native length.
|
// inserts at native length.
|
||||||
static void RunInsertSelected(int arg) {
|
static void RunInsertSelected(int arg) {
|
||||||
capture::RunInsertSelected(g_session, arg != 0);
|
capture::RunInsertSelected(g_session, arg != 0);
|
||||||
}
|
}
|
||||||
@@ -108,24 +87,15 @@ static void RunCaptureRealtime(int) { capture::RunCaptureRealtimeTrack(g_sessi
|
|||||||
static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); }
|
static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); }
|
||||||
static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); }
|
static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); }
|
||||||
static void RunShowVersion(int) {
|
static void RunShowVersion(int) {
|
||||||
// On-demand version readout — the ONLY version output on any path (Phase V: no
|
// On-demand only — no unconditional startup print (routine console chatter pops
|
||||||
// unconditional startup print; routine console chatter pops the console window).
|
// the console window).
|
||||||
ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str());
|
ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- The registration table (Q-W6) --------------------------------------------
|
// ONE row per bindable action this TU owns: FOREVER-STABLE id suffix, Actions-list
|
||||||
//
|
// phrase, handler, per-row arg. Registration, hookcommand dispatch, and the unload
|
||||||
// ONE row per bindable action this TU owns: FOREVER-STABLE id suffix (channel prefix
|
// mirror-unregister all iterate this data. The capture scope rows come first,
|
||||||
// composed at register — stable rebuilds the exact shipped id, e.g.
|
// sourced from the pure captureActionTable() taxonomy; the rest are this TU's singles.
|
||||||
// "CEREBELLUM_REASAMPLER_CAPTURE_TRACK"; beta its isolated forever-family), the
|
|
||||||
// Actions-list phrase (after the "ReaSampler[ beta]: " lead), the handler, and its
|
|
||||||
// per-row arg. Registration, hookcommand dispatch, and the unload mirror-unregister
|
|
||||||
// all iterate this data — adding an action = adding a row + a handler above.
|
|
||||||
//
|
|
||||||
// The capture scope rows (CAPTURE_ITEM / CAPTURE_TRACK) come first, sourced from the
|
|
||||||
// pure captureActionTable() taxonomy (render_settings) — suffix/phrase live in that
|
|
||||||
// one testable list, and `arg` carries the row index back to RunCapture. The
|
|
||||||
// remaining rows are this TU's singles, in the pre-table registration order.
|
|
||||||
static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
|
static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
|
||||||
using reasampler::ActionTableRow;
|
using reasampler::ActionTableRow;
|
||||||
std::vector<ActionTableRow> rows;
|
std::vector<ActionTableRow> rows;
|
||||||
@@ -135,40 +105,32 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
|
|||||||
rows.push_back(ActionTableRow{cap[i].commandSuffix, cap[i].descriptionPhrase,
|
rows.push_back(ActionTableRow{cap[i].commandSuffix, cap[i].descriptionPhrase,
|
||||||
&RunCaptureScopeRow, static_cast<int>(i)});
|
&RunCaptureScopeRow, static_cast<int>(i)});
|
||||||
|
|
||||||
// M5: show/hide the docked bank panel (display-only; never captures/inserts).
|
// Show/hide the docked bank panel (display-only; never captures/inserts).
|
||||||
rows.push_back({"TOGGLE_BANK_PANEL", "toggle bank panel", &RunToggleBankPanel});
|
rows.push_back({"TOGGLE_BANK_PANEL", "toggle bank panel", &RunToggleBankPanel});
|
||||||
// S8: Item-scope capture + assignment-request write (capture family because it
|
|
||||||
// leans on the capture render machinery; the other ingest surfaces live in the
|
|
||||||
// ingest family and the panel drop callback).
|
|
||||||
rows.push_back({"CAPTURE_ITEM_ASSIGN",
|
rows.push_back({"CAPTURE_ITEM_ASSIGN",
|
||||||
"capture selected item into bank + assign to active instance",
|
"capture selected item into bank + assign to active instance",
|
||||||
&RunCaptureItemAssign});
|
&RunCaptureItemAssign});
|
||||||
// M6: place the panel's selected sample at the edit cursor. Two variants that
|
// Two variants differing ONLY in InsertOptions — native length vs conform opt-in.
|
||||||
// differ ONLY in InsertOptions — native length vs the explicit conform opt-in.
|
|
||||||
rows.push_back({"INSERT_SELECTED", "insert selected sample at edit cursor",
|
rows.push_back({"INSERT_SELECTED", "insert selected sample at edit cursor",
|
||||||
&RunInsertSelected, 0});
|
&RunInsertSelected, 0});
|
||||||
rows.push_back({"INSERT_SELECTED_CONFORM",
|
rows.push_back({"INSERT_SELECTED_CONFORM",
|
||||||
"insert selected sample at edit cursor (conform to tempo)",
|
"insert selected sample at edit cursor (conform to tempo)",
|
||||||
&RunInsertSelected, 1});
|
&RunInsertSelected, 1});
|
||||||
// M11: one action fires N captures (per selected item / per razor area); the
|
// One action fires N captures (per selected item / per razor area); the original
|
||||||
// original selection is restored on every exit path. Bank-only, never places.
|
// selection is restored on every exit path. Bank-only, never places.
|
||||||
rows.push_back({"CAPTURE_BATCH_ITEMS",
|
rows.push_back({"CAPTURE_BATCH_ITEMS",
|
||||||
"batch capture selected items (one per item)",
|
"batch capture selected items (one per item)",
|
||||||
&RunBatchCaptureItems});
|
&RunBatchCaptureItems});
|
||||||
rows.push_back({"CAPTURE_BATCH_RAZOR", "batch capture razor areas (one per area)",
|
rows.push_back({"CAPTURE_BATCH_RAZOR", "batch capture razor areas (one per area)",
|
||||||
&RunBatchCaptureRazor});
|
&RunBatchCaptureRazor});
|
||||||
// M8: realtime sibling of the offline CAPTURE_TRACK scope — records the selected
|
// Realtime sibling of the offline CAPTURE_TRACK scope, plus its cancel-in-flight
|
||||||
// track's own output into a hidden temp track, dialog-free — plus its
|
// companion (stop + restore, non-destructive).
|
||||||
// cancel-in-flight companion (stop + restore, non-destructive).
|
|
||||||
rows.push_back({"CAPTURE_TRACK_REALTIME", "capture selected track (realtime)",
|
rows.push_back({"CAPTURE_TRACK_REALTIME", "capture selected track (realtime)",
|
||||||
&RunCaptureRealtime});
|
&RunCaptureRealtime});
|
||||||
rows.push_back({"CANCEL_REALTIME_CAPTURE", "cancel realtime capture",
|
rows.push_back({"CANCEL_REALTIME_CAPTURE", "cancel realtime capture",
|
||||||
&RunCancelRealtime});
|
&RunCancelRealtime});
|
||||||
// M10: regenerate the selected PROVENANCED sample from its recorded source's
|
|
||||||
// current state, in place. Bank-only, never places on the timeline.
|
|
||||||
rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source",
|
rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source",
|
||||||
&RunRecaptureFromSource});
|
&RunRecaptureFromSource});
|
||||||
// Phase V: on-demand version readout for bug reports.
|
|
||||||
rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion});
|
rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion});
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
@@ -180,74 +142,52 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
|
|||||||
static void OnTimer()
|
static void OnTimer()
|
||||||
{
|
{
|
||||||
// Advance any in-flight realtime capture FIRST, so a project switch is caught and
|
// Advance any in-flight realtime capture FIRST, so a project switch is caught and
|
||||||
// the capture torn down/restored before session.poll() reacts to that switch.
|
// torn down/restored before session.poll() reacts to that switch. LOAD-BEARING:
|
||||||
// LOAD-BEARING (CONTEXT.md §Phase Q): the idle fast-path is a SINGLE POINTER
|
// the idle fast-path is a SINGLE POINTER TEST — drive only when a capture is live.
|
||||||
// TEST — the cross-TU drive call is made only when a capture is in flight.
|
|
||||||
if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session);
|
if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session);
|
||||||
|
|
||||||
g_session.poll();
|
g_session.poll();
|
||||||
|
|
||||||
// D4 reapply-on-open glue. persist stays MODEL-ONLY (it loads the saved view
|
// persist stays MODEL-ONLY (loads the saved view model but does not apply
|
||||||
// model but deliberately does NOT apply visibility — that would couple persist
|
// visibility, to avoid coupling persist to the view shell); poll() raises a
|
||||||
// to the view shell). Instead poll() raises a one-shot load signal; here — the
|
// one-shot load signal that we drain here to reapply the SAVED active mode so a
|
||||||
// integration layer that already drives both persist and the view shell — we
|
// project saved in Design mode parks Arrange tracks automatically. The same
|
||||||
// drain it and reapply the SAVED active mode's visibility/processing so opening a
|
// signal re-arms the bank panel's new-content detector — notified BEFORE the
|
||||||
// project saved in Design mode parks the Arrange tracks automatically, no manual
|
// reapply so re-arm and model restore ride the one load event (otherwise
|
||||||
// toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks
|
// pre-existing tracks can be mis-detected as "new" and mass-tagged).
|
||||||
// skip it. proj = nullptr -> REAPER's active project (the one poll just loaded).
|
|
||||||
//
|
|
||||||
// The SAME signal re-arms the bank panel's new-content detector: a load must
|
|
||||||
// re-baseline the detector against the just-loaded project's content so its
|
|
||||||
// pre-existing tracks are never mis-detected as "new" and mass-tagged into the
|
|
||||||
// active mode (the reload-mis-tag bug). Notify BEFORE the reapply so the detector's
|
|
||||||
// re-arm and the model restore ride the one authoritative load event.
|
|
||||||
if (g_session.consumeLoadSignal()) {
|
if (g_session.consumeLoadSignal()) {
|
||||||
reasampler::bankPanelNotifyProjectLoaded();
|
reasampler::bankPanelNotifyProjectLoaded();
|
||||||
// Reconcile the restored lane-ownership index against the live project's lanes
|
// Reconcile lane ownership against the live project's lanes (P_LANENAME,
|
||||||
// FIRST (via REAPER's durable P_LANENAME — the cross-session source of truth),
|
// the cross-session source of truth) BEFORE reapplying visibility. Never
|
||||||
// so a saved lane-split project's managed/manual classification is correct
|
// re-mints, never mass-tags.
|
||||||
// before the active mode's lane visibility is reapplied. Never re-mints, never
|
|
||||||
// mass-tags — it only records managed ownership recovered from lane names.
|
|
||||||
reasampler::reconcileManagedLanes(g_session.view(), nullptr);
|
reasampler::reconcileManagedLanes(g_session.view(), nullptr);
|
||||||
reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
|
reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reflect a live bank change (capture / project load) in the docked grid.
|
reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed
|
||||||
// Cheap when the bank is unchanged (a fingerprint compare); repaints only on
|
|
||||||
// an actual change. No-op when the panel is closed.
|
|
||||||
reasampler::bankPanelRefresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- projectconfig hook: reload the session on undo/redo (R-B) ---------------
|
// A Ctrl-Z/Ctrl-Shift-Z rolls back/forward the "reasampler" project ext state on disk
|
||||||
// A Ctrl-Z / Ctrl-Shift-Z rolls back / forward the "reasampler" project ext state on
|
// but keeps the SAME project identity, so the timer's identity poll never re-reads
|
||||||
// disk but keeps the SAME project identity (ReaProject*/GUID/.rpp path), so the timer's
|
// ext state on undo/redo — the in-memory book/view would stay stale until
|
||||||
// identity poll reads it as NoOp and never re-reads ext state — the in-memory book/view
|
// close+reopen. REAPER's projectconfig fires BeginLoadProjectState on every
|
||||||
// would stay stale until close+reopen. REAPER's projectconfig extension fires
|
// project-state (re)load INCLUDING undo/redo (isUndo == true for both); we hook it.
|
||||||
// BeginLoadProjectState on every project-state (re)load, INCLUDING an undo/redo restore
|
|
||||||
// (isUndo == true for both). We hook it to drive a session reload.
|
|
||||||
//
|
//
|
||||||
// TIMING (the crux): BeginLoadProjectState is documented (reaper_plugin.h ~1203) as
|
// TIMING: BeginLoadProjectState fires BEFORE any state restore, so reading
|
||||||
// firing BEFORE any state restore. Reading GetProjExtState synchronously here would
|
// GetProjExtState here would return the PRE-undo value. Instead we raise a one-shot
|
||||||
// return the PRE-undo value. So we do NOT read here — we raise a one-shot reload request
|
// reload request that OnTimer's poll() drains on the NEXT tick, once REAPER has
|
||||||
// (g_session.requestReload()) that OnTimer's poll() drains on the NEXT tick, by which
|
// finished restoring the <EXTSTATE> block. A normal project open also fires this
|
||||||
// point REAPER has finished restoring the <EXTSTATE> block and GetProjExtState returns
|
// (isUndo=false); ignored here so a normal open flows solely through the timer's
|
||||||
// the POST-undo value. Deterministic, event-driven — NOT ext-state content polling.
|
// identity-transition Load path (no double load).
|
||||||
//
|
|
||||||
// GATED ON isUndo: a normal project open also fires BeginLoadProjectState (isUndo=false);
|
|
||||||
// we ignore that here so a normal open flows solely through the timer's identity-transition
|
|
||||||
// Load path (no double load). Only undo/redo (isUndo=true) requests the reload.
|
|
||||||
static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/)
|
static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/)
|
||||||
{
|
{
|
||||||
if (isUndo)
|
if (isUndo)
|
||||||
g_session.requestReload();
|
g_session.requestReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessExtensionLine / SaveExtensionConfig are intentional no-ops: ReaSampler stores
|
// Intentional no-ops: ReaSampler stores state via project EXT STATE, not this
|
||||||
// its state via project EXT STATE (SetProjExtState/GetProjExtState under "reasampler"),
|
// extension's own project lines. The struct is registered ONLY for the
|
||||||
// which REAPER persists in its own <EXTSTATE> RPP block — NOT via this extension's own
|
// BeginLoadProjectState undo/redo notification.
|
||||||
// project lines. We register the struct ONLY for the BeginLoadProjectState undo/redo
|
|
||||||
// notification. Returning false from ProcessExtensionLine means "not our line" so REAPER
|
|
||||||
// keeps dispatching (we claim none). SaveExtensionConfig writes nothing.
|
|
||||||
static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/,
|
static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/,
|
||||||
bool /*isUndo*/, project_config_extension_t* /*reg*/)
|
bool /*isUndo*/, project_config_extension_t* /*reg*/)
|
||||||
{
|
{
|
||||||
@@ -257,7 +197,6 @@ static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*
|
|||||||
static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/,
|
static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/,
|
||||||
project_config_extension_t* /*reg*/)
|
project_config_extension_t* /*reg*/)
|
||||||
{
|
{
|
||||||
// Nothing to write: our data rides in ext state, not project lines.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Storage must outlive registration — REAPER holds this pointer until we unregister it.
|
// Storage must outlive registration — REAPER holds this pointer until we unregister it.
|
||||||
@@ -268,19 +207,15 @@ static project_config_extension_t g_projectConfig{
|
|||||||
nullptr, // userData
|
nullptr, // userData
|
||||||
};
|
};
|
||||||
|
|
||||||
// REAPER calls this for EVERY action fired anywhere; claim only our own id,
|
// REAPER calls this for EVERY action fired anywhere; claim only our own id, return
|
||||||
// return false otherwise so REAPER keeps looking. This TU's own family dispatches
|
// false otherwise so REAPER keeps looking. This TU's own family dispatches through
|
||||||
// through the registration table; the Q-W4 families claim their own ids after it.
|
// the registration table; the other families claim their own ids after it.
|
||||||
static bool OnHookCommand(int command, int /*flag*/)
|
static bool OnHookCommand(int command, int /*flag*/)
|
||||||
{
|
{
|
||||||
if (command == 0) return false;
|
if (command == 0) return false;
|
||||||
if (reasampler::actionTableHandleCommand(command)) return true;
|
if (reasampler::actionTableHandleCommand(command)) return true;
|
||||||
// Design View action family (D4). Claims only its own ids; returns false for the
|
|
||||||
// rest so this hook keeps looking (per the contract).
|
|
||||||
if (reasampler::designViewHandleCommand(command)) return true;
|
if (reasampler::designViewHandleCommand(command)) return true;
|
||||||
// Multi-bank action family (B3). Same contract: claims only its own ids.
|
|
||||||
if (reasampler::bankHandleCommand(command)) return true;
|
if (reasampler::bankHandleCommand(command)) return true;
|
||||||
// S8 ingest action family (Media-Explorer import). Same contract.
|
|
||||||
if (reasampler::ingestHandleCommand(command)) return true;
|
if (reasampler::ingestHandleCommand(command)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -299,39 +234,30 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
|||||||
{
|
{
|
||||||
if (!rec)
|
if (!rec)
|
||||||
{
|
{
|
||||||
// rec == nullptr => REAPER is UNLOADING us. Mirror-unregister every
|
// rec == nullptr => REAPER is UNLOADING us.
|
||||||
// callback with the same strings prefixed '-' (per the contract).
|
|
||||||
if (g_rec)
|
if (g_rec)
|
||||||
{
|
{
|
||||||
// Abort any in-flight realtime capture FIRST, while the API pointers are
|
// Abort any in-flight realtime capture FIRST, while the API pointers are
|
||||||
// still live — finalize-or-abort + restore so we never leave a temp track,
|
// still live, so we never leave a temp track, an armed track, or an
|
||||||
// an armed track, or an altered transport/cursor in the user's project on
|
// altered transport/cursor in the user's project on unload.
|
||||||
// unload. Commit whatever was captured (best effort) before tearing down.
|
|
||||||
capture::AbortRealtimeCaptureForUnload(g_session);
|
capture::AbortRealtimeCaptureForUnload(g_session);
|
||||||
|
|
||||||
g_rec->Register("-timer", (void*)&OnTimer);
|
g_rec->Register("-timer", (void*)&OnTimer);
|
||||||
g_rec->Register("-projectconfig", (void*)&g_projectConfig);
|
g_rec->Register("-projectconfig", (void*)&g_projectConfig);
|
||||||
g_rec->Register("-toggleaction", (void*)&OnToggleAction);
|
g_rec->Register("-toggleaction", (void*)&OnToggleAction);
|
||||||
g_rec->Register("-hookcommand", (void*)&OnHookCommand);
|
g_rec->Register("-hookcommand", (void*)&OnHookCommand);
|
||||||
// Tear down the Design View action family (D4) — mirror-unregisters each
|
|
||||||
// gaccel + command_id with '-'-prefixed strings. After the hook is gone.
|
|
||||||
reasampler::designViewUnregisterActions(g_rec);
|
reasampler::designViewUnregisterActions(g_rec);
|
||||||
// Tear down the multi-bank action family (B3) — same mirror-unregister.
|
|
||||||
reasampler::bankUnregisterActions(g_rec);
|
reasampler::bankUnregisterActions(g_rec);
|
||||||
// Tear down the S8 ingest action family — same mirror-unregister.
|
|
||||||
reasampler::ingestUnregisterActions(g_rec);
|
reasampler::ingestUnregisterActions(g_rec);
|
||||||
// Tear down this TU's own family from the registration table (reverse
|
// This TU's own family, reverse table order; each '-command_id'
|
||||||
// table order; each '-command_id' re-presents the SAME interned,
|
// re-presents the SAME interned pointer used at register.
|
||||||
// channel-qualified pointer used at register).
|
|
||||||
reasampler::unregisterActionTable(g_rec);
|
reasampler::unregisterActionTable(g_rec);
|
||||||
// Retire the REMOVED command ids (command_id only — we never held a gaccel
|
// Retire the REMOVED command ids (command_id only — we never held a gaccel
|
||||||
// for them this session). Clears stale user keybindings on unload. Composed
|
// for them this session).
|
||||||
// per channel so a beta clears beta-qualified retired ids, stable its own.
|
|
||||||
for (const char* suffix : kRetiredCaptureCmdSuffixes)
|
for (const char* suffix : kRetiredCaptureCmdSuffixes)
|
||||||
g_rec->Register("-command_id", (void*)reasampler::channelIdFor(suffix));
|
g_rec->Register("-command_id", (void*)reasampler::channelIdFor(suffix));
|
||||||
}
|
}
|
||||||
// Destroy the docked window and release cached thumbnails before we drop
|
// Before dropping the API pointers: DockWindowRemove/DestroyWindow need them live.
|
||||||
// the API pointers (DockWindowRemove/DestroyWindow need them live).
|
|
||||||
reasampler::bankPanelShutdown();
|
reasampler::bankPanelShutdown();
|
||||||
g_rec = nullptr;
|
g_rec = nullptr;
|
||||||
return 0;
|
return 0;
|
||||||
@@ -349,13 +275,10 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
|||||||
g_hInst = hInstance;
|
g_hInst = hInstance;
|
||||||
g_rec = rec;
|
g_rec = rec;
|
||||||
|
|
||||||
// Point the bank panel at the live session BEFORE registering its action, so
|
// Point the bank panel at the live session BEFORE registering its action, so a
|
||||||
// a toggle firing immediately has a session to read (M5). Does not open the
|
// toggle firing immediately has a session to read. Does not open the window.
|
||||||
// window — only stores the session pointer.
|
|
||||||
reasampler::bankPanelInit(&g_session);
|
reasampler::bankPanelInit(&g_session);
|
||||||
|
|
||||||
// Register this TU's whole action family from the table: command_id -> gaccel
|
|
||||||
// per row, all channel-qualified, all FOREVER-STABLE per channel.
|
|
||||||
{
|
{
|
||||||
const std::vector<reasampler::ActionTableRow> rows = buildMainActionTable();
|
const std::vector<reasampler::ActionTableRow> rows = buildMainActionTable();
|
||||||
reasampler::registerActionTable(rec, rows.data(), rows.size());
|
reasampler::registerActionTable(rec, rows.data(), rows.size());
|
||||||
@@ -367,37 +290,23 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
|||||||
if (g_cmdToggleBankPanel)
|
if (g_cmdToggleBankPanel)
|
||||||
rec->Register("toggleaction", (void*)&OnToggleAction);
|
rec->Register("toggleaction", (void*)&OnToggleAction);
|
||||||
|
|
||||||
// Register the Design View action family (D4): toggle/activate mode, tag/untag/
|
// Each family mints its own command_id + gaccel, shares g_session, and is routed
|
||||||
// show-both selected tracks. Each mints its own command_id + gaccel; the single
|
// by the same hookcommand below. Registered before the hook so every id is
|
||||||
// hookcommand below routes them via designViewHandleCommand. Registered before
|
// minted first.
|
||||||
// the hook so every id is minted first.
|
|
||||||
reasampler::designViewRegisterActions(rec, &g_session);
|
reasampler::designViewRegisterActions(rec, &g_session);
|
||||||
|
|
||||||
// Register the multi-bank action family (B3): create/rename/delete/evacuate bank,
|
|
||||||
// activate (cycle + pool), move/copy selected samples to a bank, and the two
|
|
||||||
// full-height layout toggles. Shares g_session with the Design View family; routed
|
|
||||||
// by the same hookcommand via bankHandleCommand. Registered before the hook.
|
|
||||||
reasampler::bankRegisterActions(rec, &g_session);
|
reasampler::bankRegisterActions(rec, &g_session);
|
||||||
|
|
||||||
// Register the S8 ingest action family: the Media-Explorer import-into-bank+assign
|
|
||||||
// action. Shares g_session with the other families; routed by the same hookcommand via
|
|
||||||
// ingestHandleCommand. (The arrange capture+assign action is a table row above; the
|
|
||||||
// drop path is a bank_panel callback, not a bindable action.)
|
|
||||||
reasampler::ingestRegisterActions(rec, &g_session);
|
reasampler::ingestRegisterActions(rec, &g_session);
|
||||||
|
|
||||||
// One hookcommand routes every ReaSampler action (table + the three families).
|
|
||||||
// Registered once, after all command ids are minted.
|
|
||||||
rec->Register("hookcommand", (void*)&OnHookCommand);
|
rec->Register("hookcommand", (void*)&OnHookCommand);
|
||||||
|
|
||||||
// Drive project-load / Save-As detection (M4 persist). The timer polls the
|
// Drives project-load / Save-As detection: the timer polls the active project
|
||||||
// active project each tick; on a project load it reloads the bank from ext
|
// each tick; on a project load it reloads the bank from ext state, on a Save-As
|
||||||
// state, on a Save-As it relocates the bank folder under the new .rpp.
|
// it relocates the bank folder under the new .rpp.
|
||||||
rec->Register("timer", (void*)&OnTimer);
|
rec->Register("timer", (void*)&OnTimer);
|
||||||
|
|
||||||
// Register the projectconfig hook so an UNDO/REDO state restore reloads the
|
// An UNDO/REDO state restore reloads the session's book + view from the restored
|
||||||
// session's book + view from the restored ext state (R-B). The timer's identity
|
// ext state. The timer's identity poll cannot see an undo (same project
|
||||||
// poll cannot see an undo (same project identity), so this hook owns undo/redo; it
|
// identity), so this hook owns it (see OnBeginLoadProjectState).
|
||||||
// requests a deferred reload that the next timer tick drains (see the hook comment).
|
|
||||||
rec->Register("projectconfig", (void*)&g_projectConfig);
|
rec->Register("projectconfig", (void*)&g_projectConfig);
|
||||||
|
|
||||||
return 1; // success — REAPER keeps us loaded
|
return 1; // success — REAPER keeps us loaded
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# The pure substrate — see root CLAUDE.md's architecture section for the core/ purity invariant.
|
||||||
|
#
|
||||||
|
# Declaration order below runs base-first so the file reads as a dependency ladder; CMake
|
||||||
|
# itself does not require it (link names resolve at generate time), and a few edges do run
|
||||||
|
# backwards — core/wire's instrument_drop reuses the instrument's own state codec.
|
||||||
|
|
||||||
|
add_subdirectory(json)
|
||||||
|
add_subdirectory(util)
|
||||||
|
add_subdirectory(wire)
|
||||||
|
add_subdirectory(audio)
|
||||||
|
add_subdirectory(model)
|
||||||
|
add_subdirectory(capture)
|
||||||
|
add_subdirectory(reclaim)
|
||||||
|
add_subdirectory(version)
|
||||||
|
add_subdirectory(view)
|
||||||
|
add_subdirectory(ui)
|
||||||
|
add_subdirectory(instrument)
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
reasampler_pure_library(peaks SOURCES peaks.cpp)
|
||||||
|
reasampler_test(peaks LINK peaks)
|
||||||
+13
-28
@@ -5,14 +5,11 @@
|
|||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
||||||
// peaks implementation.
|
// peaks — pure implementation. See peaks.h.
|
||||||
//
|
//
|
||||||
// One linear pass per channel. The frame->bin partition is computed with integer
|
// One linear pass per channel. Frame->bin partition uses integer arithmetic so it's exact for
|
||||||
// arithmetic so it is exact for any frameCount / binCount pairing: bin b owns the
|
// any frameCount/binCount pairing: bin b owns [b*frameCount/binCount, (b+1)*frameCount/binCount)
|
||||||
// half-open frame span [b*frameCount/binCount, (b+1)*frameCount/binCount). That
|
// — earlier bins absorb the remainder, no rounding drift, no dropped tail.
|
||||||
// span formula distributes the remainder deterministically (earlier bins get the
|
|
||||||
// extra frames) with no rounding drift and no dropped tail — the last bin's end is
|
|
||||||
// always exactly frameCount.
|
|
||||||
|
|
||||||
namespace reasampler::audio {
|
namespace reasampler::audio {
|
||||||
|
|
||||||
@@ -22,11 +19,10 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
|||||||
std::size_t binCount) {
|
std::size_t binCount) {
|
||||||
Envelope envelope(channelCount);
|
Envelope envelope(channelCount);
|
||||||
if (channelCount == 0) {
|
if (channelCount == 0) {
|
||||||
return envelope; // no channels -> no envelopes
|
return envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never read past what the buffer actually holds, even if the caller's
|
// Never read past what the buffer actually holds, even if frameCount overstates it.
|
||||||
// frameCount overstates the buffer (defensive: no OOB on a short buffer).
|
|
||||||
const std::size_t availableFrames = interleaved.size() / channelCount;
|
const std::size_t availableFrames = interleaved.size() / channelCount;
|
||||||
const std::size_t frames = std::min(frameCount, availableFrames);
|
const std::size_t frames = std::min(frameCount, availableFrames);
|
||||||
|
|
||||||
@@ -35,14 +31,10 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
|||||||
bins.assign(binCount, MinMax{}); // empty/degenerate bins default to {0,0}
|
bins.assign(binCount, MinMax{}); // empty/degenerate bins default to {0,0}
|
||||||
|
|
||||||
for (std::size_t b = 0; b < binCount; ++b) {
|
for (std::size_t b = 0; b < binCount; ++b) {
|
||||||
// Half-open frame span for this bin: [b*frames/binCount, (b+1)*frames/binCount).
|
// Guard b*frames / (b+1)*frames overflow: binCount is caller-controlled and
|
||||||
// Guard against size_t overflow in b*frames and (b+1)*frames: binCount is
|
// unbounded. Unreachable in practice (would OOM first) but guarded to avoid UB.
|
||||||
// caller-controlled and unbounded, so when b >= SIZE_MAX/frames either
|
|
||||||
// multiplication could wrap. Any such bin is unreachable in practice
|
|
||||||
// (allocating that many MinMax entries would OOM first), but we guard
|
|
||||||
// explicitly to eliminate UB.
|
|
||||||
if (frames > 0 && b >= SIZE_MAX / frames) {
|
if (frames > 0 && b >= SIZE_MAX / frames) {
|
||||||
continue; // b*frames or (b+1)*frames would overflow; span is empty
|
continue;
|
||||||
}
|
}
|
||||||
const std::size_t begin = (b * frames) / binCount;
|
const std::size_t begin = (b * frames) / binCount;
|
||||||
const std::size_t end = ((b + 1) * frames) / binCount;
|
const std::size_t end = ((b + 1) * frames) / binCount;
|
||||||
@@ -69,21 +61,17 @@ MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col) {
|
|||||||
const int nbins = static_cast<int>(bins.size());
|
const int nbins = static_cast<int>(bins.size());
|
||||||
if (columnCount <= 0 || nbins == 0) return MinMax{};
|
if (columnCount <= 0 || nbins == 0) return MinMax{};
|
||||||
|
|
||||||
// Clamp col to [0, columnCount-1].
|
|
||||||
if (col < 0) col = 0;
|
if (col < 0) col = 0;
|
||||||
if (col >= columnCount) col = columnCount - 1;
|
if (col >= columnCount) col = columnCount - 1;
|
||||||
|
|
||||||
// Half-open bin range for this column, mirroring computeEnvelope's exact partition.
|
// Half-open bin range for this column, mirroring computeEnvelope's partition. 64-bit
|
||||||
// 64-bit products: col*nbins can exceed int range for a large oversampled envelope
|
// products: col*nbins can exceed int range for a large oversampled envelope.
|
||||||
// (same overflow discipline as computeEnvelope's frame-span arithmetic above).
|
|
||||||
const std::int64_t begin64 = (static_cast<std::int64_t>(col) * nbins) / columnCount;
|
const std::int64_t begin64 = (static_cast<std::int64_t>(col) * nbins) / columnCount;
|
||||||
const std::int64_t end64 =
|
const std::int64_t end64 =
|
||||||
(static_cast<std::int64_t>(col) + 1) * nbins / columnCount;
|
(static_cast<std::int64_t>(col) + 1) * nbins / columnCount;
|
||||||
// col <= columnCount-1 guarantees begin64 <= (columnCount-1)*nbins/columnCount < nbins.
|
|
||||||
const int colBinBegin = static_cast<int>(begin64);
|
const int colBinBegin = static_cast<int>(begin64);
|
||||||
|
|
||||||
// When the column spans no full bin (more columns than bins), use the enclosing bin
|
// When the column spans no full bin (more columns than bins), use the enclosing bin.
|
||||||
// so no column is left empty.
|
|
||||||
const int scanEnd = (end64 > begin64) ? static_cast<int>(end64) : colBinBegin + 1;
|
const int scanEnd = (end64 > begin64) ? static_cast<int>(end64) : colBinBegin + 1;
|
||||||
const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins;
|
const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins;
|
||||||
|
|
||||||
@@ -102,14 +90,11 @@ std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
|
|||||||
AudioSample linearThreshold) {
|
AudioSample linearThreshold) {
|
||||||
if (channelCount == 0) return kNoFrameAboveThreshold;
|
if (channelCount == 0) return kNoFrameAboveThreshold;
|
||||||
|
|
||||||
// Clamp to what the buffer actually holds — a caller frameCount that overstates
|
|
||||||
// the buffer must never read past the end (mirror of computeEnvelope's guard).
|
|
||||||
const std::size_t availableFrames = interleaved.size() / channelCount;
|
const std::size_t availableFrames = interleaved.size() / channelCount;
|
||||||
const std::size_t frames = std::min(frameCount, availableFrames);
|
const std::size_t frames = std::min(frameCount, availableFrames);
|
||||||
if (frames == 0) return kNoFrameAboveThreshold;
|
if (frames == 0) return kNoFrameAboveThreshold;
|
||||||
|
|
||||||
// Scan backward: the first frame (from the end) whose loudest channel exceeds the
|
// Scan backward; `f` runs frames..1 so `f-1` never wraps.
|
||||||
// threshold is the last audible frame. `f` runs frames..1 so `f-1` never wraps.
|
|
||||||
for (std::size_t f = frames; f > 0; --f) {
|
for (std::size_t f = frames; f > 0; --f) {
|
||||||
const std::size_t frame = f - 1;
|
const std::size_t frame = f - 1;
|
||||||
const std::size_t base = frame * channelCount;
|
const std::size_t base = frame * channelCount;
|
||||||
|
|||||||
+42
-76
@@ -1,32 +1,20 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// peaks — waveform min/max envelope (thumbnail) computation from raw interleaved
|
// peaks — waveform min/max envelope (thumbnail) computation from raw interleaved PCM. We compute
|
||||||
// PCM. We compute our own thumbnails from the captured file rather than depending
|
// our own thumbnails rather than depending on REAPER's peak API: we own the file format, so this
|
||||||
// on REAPER's peak API: we own the file format, so this is simpler, testable, and
|
// is simpler, testable, and dependency-free.
|
||||||
// dependency-free. A future bank panel (M5) calls this at whatever bin resolution
|
|
||||||
// the panel width dictates and draws one min/max envelope per channel.
|
|
||||||
//
|
|
||||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
|
||||||
// vendor/ includes. Standard library only. Builds and unit-tests without REAPER.
|
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace reasampler::audio {
|
namespace reasampler::audio {
|
||||||
|
|
||||||
// Canonical in-memory audio-sample type. `float` is REAPER's native audio buffer
|
// REAPER's native audio buffer format (interleaved 32-bit float), consumed directly with no
|
||||||
// format (its render/PCM_source callbacks hand back interleaved 32-bit float), so
|
// lossy conversion. Named AudioSample rather than Sample to avoid colliding with bank_model's
|
||||||
// peaks consumes that directly with no lossy conversion. If a capture ever lands
|
// metadata struct of the same short name.
|
||||||
// as a different depth, the caller converts to float at the boundary — the
|
|
||||||
// thumbnail core stays single-typed.
|
|
||||||
//
|
|
||||||
// NAMED AudioSample, not `Sample`: `reasampler::Sample` is already bank_model's
|
|
||||||
// metadata struct. A `using Sample = float` here would collide at namespace scope
|
|
||||||
// wherever both headers are visible (the bank_panel module includes both). The
|
|
||||||
// audio-domain name also reads more precisely — this is one PCM sample value.
|
|
||||||
using AudioSample = float;
|
using AudioSample = float;
|
||||||
|
|
||||||
// One bin of a channel's envelope: the extremes of every sample that fell in it.
|
// One bin's extremes across the samples that fell in it. min <= max always; an empty bin
|
||||||
// min <= max always. For an empty bin (more bins than frames), both are 0.
|
// (more bins than frames) is {0, 0}.
|
||||||
struct MinMax {
|
struct MinMax {
|
||||||
AudioSample min = 0.0f;
|
AudioSample min = 0.0f;
|
||||||
AudioSample max = 0.0f;
|
AudioSample max = 0.0f;
|
||||||
@@ -37,83 +25,61 @@ struct MinMax {
|
|||||||
// One channel's envelope: exactly `binCount` bins, in time order.
|
// One channel's envelope: exactly `binCount` bins, in time order.
|
||||||
using ChannelEnvelope = std::vector<MinMax>;
|
using ChannelEnvelope = std::vector<MinMax>;
|
||||||
|
|
||||||
// Per-channel envelopes: outer index is channel (channelCount entries, order
|
// Per-channel envelopes: outer index is channel (channelCount entries, order preserved — never
|
||||||
// preserved — never mixed or folded), inner is that channel's bins.
|
// mixed or folded), inner is that channel's bins.
|
||||||
using Envelope = std::vector<ChannelEnvelope>;
|
using Envelope = std::vector<ChannelEnvelope>;
|
||||||
|
|
||||||
// Computes a per-channel min/max envelope from interleaved PCM.
|
// Computes a per-channel min/max envelope from interleaved PCM.
|
||||||
//
|
//
|
||||||
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...].
|
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...]. Size must be
|
||||||
// Size must be >= frameCount * channelCount; extra is ignored.
|
// >= frameCount * channelCount; extra is ignored.
|
||||||
// channelCount channels per frame (the stride). Each channel is enveloped
|
// channelCount channels per frame (the stride). Each channel is enveloped INDEPENDENTLY — no
|
||||||
// INDEPENDENTLY — no averaging, no stereo fold (precision
|
// averaging, no stereo fold (channel count is preserved end to end).
|
||||||
// invariant: channel count preserved).
|
|
||||||
// frameCount frames (samples-per-channel) to consider.
|
// frameCount frames (samples-per-channel) to consider.
|
||||||
// binCount requested bins per channel. Honored exactly for any frameCount.
|
// binCount requested bins per channel. Honored exactly for any frameCount.
|
||||||
//
|
//
|
||||||
// Frame->bin partition: frames are split into `binCount` contiguous spans as
|
// Frame->bin partition: frames split into `binCount` contiguous spans as evenly as possible;
|
||||||
// evenly as possible; when frameCount does not divide evenly, the remainder is
|
// when frameCount doesn't divide evenly, the remainder spreads one-frame-per-bin across the
|
||||||
// spread one-frame-per-bin across the earliest bins (ceil/floor split), so the
|
// earliest bins, so the tail is never dropped and no bin reads out of bounds.
|
||||||
// tail is never dropped and no bin reads out of bounds. When binCount > frameCount
|
|
||||||
// the trailing empty bins are {0, 0}.
|
|
||||||
//
|
//
|
||||||
// Defined behavior for degenerate input (no UB, no throw):
|
// Degenerate input (no UB, no throw): binCount == 0 -> empty bin vector per channel;
|
||||||
// binCount == 0 -> per channel: an empty bin vector.
|
// channelCount == 0 -> empty envelope; frameCount == 0 -> binCount bins, all {0, 0}.
|
||||||
// channelCount == 0 -> an empty envelope (no channels).
|
|
||||||
// frameCount == 0 -> per channel: binCount bins, all {0, 0}.
|
|
||||||
Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
||||||
std::size_t channelCount,
|
std::size_t channelCount,
|
||||||
std::size_t frameCount,
|
std::size_t frameCount,
|
||||||
std::size_t binCount);
|
std::size_t binCount);
|
||||||
|
|
||||||
// The merged min/max for display column `col` (0-based, of `columnCount` total columns)
|
// Merged min/max for display column `col` (0-based, of `columnCount` total) of a pre-computed
|
||||||
// of a pre-computed per-bin ChannelEnvelope: the true extremes of every bin that projects
|
// ChannelEnvelope — the true extremes of every bin projecting to that column. This is the
|
||||||
// to that column. This is the display-side collapse of an envelope computed at HIGHER
|
// display-side collapse when the envelope was computed at a higher resolution than the drawn
|
||||||
// resolution than the drawn width (oversampled bins -> per-pixel-column min/max), so a
|
// width, so a steep transient split across adjacent bins (e.g. {0.9,1.0} then {-1.0,-0.9})
|
||||||
// steep transient whose adjacent bins hold disjoint spans (e.g. {0.9,1.0} then
|
// renders as one gap-free span instead of two separated dots.
|
||||||
// {-1.0,-0.9}) renders as one gap-free vertical span instead of two separated dots.
|
|
||||||
//
|
//
|
||||||
// Bin->column mapping mirrors computeEnvelope's half-open partition:
|
// Bin->column mapping mirrors computeEnvelope's half-open partition: column col owns bins
|
||||||
// column col owns bins [col*nbins/columnCount, (col+1)*nbins/columnCount).
|
// [col*nbins/columnCount, (col+1)*nbins/columnCount). When that range is empty (more columns
|
||||||
// When that range is empty (more columns than bins), the enclosing bin
|
// than bins), the enclosing bin fills the column instead. columnCount <= 0 or bins.empty()
|
||||||
// (col*nbins/columnCount) fills the column — so no column is left empty and no bin is
|
// returns {0, 0}; col is clamped to [0, columnCount-1].
|
||||||
// ever dropped. columnCount <= 0 or bins.empty() returns {0, 0}; `col` is clamped to
|
|
||||||
// [0, columnCount-1]. Pure.
|
|
||||||
MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col);
|
MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col);
|
||||||
|
|
||||||
// Sentinel returned by lastFrameAboveThreshold when NO frame in the scanned range
|
// Sentinel for "no frame in the scanned range peaked above threshold". SIZE_MAX is unambiguous
|
||||||
// peaks above the threshold (pure silence at that level). SIZE_MAX is unambiguous:
|
// since no real frame index can reach it.
|
||||||
// no valid frame index can equal it (a real index is < frameCount <= SIZE_MAX for
|
|
||||||
// any allocatable buffer), so the caller tests `== kNoFrameAboveThreshold` cleanly.
|
|
||||||
inline constexpr std::size_t kNoFrameAboveThreshold =
|
inline constexpr std::size_t kNoFrameAboveThreshold =
|
||||||
static_cast<std::size_t>(-1);
|
static_cast<std::size_t>(-1);
|
||||||
|
|
||||||
// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (the max
|
// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (max |sample| across
|
||||||
// absolute value across all channels of that frame — NO stereo fold, just the
|
// all channels of that frame — no stereo fold) exceeds `linearThreshold`. Returns
|
||||||
// loudest channel that frame) exceeds `linearThreshold`, returning that frame index.
|
// kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input).
|
||||||
// Returns kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input).
|
|
||||||
//
|
//
|
||||||
// This is the boundary primitive behind the realtime tail's decay-scan trim
|
// This is the boundary primitive behind the realtime tail's decay-scan trim (see
|
||||||
// (docs/product/capture-tail.md §The realtime path): the recorded tail window is
|
// docs/product/capture-tail.md): the recorded tail is scanned back from the end for the last
|
||||||
// scanned back from the end for the last frame still above -72 dB, and the file is
|
// frame still above -72 dB, and the file truncated one frame past it. Deliberately separate from
|
||||||
// truncated one frame past it. Deliberately a separate primitive from
|
// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail), this answers
|
||||||
// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail),
|
// "the last frame above a level" (a boundary); bending a bin-oriented envelope to a frame-exact
|
||||||
// this answers "the last frame above a level" (a boundary). Bending the bin-oriented
|
// question is a worse fit.
|
||||||
// envelope to a frame-exact boundary question is a worse fit (spec §option a).
|
|
||||||
//
|
//
|
||||||
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...].
|
// linearThreshold a LINEAR amplitude ratio (e.g. the -72 dB ratio from
|
||||||
// Must hold >= frameCount * channelCount; extra is ignored, and a
|
// render_settings::autoTrimEndRatio), NOT dB. A frame counts as above when
|
||||||
// short buffer is clamped to what it actually holds (no OOB read).
|
// its peak is STRICTLY greater than this.
|
||||||
// channelCount channels per frame (the stride). The per-frame test is the max
|
|
||||||
// |sample| over these channels — the frame is "above" if its
|
|
||||||
// loudest channel is above the threshold.
|
|
||||||
// frameCount frames to consider (the scan starts at the last of these).
|
|
||||||
// linearThreshold the comparison level as a LINEAR amplitude ratio (e.g. the
|
|
||||||
// -72 dB ratio from render_settings::autoTrimEndRatio), NOT dB.
|
|
||||||
// A frame counts as above when its peak is STRICTLY > this.
|
|
||||||
//
|
|
||||||
// Pure, stdlib-only, unit-tested (a synthetic decaying ramp, silence, all-above,
|
|
||||||
// and degenerate inputs) so the trim boundary math is locked outside the DAW.
|
|
||||||
std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
|
std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
|
||||||
std::size_t channelCount,
|
std::size_t channelCount,
|
||||||
std::size_t frameCount,
|
std::size_t frameCount,
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
reasampler_pure_library(capture_paths SOURCES capture_paths.cpp)
|
||||||
|
reasampler_test(capture_paths LINK capture_paths)
|
||||||
|
|
||||||
|
reasampler_pure_library(insert_plan SOURCES insert_plan.cpp)
|
||||||
|
reasampler_test(insert_plan LINK insert_plan)
|
||||||
|
|
||||||
|
reasampler_pure_library(render_settings SOURCES render_settings.cpp LINK PUBLIC bank_model)
|
||||||
|
reasampler_test(render_settings LINK render_settings)
|
||||||
|
|
||||||
|
reasampler_pure_library(batch_capture SOURCES batch_capture.cpp)
|
||||||
|
reasampler_test(batch_capture LINK batch_capture)
|
||||||
|
|
||||||
|
reasampler_pure_library(tail_control
|
||||||
|
SOURCES tail_control.cpp
|
||||||
|
LINK PUBLIC render_settings PRIVATE json)
|
||||||
|
reasampler_test(tail_control LINK tail_control)
|
||||||
|
|
||||||
|
reasampler_pure_library(capture_realtime SOURCES capture_realtime.cpp LINK PUBLIC bank_model)
|
||||||
|
reasampler_test(capture_realtime LINK capture_realtime)
|
||||||
|
|
||||||
|
reasampler_pure_library(wav_codec SOURCES wav_codec.cpp LINK PUBLIC peaks)
|
||||||
|
reasampler_test(wav_codec LINK wav_codec)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// batch_capture.cpp — pure logic for M11 batch capture. See header.
|
// batch_capture.cpp — pure logic for batch capture. See header.
|
||||||
// NO REAPER types; unit-tested by tests/test_batch_capture.cpp.
|
// Unit-tested by tests/test_batch_capture.cpp.
|
||||||
|
|
||||||
#include "core/capture/batch_capture.h"
|
#include "core/capture/batch_capture.h"
|
||||||
|
|
||||||
@@ -12,9 +12,7 @@ std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges)
|
|||||||
units.reserve(ranges.size());
|
units.reserve(ranges.size());
|
||||||
int ordinal = 0;
|
int ordinal = 0;
|
||||||
for (const BatchRange& r : ranges) {
|
for (const BatchRange& r : ranges) {
|
||||||
// Drop empty/inverted ranges — the offline backend refuses end<=start too, so
|
// Drop empty/inverted ranges — the offline backend refuses end<=start too.
|
||||||
// planning one would only manufacture a guaranteed per-unit failure. Ordinals
|
|
||||||
// count kept units so the reported numbering is contiguous.
|
|
||||||
if (!(r.endSeconds > r.startSeconds)) continue;
|
if (!(r.endSeconds > r.startSeconds)) continue;
|
||||||
++ordinal;
|
++ordinal;
|
||||||
units.push_back({ordinal, r.startSeconds, r.endSeconds});
|
units.push_back({ordinal, r.startSeconds, r.endSeconds});
|
||||||
|
|||||||
@@ -1,29 +1,24 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// batch_capture — the REAPER-free logic behind M11 batch capture (one action fires
|
// batch_capture — the REAPER-free logic behind batch capture (one action fires N
|
||||||
// N captures: one bank sample per selected item / per razor area).
|
// captures: one bank sample per selected item / per razor area).
|
||||||
//
|
//
|
||||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
||||||
// vendor/ includes. Standard library only. The batch shell (main.cpp) reads the DAW
|
// only. The batch shell reads the DAW state (selected items -> exact bounds;
|
||||||
// state (selected items -> their exact bounds; every track's P_RAZOREDITS -> areas)
|
// each track's P_RAZOREDITS -> areas) and hands the raw ranges here:
|
||||||
// and hands the raw ranges here so the genuinely-pure, easy-to-get-wrong pieces are
|
|
||||||
// unit-tested outside the DAW:
|
|
||||||
//
|
//
|
||||||
// 1. planCaptureUnits: an ordered list of (start,end) source ranges -> an ordered
|
// 1. planCaptureUnits: an ordered list of (start,end) ranges -> an ordered
|
||||||
// list of CaptureUnit, each carrying its 1-based ordinal and validated bounds.
|
// list of CaptureUnit, each with a 1-based ordinal and validated bounds.
|
||||||
// Empty/inverted ranges are DROPPED (mirrors the offline backend's own
|
// Empty/inverted ranges are dropped (mirrors the offline backend's own
|
||||||
// end>start guard) so a zero-length item/area never produces a stray render.
|
// end>start guard); ordinals count only the kept units, so three valid
|
||||||
// Order is preserved: unit ordinals count only the KEPT units, so a batch of
|
// items yield 1,2,3 regardless of dropped neighbors.
|
||||||
// three valid items yields ordinals 1,2,3 regardless of dropped neighbors.
|
// 2. BatchOutcome: order-preserving aggregation of per-unit results into a
|
||||||
// 2. BatchOutcome: order-preserving aggregation of per-unit results into a summary
|
// summary (succeeded/failed counts + ordered failures) for one console
|
||||||
// (succeeded / failed counts + the ordered list of failures) so the shell can
|
// line with no partial-corruption ambiguity.
|
||||||
// report a mixed result with one console line and no partial-corruption
|
|
||||||
// ambiguity. The AGGREGATION is pure; the render loop that feeds it is shell.
|
|
||||||
//
|
//
|
||||||
// Range is the ONLY thing that varies per unit here. FX scope (item vs track) is a
|
// Range is the only thing that varies per unit here. FX scope (item vs track) is
|
||||||
// per-ACTION constant the shell already owns (fxBypassPlanFor); it is not a
|
// a per-action constant the shell already owns; item-batch uses item scope,
|
||||||
// per-unit field. Item-batch uses item scope; razor-batch uses track scope — the
|
// razor-batch uses track scope, passed through unchanged from the single-capture
|
||||||
// shell passes the scope straight through to each render, unchanged from the
|
// path.
|
||||||
// single-capture path.
|
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -31,10 +26,10 @@
|
|||||||
|
|
||||||
namespace reasampler::capture {
|
namespace reasampler::capture {
|
||||||
|
|
||||||
// One capture in a batch: an exact source range plus its 1-based ordinal within the
|
// One capture in a batch: an exact source range plus its 1-based ordinal within
|
||||||
// KEPT set. The ordinal disambiguates per-unit file stems (the offline backend's
|
// the kept set. The ordinal disambiguates per-unit file stems (the offline
|
||||||
// unique tag is 1-second-granular, so a fast batch could otherwise collide N files
|
// backend's unique tag is 1-second-granular, so a fast batch could otherwise
|
||||||
// onto one name) and labels a failure in the summary.
|
// collide N files onto one name) and labels a failure in the summary.
|
||||||
struct CaptureUnit {
|
struct CaptureUnit {
|
||||||
int ordinal = 0; // 1-based, counts kept units only
|
int ordinal = 0; // 1-based, counts kept units only
|
||||||
double startSeconds = 0.0; // exact — no rounding
|
double startSeconds = 0.0; // exact — no rounding
|
||||||
@@ -42,20 +37,18 @@ struct CaptureUnit {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// A source range handed in by the shell (a selected item's [pos, pos+len] or one
|
// A source range handed in by the shell (a selected item's [pos, pos+len] or one
|
||||||
// razor area's [start, end]). Kept as a distinct type from CaptureUnit so the input
|
// razor area's [start, end]). Named BatchRange (not SourceRange) to avoid
|
||||||
// (raw, possibly-invalid) and the output (validated, ordinal-assigned) do not share
|
// collision with bank_model's SourceRange, which carries PPQ fields this planner
|
||||||
// a shape by accident. Named BatchRange (not SourceRange) to avoid collision with
|
// doesn't need.
|
||||||
// bank_model's SourceRange, which carries PPQ fields this planner does not need.
|
|
||||||
struct BatchRange {
|
struct BatchRange {
|
||||||
double startSeconds = 0.0;
|
double startSeconds = 0.0;
|
||||||
double endSeconds = 0.0;
|
double endSeconds = 0.0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validates + orders a batch's source ranges into capture units. Preserves input
|
// Validates + orders a batch's source ranges into capture units. Preserves input
|
||||||
// order; DROPS every range with end <= start (empty/inverted) so no stray render is
|
// order; drops every range with end <= start; assigns 1-based ordinals over the
|
||||||
// planned; assigns 1-based ordinals over the KEPT units. An empty input (no selected
|
// kept units. An empty input yields an empty plan — the shell reports "nothing
|
||||||
// item / no razor area) yields an empty plan — the shell reports "nothing to batch"
|
// to batch" and writes nothing.
|
||||||
// and writes nothing (the same no-op posture the single-capture path takes).
|
|
||||||
std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges);
|
std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges);
|
||||||
|
|
||||||
// The per-unit verdict the shell records after each render attempt, in unit order.
|
// The per-unit verdict the shell records after each render attempt, in unit order.
|
||||||
@@ -65,10 +58,9 @@ struct BatchUnitResult {
|
|||||||
std::string detail; // failure reason (empty on success) — for the summary
|
std::string detail; // failure reason (empty on success) — for the summary
|
||||||
};
|
};
|
||||||
|
|
||||||
// Order-preserving aggregation of a batch's per-unit results. Built incrementally by
|
// Order-preserving aggregation of a batch's per-unit results. Built incrementally
|
||||||
// the shell (record() after each unit) so a mid-batch failure is captured without
|
// by the shell (record() after each unit) so a mid-batch failure doesn't abort
|
||||||
// aborting the remaining units (no partial corruption: each unit is independent, and
|
// the remaining units — each unit is independent.
|
||||||
// the selection is restored on every exit path by the shell's RAII guard).
|
|
||||||
class BatchOutcome {
|
class BatchOutcome {
|
||||||
public:
|
public:
|
||||||
// Records one unit's verdict. Order of calls IS the reported order.
|
// Records one unit's verdict. Order of calls IS the reported order.
|
||||||
|
|||||||
@@ -6,25 +6,16 @@
|
|||||||
|
|
||||||
namespace reasampler::capture {
|
namespace reasampler::capture {
|
||||||
|
|
||||||
// The content-identity hashes (hashBytes / hashWavContent) moved to wav_codec
|
|
||||||
// (Q-W3, audit §4e) — one pure owner of the RIFF chunk walk, shared with the
|
|
||||||
// layout parse so hashing and decoding cannot desynchronize.
|
|
||||||
|
|
||||||
std::string normalizeSlashes(const std::string& path) {
|
std::string normalizeSlashes(const std::string& path) {
|
||||||
std::string out = path;
|
std::string out = path;
|
||||||
for (char& c : out) {
|
for (char& c : out) {
|
||||||
if (c == '\\') c = '/';
|
if (c == '\\') c = '/';
|
||||||
}
|
}
|
||||||
// Strip a single trailing slash so joins do not double up. Preserve a lone
|
// Strip a trailing slash but preserve a lone "/" (root).
|
||||||
// "/" (root) — stripping it would turn root into empty.
|
|
||||||
if (out.size() > 1 && out.back() == '/') {
|
if (out.size() > 1 && out.back() == '/') {
|
||||||
out.pop_back();
|
out.pop_back();
|
||||||
}
|
}
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
// Windows paths are case-insensitive. Fold to lowercase so that two paths
|
|
||||||
// that differ only in drive-letter or component casing compare equal (e.g.
|
|
||||||
// "C:/Foo/BAR.wav" == "c:/foo/bar.wav"). On macOS/Linux, exact case is
|
|
||||||
// preserved (the filesystem is case-sensitive; folding would be wrong).
|
|
||||||
for (char& c : out) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
for (char& c : out) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||||
#endif
|
#endif
|
||||||
return out;
|
return out;
|
||||||
@@ -39,8 +30,7 @@ std::string sanitizeStem(const std::string& baseName) {
|
|||||||
c == '-';
|
c == '-';
|
||||||
out.push_back(keep ? static_cast<char>(c) : '_');
|
out.push_back(keep ? static_cast<char>(c) : '_');
|
||||||
}
|
}
|
||||||
// Collapse to a stable default if nothing usable survived (e.g. all spaces).
|
// Collapse to a stable default if nothing alnum survived.
|
||||||
// A stem of only separators ('.', '_', '-') is also unhelpful as a name.
|
|
||||||
bool hasAlnum = false;
|
bool hasAlnum = false;
|
||||||
for (unsigned char c : out) {
|
for (unsigned char c : out) {
|
||||||
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||||
@@ -66,21 +56,16 @@ BankPaths deriveBankPaths(const std::string& projectDir,
|
|||||||
}
|
}
|
||||||
const std::string fileName = stem + ".wav";
|
const std::string fileName = stem + ".wav";
|
||||||
|
|
||||||
// Precondition: the capture shell must resolve a non-empty project directory
|
// Precondition: caller must resolve a non-empty project directory — an
|
||||||
// before calling this function. An empty projectDir would produce a bare
|
// empty one would otherwise fall back to a bare relative path (forbidden).
|
||||||
// relative "reasampler_bank" path — the silent default-location fallback this
|
// Assert in debug; leave absoluteDir empty in release so a caller that
|
||||||
// tool explicitly forbids. Assert in debug; leave absoluteDir empty in release
|
// ignores it fails at the render/stat step, not silently onto CWD.
|
||||||
// so any caller that ignores the precondition fails loudly at the render/stat
|
|
||||||
// step rather than silently writing to CWD.
|
|
||||||
assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty");
|
assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty");
|
||||||
|
|
||||||
BankPaths p;
|
BankPaths p;
|
||||||
p.fileStem = stem; // stem only — REAPER appends extension
|
p.fileStem = stem; // stem only — REAPER appends extension
|
||||||
p.fileName = fileName;
|
p.fileName = fileName;
|
||||||
p.relativePath = std::string(kBankSubfolder) + "/" + fileName;
|
p.relativePath = std::string(kBankSubfolder) + "/" + fileName;
|
||||||
// absoluteDir intentionally omits a trailing slash (RENDER_FILE wants the
|
|
||||||
// directory itself; RENDER_PATTERN supplies the file name separately).
|
|
||||||
// Empty when precondition is violated (dir empty) — caller must not proceed.
|
|
||||||
p.absoluteDir = dir.empty() ? std::string{}
|
p.absoluteDir = dir.empty() ? std::string{}
|
||||||
: dir + "/" + kBankSubfolder;
|
: dir + "/" + kBankSubfolder;
|
||||||
return p;
|
return p;
|
||||||
@@ -88,15 +73,12 @@ BankPaths deriveBankPaths(const std::string& projectDir,
|
|||||||
|
|
||||||
std::string bankRelativeForName(const std::string& fileName) {
|
std::string bankRelativeForName(const std::string& fileName) {
|
||||||
if (fileName.empty()) return {};
|
if (fileName.empty()) return {};
|
||||||
// The SAME expression deriveBankPaths uses for relativePath, kept in one place so
|
// Same expression deriveBankPaths uses, so the two spellings can't drift.
|
||||||
// the two spellings can never drift (Phase R spelling-consistency invariant).
|
|
||||||
return std::string(kBankSubfolder) + "/" + fileName;
|
return std::string(kBankSubfolder) + "/" + fileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string resolveBankFile(const std::string& projectDir,
|
std::string resolveBankFile(const std::string& projectDir,
|
||||||
const std::string& relativePath) {
|
const std::string& relativePath) {
|
||||||
// No default-location fallback (CLAUDE.md invariant): an empty project dir or
|
|
||||||
// relative path yields empty, not a bare relative path resolved against CWD.
|
|
||||||
if (projectDir.empty() || relativePath.empty()) {
|
if (projectDir.empty() || relativePath.empty()) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -109,10 +91,7 @@ std::string resolveBankFile(const std::string& projectDir,
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string projectDirOfRpp(const std::string& rppPath) {
|
std::string projectDirOfRpp(const std::string& rppPath) {
|
||||||
// An unsaved project reports an empty .rpp path; keep it empty so downstream
|
if (rppPath.empty()) return {}; // unsaved project: keep empty, no fallback
|
||||||
// resolution refuses (no default-location fallback). Mirrors the former persist shell's
|
|
||||||
// projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes.
|
|
||||||
if (rppPath.empty()) return {};
|
|
||||||
std::string dir = std::filesystem::path(rppPath).parent_path().string();
|
std::string dir = std::filesystem::path(rppPath).parent_path().string();
|
||||||
return normalizeSlashes(dir);
|
return normalizeSlashes(dir);
|
||||||
}
|
}
|
||||||
@@ -128,9 +107,7 @@ BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
|
|||||||
|
|
||||||
r.oldBankDir = oldDir + "/" + kBankSubfolder;
|
r.oldBankDir = oldDir + "/" + kBankSubfolder;
|
||||||
r.newBankDir = newDir + "/" + kBankSubfolder;
|
r.newBankDir = newDir + "/" + kBankSubfolder;
|
||||||
// A Save (in place) leaves the project dir unchanged — nothing to relocate.
|
r.needed = (oldDir != newDir); // Save-in-place leaves the dir unchanged
|
||||||
// Only a Save-As to a different directory needs the bank moved.
|
|
||||||
r.needed = (oldDir != newDir);
|
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,42 +116,16 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject,
|
|||||||
const std::string& lastPath,
|
const std::string& lastPath,
|
||||||
const std::string& currentGuid,
|
const std::string& currentGuid,
|
||||||
const std::string& currentPath) {
|
const std::string& currentPath) {
|
||||||
// 1. The GUID is the identity of record and is checked FIRST. A different
|
// See capture_paths.h for the GUID-primary rationale and rule order.
|
||||||
// stored GUID means a genuinely different project is active — Load ITS index.
|
|
||||||
// This catches the regression that pointer-primary classification missed:
|
|
||||||
// REAPER RECYCLES ReaProject* addresses across close/open, so a reopened /
|
|
||||||
// new project can reuse the previous project's address (sameProjectObject ==
|
|
||||||
// true) while carrying a different stored GUID. Deciding on the pointer alone
|
|
||||||
// then returned NoOp/SaveAsRelocate and the bank never reloaded. The GUID is
|
|
||||||
// immune to address recycling, so it leads. Also covers new/unsaved<->saved
|
|
||||||
// transitions (one GUID empty, the other not) and switching between two
|
|
||||||
// distinct saved projects.
|
|
||||||
if (currentGuid != lastGuid) {
|
if (currentGuid != lastGuid) {
|
||||||
return ProjectTransition::Load;
|
return ProjectTransition::Load;
|
||||||
}
|
}
|
||||||
|
|
||||||
// From here currentGuid == lastGuid (they are equal; both may be empty for
|
|
||||||
// unsaved projects). The pointer now disambiguates the same-GUID case.
|
|
||||||
|
|
||||||
// 2. Same GUID but a DIFFERENT object is a forked sibling: Save-As copied our
|
|
||||||
// GUID onto a distinct project object. Load its (own) index; never relocate.
|
|
||||||
// Two unsaved projects (both GUIDs empty, distinct objects) also land here —
|
|
||||||
// Load, so switching between them installs the right in-memory state.
|
|
||||||
if (!sameProjectObject) {
|
if (!sameProjectObject) {
|
||||||
return ProjectTransition::Load;
|
return ProjectTransition::Load; // forked sibling: same GUID, different object
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Same object AND same GUID with a NEW path is a genuine Save-As (the object
|
|
||||||
// identity is proven and the record identity is unchanged — only the .rpp
|
|
||||||
// moved). Also the first save of an unsaved project (both GUIDs empty, old
|
|
||||||
// path empty): SaveAsRelocate is safe there because deriveRelocationPlan
|
|
||||||
// no-ops on the empty old dir (empty-GUID safety preserved) while poll()
|
|
||||||
// mints a GUID.
|
|
||||||
if (currentPath != lastPath) {
|
if (currentPath != lastPath) {
|
||||||
return ProjectTransition::SaveAsRelocate;
|
return ProjectTransition::SaveAsRelocate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Same object, same GUID, same path — Save in place / idle tick.
|
|
||||||
return ProjectTransition::NoOp;
|
return ProjectTransition::NoOp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// capture_paths — the REAPER-free path arithmetic behind offline capture.
|
// capture_paths — the REAPER-free path arithmetic behind offline capture. The
|
||||||
//
|
// capture shell resolves the current project directory via REAPER APIs, then
|
||||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
// hands the raw strings here. Forward-slash form throughout, no filesystem
|
||||||
// vendor/ includes. Standard library only. The capture shell resolves the
|
// access; the bank subfolder name is a fixed constant.
|
||||||
// current project directory via REAPER APIs, then hands the raw strings here so
|
|
||||||
// the fiddly, easy-to-get-wrong path arithmetic (bank subfolder, unique file
|
|
||||||
// name, absolute render dir, project-relative index path) is unit-tested outside
|
|
||||||
// the DAW.
|
|
||||||
//
|
|
||||||
// Path convention: this module works in forward-slash form and does NOT touch
|
|
||||||
// the filesystem. The bank subfolder name is a fixed constant so the same
|
|
||||||
// project always resolves the same bank location (determinism).
|
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
@@ -20,7 +12,7 @@
|
|||||||
namespace reasampler::capture {
|
namespace reasampler::capture {
|
||||||
|
|
||||||
// The project-relative bank subfolder. All captured wavs live here so the bank
|
// The project-relative bank subfolder. All captured wavs live here so the bank
|
||||||
// travels with the .rpp (CONTEXT.md §Settled decisions: per-project bank).
|
// travels with the .rpp.
|
||||||
inline constexpr const char* kBankSubfolder = "reasampler_bank";
|
inline constexpr const char* kBankSubfolder = "reasampler_bank";
|
||||||
|
|
||||||
// A resolved pair of paths for one capture: where REAPER must be told to write
|
// A resolved pair of paths for one capture: where REAPER must be told to write
|
||||||
@@ -34,150 +26,87 @@ struct BankPaths {
|
|||||||
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
|
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
|
||||||
};
|
};
|
||||||
|
|
||||||
// NOTE (Q-W3, audit §4e): the content-identity hashes (hashBytes / hashWavContent)
|
// Normalizes a path to forward slashes and strips any trailing slash (does not
|
||||||
// moved to core/capture/wav_codec.{h,cpp} — the ONE pure owner of the WAV/RIFF byte
|
// consult the filesystem). On Windows (_WIN32) also lowercases the result so
|
||||||
// format — so this module holds path arithmetic only, with no RIFF chunk knowledge.
|
// paths differing only in casing compare equal; macOS/Linux preserve case.
|
||||||
|
|
||||||
// Normalizes a path to forward slashes and strips any trailing slash. Empty in
|
|
||||||
// -> empty out. Pure string transform (does not consult the filesystem).
|
|
||||||
// Platform case rule: on Windows (_WIN32) the result is also lowercased so that
|
|
||||||
// paths differing only in drive-letter or component casing compare equal (Windows
|
|
||||||
// paths are case-insensitive). On macOS/Linux the case is preserved exactly (those
|
|
||||||
// filesystems are case-sensitive).
|
|
||||||
std::string normalizeSlashes(const std::string& path);
|
std::string normalizeSlashes(const std::string& path);
|
||||||
|
|
||||||
// Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps
|
// Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps
|
||||||
// [A-Za-z0-9._-], replaces every other byte (spaces, slashes, quotes, control)
|
// [A-Za-z0-9._-], replaces every other byte with '_', and collapses to
|
||||||
// with '_', and collapses to "capture" if nothing usable remains. Deterministic:
|
// "capture" if nothing usable remains. Deterministic.
|
||||||
// the same input always yields the same stem (feeds bit-identical file naming).
|
|
||||||
std::string sanitizeStem(const std::string& baseName);
|
std::string sanitizeStem(const std::string& baseName);
|
||||||
|
|
||||||
// Derives the bank paths for one capture.
|
// Derives the bank paths for one capture: baseName is the sanitized file-stem
|
||||||
// projectDir : absolute directory of the current .rpp (any slash style)
|
// source, uniqueTag an optional sanitized disambiguator (timestamp/counter) so
|
||||||
// baseName : human base for the file stem (sanitized)
|
// repeated captures don't collide. Produces "<stem>[_<tag>].wav".
|
||||||
// uniqueTag : caller-supplied disambiguator appended to the stem (e.g. a
|
|
||||||
// timestamp or counter) so repeated captures do not collide.
|
|
||||||
// Also sanitized. May be empty.
|
|
||||||
// Produces "<stem>[_<tag>].wav". The relativePath is always project-relative and
|
|
||||||
// forward-slashed so it satisfies BankModel::add's relative-only invariant.
|
|
||||||
BankPaths deriveBankPaths(const std::string& projectDir,
|
BankPaths deriveBankPaths(const std::string& projectDir,
|
||||||
const std::string& baseName,
|
const std::string& baseName,
|
||||||
const std::string& uniqueTag);
|
const std::string& uniqueTag);
|
||||||
|
|
||||||
// The project-relative index spelling for a bank file KNOWN ONLY by its file name —
|
// The project-relative index spelling for a bank file known only by its file
|
||||||
// the forward derivation the Phase R prune shell uses to spell an ENUMERATED folder
|
// name (bare entry, no directory) — the prune shell uses this to spell an
|
||||||
// entry the SAME way deriveBankPaths spelled it at capture time. By construction it
|
// enumerated folder entry the SAME way deriveBankPaths spelled it at capture
|
||||||
// is the identical expression deriveBankPaths().relativePath uses (kBankSubfolder +
|
// time; a divergence here could make a referenced file look like an orphan.
|
||||||
// "/" + fileName), so a file the capture path created and a directory listing of that
|
|
||||||
// same file resolve to the byte-identical relative string — the safety-critical
|
|
||||||
// spelling-consistency the prune core's exact-string match depends on (a divergence
|
|
||||||
// here could make a referenced file look like an orphan). fileName is a bare entry
|
|
||||||
// name (no directory component); the caller supplies forward-slash-free names from the
|
|
||||||
// folder enumeration. Empty in -> empty out.
|
|
||||||
std::string bankRelativeForName(const std::string& fileName);
|
std::string bankRelativeForName(const std::string& fileName);
|
||||||
|
|
||||||
// --- Persist-side path arithmetic (M4) --------------------------------------
|
// --- Persist-side path arithmetic -------------------------------------------
|
||||||
//
|
//
|
||||||
// The index stores relative paths only; on project load the persist shell must
|
// The index stores relative paths only; on project load the persist shell
|
||||||
// turn each entry's relativePath back into an absolute path against the CURRENT
|
// turns each relativePath back into an absolute path against the current
|
||||||
// project directory (so a project opened from a new location still resolves its
|
// project directory — the inverse of deriveBankPaths.
|
||||||
// bank). This is the inverse of the relativePath the capture path produced.
|
|
||||||
//
|
// Returns "<projectDir>/<relativePath>" forward-slashed, or empty if either
|
||||||
// projectDir : absolute directory of the current .rpp (any slash style)
|
// input is empty (no default-location fallback — an unsaved/unset project
|
||||||
// relativePath : a project-relative index entry (e.g. "reasampler_bank/x.wav")
|
// fails loudly rather than resolving against CWD).
|
||||||
//
|
|
||||||
// Returns "<projectDir>/<relativePath>" forward-slashed. Returns empty when
|
|
||||||
// either input is empty (no default-location fallback — CLAUDE.md invariant) so
|
|
||||||
// a caller that ignores an unsaved/unset project fails loudly rather than
|
|
||||||
// resolving against CWD.
|
|
||||||
std::string resolveBankFile(const std::string& projectDir,
|
std::string resolveBankFile(const std::string& projectDir,
|
||||||
const std::string& relativePath);
|
const std::string& relativePath);
|
||||||
|
|
||||||
// The project directory that holds a .rpp: its parent directory, forward-slashed,
|
// The project directory that holds a .rpp: parent directory, forward-slashed,
|
||||||
// trailing slash stripped. Empty in -> empty out (an unsaved project has an empty
|
// trailing slash stripped. Empty in -> empty out (an unsaved project reports
|
||||||
// .rpp path, which must stay empty so resolveBankFile refuses to resolve — the
|
// an empty .rpp path). Pure so the VST3 instrument resolves audio paths the
|
||||||
// no-default-location invariant). This is the M4 convention persist uses to place
|
// same way persist does.
|
||||||
// the bank alongside the .rpp; extracted here (pure) so the VST3 instrument resolves
|
|
||||||
// audio paths the SAME way persist does rather than re-implementing the derivation.
|
|
||||||
std::string projectDirOfRpp(const std::string& rppPath);
|
std::string projectDirOfRpp(const std::string& rppPath);
|
||||||
|
|
||||||
// A relocation plan for the physical bank folder on Save-As to a new project
|
// A relocation plan for the physical bank folder on Save-As to a new project
|
||||||
// location. The index's relative paths do NOT change (they are relative to the
|
// location. The index's relative paths do NOT change (they are relative to the
|
||||||
// project dir, which is what moved with the .rpp), so relocation is purely a
|
// project dir, which moved with the .rpp), so relocation is purely a folder
|
||||||
// folder move: copy/move the whole bank subfolder from the old project dir to
|
// move. Both dirs are absolute, forward-slashed, trailing-slash-stripped.
|
||||||
// the new one. Both dirs are absolute, forward-slashed, trailing-slash-stripped.
|
|
||||||
struct BankRelocation {
|
struct BankRelocation {
|
||||||
std::string oldBankDir; // <oldProjectDir>/reasampler_bank
|
std::string oldBankDir; // <oldProjectDir>/reasampler_bank
|
||||||
std::string newBankDir; // <newProjectDir>/reasampler_bank
|
std::string newBankDir; // <newProjectDir>/reasampler_bank
|
||||||
bool needed = false; // false when old==new (Save in place, not Save-As)
|
bool needed = false; // false when old==new (Save in place, not Save-As)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Derives the relocation plan from the old and new project directories.
|
// Derives the relocation plan: `needed` is true iff the normalized old/new
|
||||||
// oldProjectDir : project dir the bank currently sits under (any slash style)
|
// project dirs differ (a genuine Save-As-to-new-dir); empty dirs/needed=false
|
||||||
// newProjectDir : project dir the .rpp was just saved to (any slash style)
|
// when either input is empty.
|
||||||
// `needed` is true iff the normalized dirs differ (a genuine Save-As-to-new-dir).
|
|
||||||
// Returns a plan with empty dirs and needed=false when either input is empty.
|
|
||||||
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
|
BankRelocation deriveRelocationPlan(const std::string& oldProjectDir,
|
||||||
const std::string& newProjectDir);
|
const std::string& newProjectDir);
|
||||||
|
|
||||||
// --- Project-identity transition (W12 combined identity fix) -----------------
|
// --- Project-identity transition ---------------------------------------------
|
||||||
//
|
//
|
||||||
// What the persist timer must do on each tick. Identity rests on TWO facts,
|
// What the persist timer must do on each tick. GUID is checked FIRST because
|
||||||
// layered GUID-PRIMARY:
|
// two prior pointer-primary/GUID-only designs each broke a real case: a
|
||||||
// 1. the minted GUID — content-based identity of record, stored in ext state.
|
// GUID-only check misreads a Save-As fork as the same project (fork and
|
||||||
// It is IMMUNE to REAPER recycling a closed project's ReaProject* address,
|
// parent share a GUID on disk); a pointer-primary check misreads REAPER
|
||||||
// so it is checked FIRST.
|
// recycling a closed project's ReaProject* address onto an unrelated project
|
||||||
// 2. sameProjectObject — did the same live ReaProject* stay active across the
|
// (a different project, same recycled pointer, read as NoOp/SaveAsRelocate —
|
||||||
// two ticks (computed in poll() as `proj == lastProject_`)? Used ONLY to
|
// the bank never reloads). Checking GUID first catches recycling; the pointer
|
||||||
// disambiguate the same-GUID case: a forked sibling (Save-As copied our GUID
|
// (sameProjectObject) then separates a forked sibling (Load) from a genuine
|
||||||
// onto a distinct object) vs a genuine Save-As (one object, new path).
|
// Save-As (SaveAsRelocate).
|
||||||
//
|
|
||||||
// This fix layers both prior designs, GUID-primary. M4 (GUID-only) broke Save-As
|
|
||||||
// forks: Save-As copies the whole .rpp incl. our stored GUID, so a fork and its
|
|
||||||
// parent share a GUID on disk. W10 (pointer-primary, GUID voided) broke pointer
|
|
||||||
// RECYCLING: REAPER reuses a closed project's address, so a reopened/new project
|
|
||||||
// can present the previous project's pointer with a different stored GUID —
|
|
||||||
// pointer-primary read that as NoOp/SaveAsRelocate and the bank never reloaded.
|
|
||||||
// Checking the GUID first catches recycling; the pointer then separates a fork
|
|
||||||
// (same GUID, different object -> Load) from a Save-As (same GUID, same object,
|
|
||||||
// new path -> relocate).
|
|
||||||
//
|
|
||||||
// The load-bearing rule: a DIFFERENT record identity (GUID) is always a Load; a
|
|
||||||
// DIFFERENT project object with the same GUID is a fork Load, never a relocate.
|
|
||||||
enum class ProjectTransition {
|
enum class ProjectTransition {
|
||||||
NoOp, // same object, same GUID, same location — nothing to do
|
NoOp, // same object, same GUID, same location — nothing to do
|
||||||
Load, // a different project is active — load ITS index from ext state
|
Load, // a different project is active — load ITS index from ext state
|
||||||
SaveAsRelocate, // SAME object + SAME GUID, new .rpp location — relocate the bank
|
SaveAsRelocate, // SAME object + SAME GUID, new .rpp location — relocate the bank
|
||||||
};
|
};
|
||||||
|
|
||||||
// Classifies what a poll tick observed.
|
// Classifies what a poll tick observed. sameProjectObject is passed as a bool
|
||||||
// sameProjectObject : true iff the SAME ReaProject* stayed active across the two
|
// (not the raw pointer) to keep the classifier REAPER-free and testable;
|
||||||
// ticks (poll() computes `proj == lastProject_`). The pure
|
// lastGuid/lastPath is the project persist last acted on, currentGuid/
|
||||||
// classifier takes the bool, not the raw pointer, to stay
|
// currentPath the now-active project (both "" if unsaved/unwritten).
|
||||||
// REAPER-free and testable.
|
// Evaluated in order: currentGuid!=lastGuid -> Load; !sameProjectObject ->
|
||||||
// lastGuid : the GUID of the project persist last acted on ("" if none/unsaved)
|
// Load (forked sibling); currentPath!=lastPath -> SaveAsRelocate (also covers
|
||||||
// lastPath : that project's .rpp path when last seen ("" if unsaved)
|
// first save of an unsaved project); else NoOp.
|
||||||
// currentGuid : the GUID stored in the now-active project's ext state ("" if
|
|
||||||
// unsaved or never written)
|
|
||||||
// currentPath : the now-active project's .rpp path ("" if unsaved)
|
|
||||||
//
|
|
||||||
// Rules (evaluated in EXACTLY this order):
|
|
||||||
// 1. currentGuid != lastGuid -> Load (different record identity:
|
|
||||||
// recycled pointer w/ different GUID,
|
|
||||||
// new/unsaved<->saved, or two distinct
|
|
||||||
// saved projects)
|
|
||||||
// 2. !sameProjectObject -> Load (same GUID, different object:
|
|
||||||
// forked sibling, or two unsaved projects)
|
|
||||||
// 3. currentPath != lastPath -> SaveAsRelocate (same object + same GUID,
|
|
||||||
// new path: genuine Save-As, or first save
|
|
||||||
// of an unsaved project — relocate no-ops
|
|
||||||
// on the empty old dir, poll() mints a GUID)
|
|
||||||
// 4. otherwise -> NoOp (same object, same GUID, same path)
|
|
||||||
//
|
|
||||||
// The GUID (identity of record) leads; the pointer only disambiguates the same-GUID
|
|
||||||
// case (fork-Load in step 2 vs Save-As in step 3). The empty-GUID safety (unsaved
|
|
||||||
// projects never physically relocate) is preserved because an empty old project dir
|
|
||||||
// makes deriveRelocationPlan's `needed` false.
|
|
||||||
ProjectTransition classifyProjectTransition(bool sameProjectObject,
|
ProjectTransition classifyProjectTransition(bool sameProjectObject,
|
||||||
const std::string& lastGuid,
|
const std::string& lastGuid,
|
||||||
const std::string& lastPath,
|
const std::string& lastPath,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// capture_realtime.cpp — pure logic for the realtime-record backend (M8). See
|
// capture_realtime.cpp — pure logic for the realtime-record backend. See header.
|
||||||
// header. NO REAPER types; unit-tested by tests/test_capture_realtime.cpp.
|
// Unit-tested by tests/test_capture_realtime.cpp.
|
||||||
// (Renamed from realtime_record.cpp in Q-W3 — the Q-9 naming rider.)
|
|
||||||
|
|
||||||
#include "core/capture/capture_realtime.h"
|
#include "core/capture/capture_realtime.h"
|
||||||
|
|
||||||
@@ -9,11 +8,8 @@ namespace reasampler::capture {
|
|||||||
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) {
|
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) {
|
||||||
RecordModePlan p;
|
RecordModePlan p;
|
||||||
|
|
||||||
// Stereo vs mono output recording, latency-compensated either way so the
|
// REAPER's output-record modes are mono/stereo only; >2 channels still
|
||||||
// recorded file lines up with the source. A request asking for <= 1 channel
|
// records stereo-out (a >2-channel realtime capture is out of scope).
|
||||||
// records mono-out; anything else records stereo-out. (Higher channel counts
|
|
||||||
// still record stereo-out here — REAPER's output-record modes are mono/stereo
|
|
||||||
// only; a >2-channel realtime capture is out of scope for this increment.)
|
|
||||||
p.recMode = (channelCount <= 1) ? kRecModeMonoOutLatComp
|
p.recMode = (channelCount <= 1) ? kRecModeMonoOutLatComp
|
||||||
: kRecModeStereoOutLatComp;
|
: kRecModeStereoOutLatComp;
|
||||||
|
|
||||||
@@ -26,42 +22,32 @@ RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
OutputTap outputTapForWetDry(double wetDry) {
|
OutputTap outputTapForWetDry(double wetDry) {
|
||||||
// Fully wet (1.0) taps post-fader; any dry-ward value taps pre-FX — the true
|
|
||||||
// pre-FX dry that offline render cannot produce (the realtime backend's whole
|
|
||||||
// reason to exist for the M10 null test). PostFxPreFader is an explicit future
|
|
||||||
// option, not reachable from the wet/dry axis, so it is not returned here.
|
|
||||||
return (wetDry >= 1.0) ? OutputTap::PostFader : OutputTap::PreFx;
|
return (wetDry >= 1.0) ? OutputTap::PostFader : OutputTap::PreFx;
|
||||||
}
|
}
|
||||||
|
|
||||||
Sample sampleFromRecordedCapture(const RecordedCapture& cap) {
|
Sample sampleFromRecordedCapture(const RecordedCapture& cap) {
|
||||||
Sample s;
|
Sample s;
|
||||||
// Same id shape as the offline path: "cap-<tag>-<fileName>" would need the file
|
// Relative path tail included so two same-tag captures (shouldn't happen) still differ.
|
||||||
// name; here the recorded file name is the tail of relativePath. Keep the id
|
|
||||||
// stable + unique via the tag, and include the relative path tail so two
|
|
||||||
// captures with the same tag (impossible in practice) still differ.
|
|
||||||
s.id = "cap-" + cap.uniqueTag + "-" + cap.relativePath;
|
s.id = "cap-" + cap.uniqueTag + "-" + cap.relativePath;
|
||||||
s.displayName = cap.displayName;
|
s.displayName = cap.displayName;
|
||||||
s.relativePath = cap.relativePath; // project-relative (invariant)
|
s.relativePath = cap.relativePath;
|
||||||
s.sourceMode = cap.sourceMode;
|
s.sourceMode = cap.sourceMode;
|
||||||
s.sourceRange.startSeconds = cap.startSeconds;
|
s.sourceRange.startSeconds = cap.startSeconds;
|
||||||
s.sourceRange.endSeconds = cap.endSeconds;
|
s.sourceRange.endSeconds = cap.endSeconds;
|
||||||
// PPQ/beats deferred (musical-placement concern) — identical to the offline path.
|
// PPQ/beats deferred (musical-placement concern), as offline.
|
||||||
s.wetDry = cap.wetDry;
|
s.wetDry = cap.wetDry;
|
||||||
s.trackGuids = cap.trackGuids;
|
s.trackGuids = cap.trackGuids;
|
||||||
s.channelCount = cap.channelCount;
|
s.channelCount = cap.channelCount;
|
||||||
s.sampleRate = cap.sampleRate; // 0 when project rate was unknown
|
s.sampleRate = cap.sampleRate; // 0 when project rate was unknown
|
||||||
s.lengthSeconds = cap.endSeconds - cap.startSeconds;
|
s.lengthSeconds = cap.endSeconds - cap.startSeconds;
|
||||||
s.captureTempo = cap.captureTempo;
|
s.captureTempo = cap.captureTempo;
|
||||||
s.captureTimeSigNum = cap.captureTimeSigNum; // L7 F1 meter stamp (0/0 = unstamped)
|
s.captureTimeSigNum = cap.captureTimeSigNum; // 0/0 = unstamped
|
||||||
s.captureTimeSigDenom = cap.captureTimeSigDenom;
|
s.captureTimeSigDenom = cap.captureTimeSigDenom;
|
||||||
s.tier = Tier::Scratch; // captures land in scratch by default
|
s.tier = Tier::Scratch;
|
||||||
// contentHash set by the caller (capture_realtime.cpp) after the file is
|
// contentHash is left empty: this mapping runs before the file exists on
|
||||||
// finalized and on disk — the hash is over the finished file bytes. Left empty
|
// disk; the shell patches the hash in after the move+trim.
|
||||||
// here because sampleFromRecordedCapture runs before the file exists (the
|
// rootNote/loop left empty: a realtime record of wet output isn't a single
|
||||||
// mapping is pure / DAW-free); the shell patches it in after the move+trim.
|
// played note, so no root note is derivable; loop points are a later action.
|
||||||
// Phase S seam fields (rootNote / loop) left empty (D-B) — same reasoning as the
|
|
||||||
// offline path: a realtime record of wet output is not a single played note, so
|
|
||||||
// no root note is derivable; loop points are set by a later explicit action.
|
|
||||||
s.createdTimestamp = cap.createdTimestamp;
|
s.createdTimestamp = cap.createdTimestamp;
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
@@ -72,20 +58,15 @@ RecordPhase advanceRecordPhase(RecordPhase current,
|
|||||||
double rangeEndSeconds) {
|
double rangeEndSeconds) {
|
||||||
switch (current) {
|
switch (current) {
|
||||||
case RecordPhase::Recording: {
|
case RecordPhase::Recording: {
|
||||||
// Transport stopped while we still expected to be recording -> the user
|
// Stopped early (user or REAPER) -> finalize what was captured so far.
|
||||||
// (or REAPER) stopped early. Move to the flush wait and finalize whatever
|
|
||||||
// was captured up to the stop.
|
|
||||||
if (!inputs.transport.recording) return RecordPhase::Finalizing;
|
if (!inputs.transport.recording) return RecordPhase::Finalizing;
|
||||||
|
|
||||||
// Reached the range end (latency-compensated play position). >= (not >)
|
// >= (not >): a cursor landing exactly on the end completes.
|
||||||
// so a cursor landing exactly on the end completes.
|
|
||||||
if (inputs.transport.playPosition >= rangeEndSeconds)
|
if (inputs.transport.playPosition >= rangeEndSeconds)
|
||||||
return RecordPhase::Finalizing;
|
return RecordPhase::Finalizing;
|
||||||
|
|
||||||
// Self-defense (review §3): the transport is running but the play cursor
|
// Self-defense: a stuck/looping transport that never reaches end would
|
||||||
// is not advancing to the end (stuck / looping). Without this the machine
|
// otherwise stay in Recording forever, leaking the temp track + armed sink.
|
||||||
// stays in Recording forever, leaking the temp track + armed sink. Force
|
|
||||||
// the flush wait once wall-clock exceeds the nominal duration + margin.
|
|
||||||
const double ceiling =
|
const double ceiling =
|
||||||
(rangeEndSeconds - rangeStartSeconds) + kRecordMarginSeconds;
|
(rangeEndSeconds - rangeStartSeconds) + kRecordMarginSeconds;
|
||||||
if (inputs.elapsedSeconds > ceiling) return RecordPhase::Finalizing;
|
if (inputs.elapsedSeconds > ceiling) return RecordPhase::Finalizing;
|
||||||
@@ -94,22 +75,16 @@ RecordPhase advanceRecordPhase(RecordPhase current,
|
|||||||
}
|
}
|
||||||
|
|
||||||
case RecordPhase::Finalizing: {
|
case RecordPhase::Finalizing: {
|
||||||
// The transport is stopped; wait for REAPER to flush/close the recorded
|
// Moving the file before it's stable would race REAPER's flush and
|
||||||
// take on the audio thread. Finalize (move + Sample) only once the file
|
// yield a truncated/missing capture.
|
||||||
// exists AND is stable (review §2) — moving it early races the flush and
|
|
||||||
// yields a truncated / missing capture.
|
|
||||||
if (inputs.fileReady) return RecordPhase::Done;
|
if (inputs.fileReady) return RecordPhase::Done;
|
||||||
|
|
||||||
// Bound the wait: a file that never stabilizes fails cleanly rather than
|
|
||||||
// hanging the in-flight state for the session.
|
|
||||||
if (inputs.finalizingSeconds > kFinalizeFlushCeilingSeconds)
|
if (inputs.finalizingSeconds > kFinalizeFlushCeilingSeconds)
|
||||||
return RecordPhase::Failed;
|
return RecordPhase::Failed;
|
||||||
|
|
||||||
return RecordPhase::Finalizing;
|
return RecordPhase::Finalizing;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal phases are sticky: once the verdict is in, a later tick (a stray
|
|
||||||
// extra call before the shell has finished tearing down) must not flip it.
|
|
||||||
case RecordPhase::Done:
|
case RecordPhase::Done:
|
||||||
case RecordPhase::Failed:
|
case RecordPhase::Failed:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,27 +1,11 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// capture_realtime — the REAPER-free logic behind the realtime-record backend (M8).
|
// capture_realtime — the REAPER-free logic behind the realtime-record backend.
|
||||||
// (Renamed from realtime_record in Q-W3 — the Q-9 naming rider: the PURE module
|
// The shell drives the transport, temp track, send routing, and file move; the
|
||||||
// takes the stem, the shell takes the suffix — capture_realtime_shell.cpp /
|
// pure pieces split out here and unit-tested outside the DAW are: (1) record-
|
||||||
// capture_realtime_finalize.cpp — matching the drag_out ↔ drag_out_win model.)
|
// mode bookkeeping — scope + FX-tap point -> I_RECMODE/I_RECMODE_FLAGS values
|
||||||
//
|
// (bit MEANINGS transcribed verbatim from reaper_plugin_functions.h ~2197-2198;
|
||||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
// the CHOICE of value per scope is this module's tested logic) — and (2) the
|
||||||
// vendor/ includes. Standard library only. The realtime shell drives the
|
// recorded-file -> Sample mapping (mirrors OfflineRenderBackend's population).
|
||||||
// transport, the temp track, the send routing, and the file move —
|
|
||||||
// all REAPER-bound and DAW-verified. The genuinely pure, easy-to-get-wrong
|
|
||||||
// pieces are split out here and unit-tested outside the DAW:
|
|
||||||
//
|
|
||||||
// 1. the record-mode/recipe bookkeeping: given a capture scope + a desired
|
|
||||||
// FX-tap point (post-fader / pre-FX / post-FX-pre-fader), the I_RECMODE and
|
|
||||||
// I_RECMODE_FLAGS integer values the temp track must carry.
|
|
||||||
// 2. the recorded-file -> Sample mapping: given a finished capture (the
|
|
||||||
// recorded file's project-relative path + the request's own bounds/format),
|
|
||||||
// the populated Sample handed to bank_model. Mirrors the inline Sample
|
|
||||||
// population OfflineRenderBackend does — factored out so it is tested once,
|
|
||||||
// without a DAW, and shared shape with the offline path is guaranteed.
|
|
||||||
//
|
|
||||||
// The I_RECMODE / I_RECMODE_FLAGS bit MEANINGS are transcribed verbatim from
|
|
||||||
// reaper_plugin_functions.h line ~2197-2198 (see kRecMode* constants); the CHOICE
|
|
||||||
// of which values each scope needs is this module's logic and is tested.
|
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -35,26 +19,17 @@ using model::Sample;
|
|||||||
using model::Tier;
|
using model::Tier;
|
||||||
using model::SourceMode;
|
using model::SourceMode;
|
||||||
|
|
||||||
// --- I_RECMODE values (verbatim from SDK header ~2197) -----------------------
|
// I_RECMODE (verbatim from SDK header ~2197): 0=input, 1=stereo out, 2=none,
|
||||||
//
|
// 3=stereo out w/latency comp, 4=midi output, 5=mono out, 6=mono out w/latency
|
||||||
// I_RECMODE : int * : record mode, 0=input, 1=stereo out, 2=none,
|
// comp, 7=midi overdub, 8=midi replace. We record a track's OUTPUT, latency-
|
||||||
// 3=stereo out w/latency compensation, 4=midi output, 5=mono out,
|
// compensated, so the recorded file lines up sample-accurately with the source.
|
||||||
// 6=mono out w/ latency compensation, 7=midi overdub, 8=midi replace.
|
|
||||||
//
|
|
||||||
// We record a track's OUTPUT (the scoped signal routed into the temp track),
|
|
||||||
// latency-compensated, so the recorded file lines up sample-accurately with the
|
|
||||||
// source. Stereo vs mono is chosen by the request's channel count.
|
|
||||||
inline constexpr int kRecModeStereoOutLatComp = 3; // stereo out w/latency comp
|
inline constexpr int kRecModeStereoOutLatComp = 3; // stereo out w/latency comp
|
||||||
inline constexpr int kRecModeMonoOutLatComp = 6; // mono out w/latency comp
|
inline constexpr int kRecModeMonoOutLatComp = 6; // mono out w/latency comp
|
||||||
|
|
||||||
// --- I_RECMODE_FLAGS values (verbatim from SDK header ~2198) ------------------
|
// I_RECMODE_FLAGS (verbatim from SDK header ~2198): &3=output recording mode
|
||||||
//
|
// (0=post fader, 1=pre-fx, 2=post-fx/pre-fader). This is the only documented
|
||||||
// I_RECMODE_FLAGS : int * : record mode flags, &3=output recording mode
|
// pre-FX tap in the SDK — offline render has no pre-FX bit — so the realtime
|
||||||
// (0=post fader, 1=pre-fx, 2=post-fx/pre-fader).
|
// backend is the true pre-FX "dry" path.
|
||||||
//
|
|
||||||
// This is the ONLY documented pre-FX tap in the whole SDK — offline render has no
|
|
||||||
// pre-FX bit (see render_settings.h note + the M10 null-test note in PLAN.md).
|
|
||||||
// The realtime backend is therefore the true pre-FX "dry" path.
|
|
||||||
inline constexpr int kRecOutPostFader = 0; // &3==0: post-fader (fully wet)
|
inline constexpr int kRecOutPostFader = 0; // &3==0: post-fader (fully wet)
|
||||||
inline constexpr int kRecOutPreFx = 1; // &3==1: pre-FX (true dry)
|
inline constexpr int kRecOutPreFx = 1; // &3==1: pre-FX (true dry)
|
||||||
inline constexpr int kRecOutPostFxPreFader = 2; // &3==2: post-FX, pre-fader
|
inline constexpr int kRecOutPostFxPreFader = 2; // &3==2: post-FX, pre-fader
|
||||||
@@ -68,41 +43,34 @@ enum class OutputTap {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// The concrete record-mode values a temp track must carry to capture the scoped
|
// The concrete record-mode values a temp track must carry to capture the scoped
|
||||||
// output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated); `recModeFlags`
|
// output. `recMode` sets I_RECMODE (stereo/mono, latency-compensated);
|
||||||
// sets the &3 output-recording tap bits (higher bits are left at their default 0
|
// `recModeFlags` sets the &3 output-recording tap bits (we only own those bits).
|
||||||
// here — we only own the tap-point bits).
|
|
||||||
struct RecordModePlan {
|
struct RecordModePlan {
|
||||||
int recMode = kRecModeStereoOutLatComp;
|
int recMode = kRecModeStereoOutLatComp;
|
||||||
int recModeFlags = kRecOutPostFader;
|
int recModeFlags = kRecOutPostFader;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Maps (channelCount, tap) to the record-mode values.
|
// Maps (channelCount, tap) to the record-mode values: channelCount <= 1 ->
|
||||||
// channelCount <= 1 -> mono-out latency-comp; otherwise stereo-out latency-comp.
|
// mono-out latency-comp, else stereo-out; tap -> the &3 bits. The shell applies
|
||||||
// tap -> the &3 output-recording bits.
|
// these via SetMediaTrackInfo_Value(I_RECMODE / I_RECMODE_FLAGS).
|
||||||
// Pure so the "which I_RECMODE for N channels + this tap" rule is unit-tested
|
|
||||||
// without a DAW; the shell reads the request and applies these via
|
|
||||||
// SetMediaTrackInfo_Value(I_RECMODE / I_RECMODE_FLAGS).
|
|
||||||
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap);
|
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap);
|
||||||
|
|
||||||
// Maps a wetDry value to the output tap point. 1.0 (fully wet) -> PostFader; any
|
// Maps a wetDry value to the output tap point: 1.0 (fully wet) -> PostFader,
|
||||||
// value < 1.0 -> PreFx (true dry — the realtime backend's distinguishing
|
// anything less -> PreFx (true dry — the realtime backend's distinguishing
|
||||||
// capability). Kept pure + separate from recordModePlanFor so the wet/dry ->
|
// capability over offline render). PostFxPreFader is not reachable from wetDry.
|
||||||
// tap decision is tested on its own; PostFxPreFader is not selected by wetDry
|
|
||||||
// (it is an explicit future option, not on the wet/dry axis).
|
|
||||||
OutputTap outputTapForWetDry(double wetDry);
|
OutputTap outputTapForWetDry(double wetDry);
|
||||||
|
|
||||||
// --- Recorded-file -> Sample mapping ----------------------------------------
|
// --- Recorded-file -> Sample mapping ----------------------------------------
|
||||||
//
|
|
||||||
// The inputs a finished realtime capture yields, gathered by the shell into a
|
// The inputs a finished realtime capture yields, gathered by the shell into a
|
||||||
// pure struct so the Sample population is a single tested transform (mirror of
|
// pure struct so Sample population is a single tested transform (mirrors the
|
||||||
// the inline population in OfflineRenderBackend::capture).
|
// inline population in OfflineRenderBackend::capture).
|
||||||
struct RecordedCapture {
|
struct RecordedCapture {
|
||||||
// Project-relative path of the recorded file (relative-paths-only invariant;
|
// Project-relative path of the recorded file (the shell resolves REAPER's
|
||||||
// the shell resolves REAPER's recorded absolute path back to project-relative).
|
// absolute path back to project-relative).
|
||||||
std::string relativePath;
|
std::string relativePath;
|
||||||
|
|
||||||
// The disambiguating tag that named the file (feeds the Sample id, so id and
|
// The disambiguating tag that named the file (feeds the Sample id).
|
||||||
// file name stay consistent — same discipline as the offline path).
|
|
||||||
std::string uniqueTag;
|
std::string uniqueTag;
|
||||||
|
|
||||||
// Echoed from the request (exact bounds — no re-measuring the file).
|
// Echoed from the request (exact bounds — no re-measuring the file).
|
||||||
@@ -115,60 +83,41 @@ struct RecordedCapture {
|
|||||||
|
|
||||||
int channelCount = 0;
|
int channelCount = 0;
|
||||||
|
|
||||||
// TEST-ONLY / dead in production (Q-W3 review follow-up): the shell no longer
|
// Left at defaults here — capture_realtime_finalize.cpp calls
|
||||||
// populates these five fields before calling sampleFromRecordedCapture — the
|
// stampCaptureSample(result.sample, ...) afterward, overwriting these five
|
||||||
// finalize path (capture_realtime_finalize.cpp) leaves them at their defaults
|
// from the live project. Kept because the pure unit tests still assert them.
|
||||||
// and instead calls the shared stampCaptureSample(result.sample, ...) right
|
|
||||||
// after, which writes Sample::sampleRate/captureTempo/captureTimeSigNum/
|
|
||||||
// captureTimeSigDenom/createdTimestamp directly, overwriting whatever
|
|
||||||
// sampleFromRecordedCapture set from these. Kept (not deleted) because the pure
|
|
||||||
// unit tests still construct/assert them directly; removing the fields is a
|
|
||||||
// struct-shape decision out of scope here.
|
|
||||||
int sampleRate = 0; // 0 when the project rate was unknown (as offline)
|
int sampleRate = 0; // 0 when the project rate was unknown (as offline)
|
||||||
double captureTempo = 0.0; // BPM at capture time (shell reads Master_GetTempo)
|
double captureTempo = 0.0; // BPM at capture time
|
||||||
// Time signature at capture start (L7 F1; shell reads TimeMap_GetTimeSigAtTime).
|
int captureTimeSigNum = 0; // 0/0 = unstamped
|
||||||
// 0/0 = unstamped (matches the Sample default; formatter renders a blank read-out).
|
|
||||||
int captureTimeSigNum = 0;
|
|
||||||
int captureTimeSigDenom = 0;
|
int captureTimeSigDenom = 0;
|
||||||
std::int64_t createdTimestamp = 0; // unix epoch seconds (shell reads the clock)
|
std::int64_t createdTimestamp = 0; // unix epoch seconds
|
||||||
};
|
};
|
||||||
|
|
||||||
// Builds the Sample for a finished realtime capture. Deliberately identical in
|
// Builds the Sample for a finished realtime capture: exact request bounds,
|
||||||
// shape to OfflineRenderBackend's population: exact request bounds (no rounding),
|
// scratch tier, empty content hash, lengthSeconds = end - start. PPQ/beats
|
||||||
// scratch tier, empty content hash (does not dedup), lengthSeconds = end - start.
|
// left 0 (deferred, as offline).
|
||||||
// PPQ/beats are left 0 (a musical-placement concern deferred exactly as offline).
|
|
||||||
Sample sampleFromRecordedCapture(const RecordedCapture& cap);
|
Sample sampleFromRecordedCapture(const RecordedCapture& cap);
|
||||||
|
|
||||||
// --- Async record-phase state machine (M8 rework) ----------------------------
|
// --- Async record-phase state machine ----------------------------------------
|
||||||
//
|
//
|
||||||
// A realtime record spans many timer ticks (CSurf_OnRecord starts the transport on
|
// A realtime record spans many timer ticks (CSurf_OnRecord starts the transport
|
||||||
// REAPER's audio thread and returns immediately — it does NOT block until the range
|
// on REAPER's audio thread and returns immediately — it does not block until the
|
||||||
// completes). The completion decision — "given where the transport is now, should
|
// range completes). The completion decision — keep waiting, stop-and-flush,
|
||||||
// the tick keep waiting, stop-and-flush, finalize, or give up?" — is pure and
|
// finalize, or give up — is pure and unit-tested without a DAW; the shell only
|
||||||
// exactly the kind of off-by-one/edge logic a unit test locks without a DAW. It is
|
// reads the transport/clock/file and applies the verdict.
|
||||||
// factored out here; the REAPER shell only reads the transport/clock/file and applies
|
|
||||||
// the verdict (stop, wait for the file to flush, then finalize/abort + restore).
|
|
||||||
//
|
//
|
||||||
// The lifecycle has TWO waits, not one:
|
// Two waits, not one:
|
||||||
// 1. the RECORD wait (Recording): the transport is running; we wait for the play
|
// 1. RECORD wait (Recording): transport running; wait for the play cursor to
|
||||||
// cursor to reach the range end — OR the user stops early — OR a wall-clock
|
// reach the range end, OR the user stops early, OR a wall-clock safety
|
||||||
// safety ceiling trips (a started-but-never-advancing transport, §3 of review).
|
// ceiling trips (a started-but-never-advancing transport).
|
||||||
// 2. the FLUSH wait (Finalizing): the transport is stopped but REAPER closes/flushes
|
// 2. FLUSH wait (Finalizing): transport stopped but REAPER closes/flushes the
|
||||||
// the recorded take on the AUDIO thread — the file may not be fully written/closed
|
// recorded take on the audio thread — the file may lag a tick or two.
|
||||||
// for a tick or two. We defer the file move until the file exists AND is stable
|
// Defer the move until the file exists AND is stable, bounded by a flush
|
||||||
// (§2 of review), bounded by a flush ceiling so a file that never appears fails
|
// ceiling so a file that never appears fails cleanly instead of hanging.
|
||||||
// cleanly rather than hanging.
|
|
||||||
|
|
||||||
// Where an in-progress capture is in its lifecycle.
|
// Where an in-progress capture is in its lifecycle: Recording (live, transport
|
||||||
// Recording — live: transport running, shell keeps ticking.
|
// running) and Finalizing (live-but-stopped, waiting for flush) are the two
|
||||||
// Finalizing — live-but-stopped: transport halted, shell stops the transport once
|
// waits above; Done/Failed are terminal — the shell's verdict to act on.
|
||||||
// then ticks waiting for the recorded file to flush/stabilize.
|
|
||||||
// Done — terminal: the file is flushed + stable, finalize (move + Sample) now.
|
|
||||||
// Failed — terminal: the flush ceiling tripped without a stable file — give up
|
|
||||||
// (RenderFailed) + restore. (A record that produced NO file at all also
|
|
||||||
// lands here via the shell's finalize returning RenderFailed.)
|
|
||||||
// Only Recording and Finalizing are live phases the shell advances per tick; Done and
|
|
||||||
// Failed are the shell's verdict to act on (finalize-or-fail, then restore).
|
|
||||||
enum class RecordPhase {
|
enum class RecordPhase {
|
||||||
Recording,
|
Recording,
|
||||||
Finalizing,
|
Finalizing,
|
||||||
@@ -176,63 +125,34 @@ enum class RecordPhase {
|
|||||||
Failed
|
Failed
|
||||||
};
|
};
|
||||||
|
|
||||||
// A distilled transport reading for the pure transition, so the state machine never
|
// A distilled transport reading so the state machine never touches a REAPER
|
||||||
// touches a REAPER type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition`
|
// type. `recording` is (GetPlayStateEx & 4) != 0; `playPosition` is
|
||||||
// is GetPlayPositionEx (latency-compensated what-you-hear position).
|
// GetPlayPositionEx (latency-compensated).
|
||||||
struct TransportReading {
|
struct TransportReading {
|
||||||
bool recording = false;
|
bool recording = false;
|
||||||
double playPosition = 0.0;
|
double playPosition = 0.0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Everything the pure transition needs beyond the current phase, gathered by the
|
// Everything the pure transition needs beyond the current phase, gathered by
|
||||||
// shell each tick so the machine stays REAPER-free AND owns every timing/ceiling
|
// the shell each tick (the shell only reads and reports; never decides).
|
||||||
// decision (the shell only reads and reports; it never decides a transition itself).
|
|
||||||
struct RecordTickInputs {
|
struct RecordTickInputs {
|
||||||
TransportReading transport;
|
TransportReading transport;
|
||||||
|
double elapsedSeconds = 0.0; // wall-clock since begin() — record ceiling
|
||||||
// Wall-clock seconds since begin() (the shell reads a steady clock). Drives the
|
double finalizingSeconds = 0.0; // wall-clock in Finalizing — flush ceiling
|
||||||
// record safety ceiling: a transport that starts but never advances to the range
|
bool fileReady = false; // recorded file exists+stable (Finalizing only)
|
||||||
// end (stuck / looping) would otherwise keep the machine in Recording forever.
|
|
||||||
double elapsedSeconds = 0.0;
|
|
||||||
|
|
||||||
// Wall-clock seconds spent in the Finalizing phase (since the transport stop).
|
|
||||||
// Drives the flush ceiling: bound the deferred-finalize wait so a file that never
|
|
||||||
// stabilizes fails cleanly instead of hanging.
|
|
||||||
double finalizingSeconds = 0.0;
|
|
||||||
|
|
||||||
// Whether the recorded take's file exists AND is stable/closed this tick (the
|
|
||||||
// shell resolves the take source path and checks size-stable-across-a-tick).
|
|
||||||
// Only consulted in Finalizing.
|
|
||||||
bool fileReady = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Safety ceilings (named constants, review §2/§3) -------------------------
|
// Record ceiling margin added to nominal duration: generous enough that
|
||||||
//
|
// pre-roll/count-in/latency never trips it, tight enough a stuck transport is
|
||||||
// kRecordMarginSeconds: added to the record's nominal duration (end - start) to form
|
// force-terminated within seconds.
|
||||||
// the record wall-clock ceiling. Generous so a normal record (with pre-roll, count-in,
|
|
||||||
// or transport latency) never trips it; tight enough that a stuck transport is force-
|
|
||||||
// terminated within a few seconds of overrun.
|
|
||||||
inline constexpr double kRecordMarginSeconds = 5.0;
|
inline constexpr double kRecordMarginSeconds = 5.0;
|
||||||
|
|
||||||
// kFinalizeFlushCeilingSeconds: the max wall-clock the Finalizing phase waits for the
|
// Max wall-clock Finalizing waits for the file to flush/stabilize before
|
||||||
// recorded file to flush/stabilize before giving up (RenderFailed). REAPER closes the
|
// giving up (REAPER closes the take within a tick or two in practice).
|
||||||
// take on the audio thread within a tick or two in practice; this is a generous bound.
|
|
||||||
inline constexpr double kFinalizeFlushCeilingSeconds = 5.0;
|
inline constexpr double kFinalizeFlushCeilingSeconds = 5.0;
|
||||||
|
|
||||||
// The pure transition: given the current phase, this tick's inputs, and the record
|
// The pure transition (total + deterministic). Done/Failed are sticky — a late
|
||||||
// range end, return the next phase. Total + deterministic.
|
// tick before teardown finishes cannot flip the verdict (the idempotence the
|
||||||
//
|
|
||||||
// From Recording:
|
|
||||||
// * recording AND cursor < end AND under the record ceiling -> Recording (wait)
|
|
||||||
// * recording AND cursor >= end -> Finalizing (reached end)
|
|
||||||
// * NOT recording -> Finalizing (stopped early)
|
|
||||||
// * recording BUT over the record ceiling (end-start+margin)-> Finalizing (stuck: forced)
|
|
||||||
// From Finalizing:
|
|
||||||
// * fileReady -> Done (flushed + stable)
|
|
||||||
// * over the flush ceiling without a stable file -> Failed (give up)
|
|
||||||
// * otherwise -> Finalizing (keep flushing)
|
|
||||||
// Done and Failed are sticky: feeding a terminal phase back returns it unchanged, so a
|
|
||||||
// late tick before teardown finishes cannot flip the verdict (the idempotence the
|
|
||||||
// shell's single-restore relies on).
|
// shell's single-restore relies on).
|
||||||
RecordPhase advanceRecordPhase(RecordPhase current,
|
RecordPhase advanceRecordPhase(RecordPhase current,
|
||||||
const RecordTickInputs& inputs,
|
const RecordTickInputs& inputs,
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ namespace reasampler::capture {
|
|||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
// Base target bits (mode&3). We use only 0 (current track) and 1 (new track).
|
// Base target bits (mode&3). We use only 0 (current track) and 1 (new track).
|
||||||
constexpr int kBaseCurrentTrack = 0; // add to current track
|
constexpr int kBaseCurrentTrack = 0;
|
||||||
constexpr int kBaseNewTrack = 1; // add new track
|
constexpr int kBaseNewTrack = 1;
|
||||||
|
|
||||||
// Tempo-conform bits, verbatim from the header doc-comment.
|
// Tempo-conform bits, verbatim from the header doc-comment.
|
||||||
constexpr int kMatchTempo1x = 8; // &8: try to match tempo 1x
|
constexpr int kMatchTempo1x = 8;
|
||||||
constexpr int kMatchTempoHalf = 16; // &16: try to match tempo 0.5x
|
constexpr int kMatchTempoHalf = 16;
|
||||||
constexpr int kMatchTempoDbl = 32; // &32: try to match tempo 2x
|
constexpr int kMatchTempoDbl = 32;
|
||||||
constexpr int kDontPreservePitch = 64; // &64: don't preserve pitch when matching tempo
|
constexpr int kDontPreservePitch = 64;
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
@@ -24,8 +24,7 @@ int computeInsertMode(const InsertOptions& opts) {
|
|||||||
|
|
||||||
switch (opts.conform) {
|
switch (opts.conform) {
|
||||||
case TempoConform::None:
|
case TempoConform::None:
|
||||||
// No tempo bits: native length, no stretch. (Also never &4.)
|
return mode; // native length, no stretch; never &4
|
||||||
return mode;
|
|
||||||
case TempoConform::Ratio1x:
|
case TempoConform::Ratio1x:
|
||||||
mode |= kMatchTempo1x;
|
mode |= kMatchTempo1x;
|
||||||
break;
|
break;
|
||||||
@@ -37,9 +36,7 @@ int computeInsertMode(const InsertOptions& opts) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tempo bits are set (conform != None). Add the pitch-shift bit only when the
|
// Reached only when a tempo bit is set (None already returned above).
|
||||||
// caller asked NOT to preserve pitch. When conform == None we already returned
|
|
||||||
// above, so this can never fire without a tempo bit present.
|
|
||||||
if (!opts.preservePitch)
|
if (!opts.preservePitch)
|
||||||
mode |= kDontPreservePitch;
|
mode |= kDontPreservePitch;
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,32 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// insert_plan — the REAPER-free logic behind the `insert` shell (M6): computing
|
// insert_plan — the REAPER-free logic behind the `insert` shell: computing the
|
||||||
// the InsertMedia `mode` bitmask from a small options struct.
|
// InsertMedia `mode` bitmask from a small options struct.
|
||||||
//
|
//
|
||||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
||||||
// vendor/ includes. Standard library only. The one genuinely testable-outside-DAW
|
// only. The InsertMedia bitfield is easy to get wrong and its bits are
|
||||||
// piece of insert is the mode-bit arithmetic — the InsertMedia bitfield is easy to
|
// load-bearing for the "no silent time-stretch" invariant, so it's factored here
|
||||||
// get wrong and its bits are load-bearing for the "no silent time-stretch"
|
// and unit-tested. The REAPER-bound placement (InsertMedia call, edit-cursor
|
||||||
// invariant, so it is factored here and unit-tested. The REAPER-bound placement
|
// movement, undo block) lives in insert.cpp and is DAW-verified.
|
||||||
// (InsertMedia call, edit-cursor movement, undo block) lives in insert.cpp and is
|
|
||||||
// DAW-verified.
|
|
||||||
//
|
//
|
||||||
// The bit meanings below are transcribed VERBATIM from the authoritative header
|
// Bit meanings below are transcribed VERBATIM from the authoritative header
|
||||||
// doc-comment (vendor/reaper-sdk/sdk/reaper_plugin_functions.h, InsertMedia):
|
// doc-comment (vendor/reaper-sdk/sdk/reaper_plugin_functions.h, InsertMedia):
|
||||||
// mode: 0=add to current track, 1=add new track, 3=add to selected items as
|
// mode: 0=add to current track, 1=add new track, 3=add to selected items as
|
||||||
// takes, &4=stretch/loop to fit time sel, &8=try to match tempo 1x,
|
// takes, &4=stretch/loop to fit time sel, &8=try to match tempo 1x,
|
||||||
// &16=try to match tempo 0.5x, &32=try to match tempo 2x,
|
// &16=try to match tempo 0.5x, &32=try to match tempo 2x,
|
||||||
// &64=don't preserve pitch when matching tempo, ...
|
// &64=don't preserve pitch when matching tempo, ...
|
||||||
// We intentionally use only the base target (0/1) and the tempo-conform bits
|
// We use only the base target (0/1) and the tempo-conform bits (&8/&16/&32/&64).
|
||||||
// (&8/&16/&32/&64). We NEVER set &4 (stretch/loop to fit time selection) — that is
|
// We NEVER set &4 (stretch/loop to fit time selection) — the silent-time-stretch
|
||||||
// the silent-time-stretch path the tool forbids (CONTEXT.md §Non-goals).
|
// path the tool forbids.
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
||||||
namespace reasampler::capture {
|
namespace reasampler::capture {
|
||||||
|
|
||||||
// Where InsertMedia drops the item. Maps to the low bits of `mode` (mode&3).
|
// Where InsertMedia drops the item. Maps to the low bits of `mode` (mode&3).
|
||||||
// We expose only the two placement targets M6 needs; "add as takes" (3) is a
|
// We expose only the two placement targets needed here; "add as takes" (3) is
|
||||||
// later concern (YAGNI). Both insert AT THE EDIT CURSOR — that is REAPER's
|
// out of scope. Both insert at the edit cursor — REAPER's convention for base
|
||||||
// convention for base modes 0/1 (the header names no explicit edit-cursor bit;
|
// modes 0/1 (the header names no explicit edit-cursor bit; see the flagged
|
||||||
// see the flagged runtime assumption in insert.cpp).
|
// runtime assumption in insert.cpp).
|
||||||
enum class InsertTarget {
|
enum class InsertTarget {
|
||||||
NewTrack, // mode base 1: add a new track for the item
|
NewTrack, // mode base 1: add a new track for the item
|
||||||
CurrentTrack, // mode base 0: add to the current/selected track
|
CurrentTrack, // mode base 0: add to the current/selected track
|
||||||
|
|||||||
@@ -10,9 +10,7 @@
|
|||||||
namespace reasampler::capture {
|
namespace reasampler::capture {
|
||||||
|
|
||||||
double autoTrimEndRatio() {
|
double autoTrimEndRatio() {
|
||||||
// Amplitude ratio = 10^(dB/20). Derived from kAutoTrimThresholdDb so the dB is
|
// Amplitude ratio = 10^(dB/20) (header ~3062). For -72 dB this is ~0.00025119.
|
||||||
// the single source of truth (header ~3062: RENDER_TRIMEND is an amplitude ratio,
|
|
||||||
// "0.5 means -6.02 dB"). For -72 dB this is ~= 0.00025119.
|
|
||||||
return std::pow(10.0, kAutoTrimThresholdDb / 20.0);
|
return std::pow(10.0, kAutoTrimThresholdDb / 20.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,8 +18,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
|
|||||||
TailRenderSettings t;
|
TailRenderSettings t;
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case TailMode::None:
|
case TailMode::None:
|
||||||
// Exact bounds — byte-identical to the pre-tail no-tail capture. Tail off,
|
// Exact bounds — byte-identical to the pre-tail capture.
|
||||||
// disable-all normalize (the current default), no trim.
|
|
||||||
t.tailFlag = kTailFlagNone;
|
t.tailFlag = kTailFlagNone;
|
||||||
t.tailMs = 0.0;
|
t.tailMs = 0.0;
|
||||||
t.normalize = kNormalizeDisableAll;
|
t.normalize = kNormalizeDisableAll;
|
||||||
@@ -29,12 +26,10 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
|
|||||||
return t;
|
return t;
|
||||||
|
|
||||||
case TailMode::Auto:
|
case TailMode::Auto:
|
||||||
// Generous 8 s tail, then SURGICAL normalize: ONLY the trim-ending-silence
|
// Surgical normalize: only the trim-ending-silence bit set, every other
|
||||||
// bit (32768) — every other postprocessing bit clear. A fixed-threshold
|
// postprocessing bit clear. A fixed-threshold trim scales/limits/fades
|
||||||
// trailing-silence trim is a pure boundary decision (it scales/limits/fades
|
// nothing, so identical requests trim at the identical sample -> holds
|
||||||
// nothing), so it re-introduces none of the coloring the disable-all bit
|
// the bit-identical-repeats invariant.
|
||||||
// guarded against, and two identical requests trim at the identical sample
|
|
||||||
// -> bit-identical repeats hold (spec §surgical normalize).
|
|
||||||
t.tailFlag = kTailFlagCustomBounds;
|
t.tailFlag = kTailFlagCustomBounds;
|
||||||
t.tailMs = kMaxTailMs;
|
t.tailMs = kMaxTailMs;
|
||||||
t.normalize = kNormalizeTrimEnd;
|
t.normalize = kNormalizeTrimEnd;
|
||||||
@@ -42,10 +37,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
|
|||||||
return t;
|
return t;
|
||||||
|
|
||||||
case TailMode::Manual:
|
case TailMode::Manual:
|
||||||
// Fixed tail, no trim -> keep the disable-all normalize exactly as the
|
// Clamped to the cap regardless of source; negative floors to 0.
|
||||||
// no-tail path does. Clamp to the 8 s cap even here: the runaway guard
|
|
||||||
// applies whether the length came from the Auto default or an explicit
|
|
||||||
// request (spec §Manual override). Negative requests floor to 0.
|
|
||||||
t.tailFlag = kTailFlagCustomBounds;
|
t.tailFlag = kTailFlagCustomBounds;
|
||||||
t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs);
|
t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs);
|
||||||
t.normalize = kNormalizeDisableAll;
|
t.normalize = kNormalizeDisableAll;
|
||||||
@@ -60,14 +52,10 @@ double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
|
|||||||
double manualTailMs) {
|
double manualTailMs) {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case TailMode::None:
|
case TailMode::None:
|
||||||
// Exact — no extra recording (byte-identical to today's realtime capture).
|
return rangeEndSeconds; // exact, no extra recording
|
||||||
return rangeEndSeconds;
|
|
||||||
case TailMode::Auto:
|
case TailMode::Auto:
|
||||||
// The 8 s runaway cap past the range end; the decay-trim shortens it later.
|
return rangeEndSeconds + kMaxTailSeconds; // runaway cap; decay-trim shortens later
|
||||||
return rangeEndSeconds + kMaxTailSeconds;
|
|
||||||
case TailMode::Manual:
|
case TailMode::Manual:
|
||||||
// Fixed window: range + the set length, clamped to the 8 s cap (the same
|
|
||||||
// runaway guard the offline Manual path applies). Negative floors to 0.
|
|
||||||
return rangeEndSeconds + std::clamp(manualTailMs, 0.0, kMaxTailMs) / 1000.0;
|
return rangeEndSeconds + std::clamp(manualTailMs, 0.0, kMaxTailMs) / 1000.0;
|
||||||
}
|
}
|
||||||
// Unreachable for a valid enum; fail closed to exact bounds (never a stray tail).
|
// Unreachable for a valid enum; fail closed to exact bounds (never a stray tail).
|
||||||
@@ -75,42 +63,36 @@ double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
|
|||||||
}
|
}
|
||||||
|
|
||||||
RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
||||||
// `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future
|
// wetDry doesn't affect this mapping (seam for future dry work); FX scoping
|
||||||
// dry work (M10 null test), but it does not affect this mapping. FX scoping is
|
// is handled by fxBypassPlanFor, not by these render bits.
|
||||||
// handled by fxBypassPlanFor, not by these render bits.
|
|
||||||
RenderSettingsChoice c;
|
RenderSettingsChoice c;
|
||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case SourceMode::MasterMix:
|
case SourceMode::MasterMix:
|
||||||
case SourceMode::TimeSelection:
|
case SourceMode::TimeSelection:
|
||||||
// Master IS the mix — wet-only; &(1|2)==0, no source bits.
|
c.settings = kRenderMasterMix; // wet-only, no source bits
|
||||||
c.settings = kRenderMasterMix;
|
|
||||||
c.supported = true;
|
c.supported = true;
|
||||||
return c;
|
return c;
|
||||||
|
|
||||||
case SourceMode::SelectedTracks:
|
case SourceMode::SelectedTracks:
|
||||||
// Selected tracks via master (&128) — wet (post-FX). Header ~3041.
|
|
||||||
c.settings = kRenderSelTracksViaMaster;
|
c.settings = kRenderSelTracksViaMaster;
|
||||||
c.supported = true;
|
c.supported = true;
|
||||||
return c;
|
return c;
|
||||||
|
|
||||||
case SourceMode::SelectedItems:
|
case SourceMode::SelectedItems:
|
||||||
// Selected media items, rendered to ONE file (single-file bit) so a
|
// Single-file bit so a multi-item selection yields one bank entry.
|
||||||
// multi-item selection yields a single bank entry, not N wavs.
|
|
||||||
c.settings = kRenderSelItems | kRenderSingleFile;
|
c.settings = kRenderSelItems | kRenderSingleFile;
|
||||||
c.supported = true;
|
c.supported = true;
|
||||||
return c;
|
return c;
|
||||||
|
|
||||||
case SourceMode::RazorArea:
|
case SourceMode::RazorArea:
|
||||||
// Render razor edits to ONE file (same single-file rationale as items).
|
|
||||||
c.settings = kRenderRazorEdits | kRenderSingleFile;
|
c.settings = kRenderRazorEdits | kRenderSingleFile;
|
||||||
c.supported = true;
|
c.supported = true;
|
||||||
return c;
|
return c;
|
||||||
|
|
||||||
case SourceMode::Realtime:
|
case SourceMode::Realtime:
|
||||||
// Not an offline-render source — the realtime backend (M8) owns it.
|
|
||||||
c.settings = kRenderMasterMix;
|
c.settings = kRenderMasterMix;
|
||||||
c.supported = false;
|
c.supported = false; // not an offline-render source
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
// Unreachable for a valid enum; fail closed (unsupported) rather than render.
|
// Unreachable for a valid enum; fail closed (unsupported) rather than render.
|
||||||
@@ -135,17 +117,13 @@ FxBypassPlan fxBypassPlanFor(CaptureScope scope) {
|
|||||||
FxBypassPlan p;
|
FxBypassPlan p;
|
||||||
switch (scope) {
|
switch (scope) {
|
||||||
case CaptureScope::Item:
|
case CaptureScope::Item:
|
||||||
// Item = take/item FX ONLY. Bypass the item's own track FX, every
|
// Take FX live in the item and are always rendered — bypass everything else.
|
||||||
// ancestor's FX, and the master's FX. (Take FX live in the item and
|
|
||||||
// are always rendered — there is no track to bypass them from.)
|
|
||||||
p.bypassSelfFx = true;
|
p.bypassSelfFx = true;
|
||||||
p.bypassAncestorFx = true;
|
p.bypassAncestorFx = true;
|
||||||
p.bypassMaster = true;
|
p.bypassMaster = true;
|
||||||
return p;
|
return p;
|
||||||
case CaptureScope::Track:
|
case CaptureScope::Track:
|
||||||
// Track = item FX + the selected track's OWN FX. Keep self FX; bypass
|
// Keep self FX; bypass every ancestor (parent/folder) and the master.
|
||||||
// every ancestor (parent/folder) and the master. Parent/master GAIN
|
|
||||||
// still applies (I_FXEN is FX-only) — documented boundary.
|
|
||||||
p.bypassSelfFx = false;
|
p.bypassSelfFx = false;
|
||||||
p.bypassAncestorFx = true;
|
p.bypassAncestorFx = true;
|
||||||
p.bypassMaster = true;
|
p.bypassMaster = true;
|
||||||
@@ -158,24 +136,19 @@ std::vector<RazorRange> parseRazorEdits(const std::string& razorString) {
|
|||||||
std::vector<RazorRange> ranges;
|
std::vector<RazorRange> ranges;
|
||||||
std::istringstream in(razorString);
|
std::istringstream in(razorString);
|
||||||
|
|
||||||
// The string is space-separated TRIPLES: <start> <end> <envGuidString>.
|
|
||||||
// A track-audio area's third token is the literal two-char string `""`; an
|
|
||||||
// envelope-lane area's is a GUID `{…}`. We keep only track-audio triples.
|
|
||||||
std::string startTok, endTok, guidTok;
|
std::string startTok, endTok, guidTok;
|
||||||
while (in >> startTok >> endTok >> guidTok) {
|
while (in >> startTok >> endTok >> guidTok) {
|
||||||
// Envelope-lane areas carry a real GUID; skip them (razor captures track audio only).
|
// Skip envelope-lane areas (real GUID); keep only track-audio (`""`).
|
||||||
// A track-audio area's GUID token is the empty quoted string `""`.
|
|
||||||
if (guidTok != "\"\"") continue;
|
if (guidTok != "\"\"") continue;
|
||||||
|
|
||||||
// Parse the two time tokens. std::stod throws on garbage — guard so one
|
// std::stod throws on garbage — guard so one malformed triple doesn't
|
||||||
// malformed triple does not abort the whole parse.
|
// abort the whole parse.
|
||||||
double start = 0.0, end = 0.0;
|
double start = 0.0, end = 0.0;
|
||||||
try {
|
try {
|
||||||
std::size_t sp = 0, ep = 0;
|
std::size_t sp = 0, ep = 0;
|
||||||
start = std::stod(startTok, &sp);
|
start = std::stod(startTok, &sp);
|
||||||
end = std::stod(endTok, &ep);
|
end = std::stod(endTok, &ep);
|
||||||
// Reject tokens with trailing garbage (e.g. "1.0x") — a partial parse
|
// Reject trailing garbage (e.g. "1.0x") — a partial parse is malformed.
|
||||||
// is a malformed area, not a valid range.
|
|
||||||
if (sp != startTok.size() || ep != endTok.size()) continue;
|
if (sp != startTok.size() || ep != endTok.size()) continue;
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
continue;
|
continue;
|
||||||
@@ -197,23 +170,13 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const std::vector<CaptureActionDef>& captureActionTable() {
|
const std::vector<CaptureActionDef>& captureActionTable() {
|
||||||
// Built once (function-local static): two SCOPE actions, item + track. Both
|
// FOREVER-STABLE ids — never edit a shipped string. No master capture
|
||||||
// exact bounds by default; the tail mode a capture applies is read from the
|
// action (its id was retired; do not reintroduce it).
|
||||||
// docked-panel setting at fire time (tail_control + bank_panel), so tail is NOT
|
|
||||||
// a per-action variant. Ids are FOREVER-STABLE — never edit a shipped string.
|
|
||||||
// Each action infers its range (razor-else-time) at fire time and enforces its
|
|
||||||
// FX-scope invariant via fxBypassPlanFor. The M7 CAPTURE_TRACKS_WET /
|
|
||||||
// CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids are RETIRED (mirror-unregistered in
|
|
||||||
// main.cpp); the CAPTURE_MASTER scope action is REMOVED (its id is likewise
|
|
||||||
// mirror-unregistered) — to capture the master you render a track.
|
|
||||||
static const std::vector<CaptureActionDef> table = {
|
static const std::vector<CaptureActionDef> table = {
|
||||||
// Item scope — item/take FX only. Suffix + phrase are channel-agnostic; the shell
|
|
||||||
// composes the FOREVER-STABLE id (prefix + "CAPTURE_ITEM") and the display name.
|
|
||||||
{"CAPTURE_ITEM",
|
{"CAPTURE_ITEM",
|
||||||
"capture selected item(s)", "item",
|
"capture selected item(s)", "item",
|
||||||
CaptureScope::Item},
|
CaptureScope::Item},
|
||||||
|
|
||||||
// Track scope — item FX + the track's own FX.
|
|
||||||
{"CAPTURE_TRACK",
|
{"CAPTURE_TRACK",
|
||||||
"capture selected track(s)", "track",
|
"capture selected track(s)", "track",
|
||||||
CaptureScope::Track},
|
CaptureScope::Track},
|
||||||
|
|||||||
@@ -1,26 +1,9 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// render_settings — the REAPER-free logic behind the capture action family.
|
// render_settings — the REAPER-free logic behind the capture action family:
|
||||||
//
|
// sourceMode -> RENDER_SETTINGS bits, P_RAZOREDITS parsing + range union,
|
||||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
// razor-else-time inference, the FX-scope bypass plan, and the capture-action
|
||||||
// vendor/ includes. Standard library only. The capture shell (capture.cpp) and
|
// table main.cpp iterates. Bit MEANINGS below are transcribed verbatim from
|
||||||
// action layer (main.cpp) read the actual DAW state (time selection, selected
|
// reaper_plugin_functions.h; the CHOICE of which bits each mode sets is tested.
|
||||||
// tracks/items, razor strings, the ancestor-track chain) and hand the raw values
|
|
||||||
// here so the genuinely-pure, easy-to-get-wrong pieces are unit-tested outside
|
|
||||||
// the DAW:
|
|
||||||
//
|
|
||||||
// 1. sourceMode -> the RENDER_SETTINGS integer bit value (wet only).
|
|
||||||
// 2. a P_RAZOREDITS string -> the list of (start,end) ranges + their union bound.
|
|
||||||
// 3. range inference: razor-present -> razor union, else time selection. Range
|
|
||||||
// is a SOURCE choice orthogonal to the capture scope.
|
|
||||||
// 4. the FX-scope bypass plan: given a scope + an ancestor-chain length, which
|
|
||||||
// tracks' FX to bypass so each scope hears only the FX it should (the M7
|
|
||||||
// "items captured through parent FX" defect is corrected here).
|
|
||||||
// 5. the capture-action table (id string, description, scope) — the taxonomy,
|
|
||||||
// in one place so main.cpp iterates it instead of hand-listing.
|
|
||||||
//
|
|
||||||
// The RENDER_SETTINGS bit MEANINGS are transcribed verbatim from
|
|
||||||
// reaper_plugin_functions.h line ~3041 (see kRender* constants); the CHOICE of
|
|
||||||
// which bits each source mode sets is this module's logic and is tested.
|
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -32,67 +15,44 @@ namespace reasampler::capture {
|
|||||||
using model::SourceMode;
|
using model::SourceMode;
|
||||||
|
|
||||||
// --- RENDER_SETTINGS source/processing bits (verbatim from SDK header ~3041) --
|
// --- RENDER_SETTINGS source/processing bits (verbatim from SDK header ~3041) --
|
||||||
//
|
|
||||||
// Only the bits this module actually uses are named. Values are the documented bit
|
|
||||||
// weights; the DOC of each is the SDK header's, not a guess.
|
|
||||||
inline constexpr int kRenderMasterMix = 0; // (&(1|2))==0, no source bits
|
inline constexpr int kRenderMasterMix = 0; // (&(1|2))==0, no source bits
|
||||||
inline constexpr int kRenderSelItems = 32; // &32 selected media items
|
inline constexpr int kRenderSelItems = 32; // &32 selected media items
|
||||||
inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master
|
inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master
|
||||||
inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master
|
inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master
|
||||||
inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits
|
inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits
|
||||||
// NOTE: kRenderPreFaderStems (&8192) is NOT used. REAPER offline render has no
|
// kRenderPreFaderStems (&8192) is deliberately NOT used — REAPER offline render
|
||||||
// true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-render
|
// has no true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-render
|
||||||
// mechanism (see fxBypassPlan below) — bypassing the FX-enable of the tracks that
|
// mechanism (see fxBypassPlan below), not by any render bit. All capture actions
|
||||||
// fall outside a scope — NOT by any render bit. All capture actions render wet
|
// render wet; the scope decides which FX remain enabled.
|
||||||
// (post the FX that remain enabled); the scope decides which FX remain enabled.
|
|
||||||
inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file
|
inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file
|
||||||
|
|
||||||
// --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ----------
|
// --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ----------
|
||||||
//
|
//
|
||||||
// The capture-tail feature (docs/product/capture-tail.md) preserves reverb/release
|
// Every offline capture renders custom-time-bounds, so &1 (RENDER_TAILFLAG,
|
||||||
// decay past the range end. Every offline capture renders custom-time-bounds, so
|
// header ~3047) is the only tail-flag bit that ever applies. RENDER_NORMALIZE
|
||||||
// the only tail-flag bit that ever applies is &1 (RENDER_TAILFLAG, header ~3047).
|
// (verbatim, header ~3051): &32768 = trim ending silence (Auto path);
|
||||||
// These values are the pure part — mode -> (RENDER_* values) — unit-tested outside
|
// &(4<<16) = disable all render postprocessing (None/Manual path).
|
||||||
// the DAW exactly like renderSettingsFor; the backend just applies them.
|
|
||||||
//
|
|
||||||
// RENDER_NORMALIZE bit meanings (verbatim from SDK header ~3051):
|
|
||||||
// &32768 = trim ending silence (the surgical Auto path)
|
|
||||||
// &(4<<16) = disable all render postprocessing (the None/Manual path)
|
|
||||||
inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence
|
inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence
|
||||||
inline constexpr int kNormalizeDisableAll = (4 << 16); // &(4<<16) = 262144, disable all
|
inline constexpr int kNormalizeDisableAll = (4 << 16); // &(4<<16) = 262144, disable all
|
||||||
|
|
||||||
// RENDER_TAILFLAG &1 = apply tail for custom time bounds (header ~3047). We render
|
|
||||||
// custom bounds unconditionally, so this is the only tail bit that ever applies.
|
|
||||||
inline constexpr int kTailFlagNone = 0;
|
inline constexpr int kTailFlagNone = 0;
|
||||||
inline constexpr int kTailFlagCustomBounds = 1; // &1
|
inline constexpr int kTailFlagCustomBounds = 1; // &1, header ~3047
|
||||||
|
|
||||||
// Auto-trim trailing-silence threshold. -72 dB is quiet enough that the trimmed
|
// Auto-trim trailing-silence threshold; single source of truth (RENDER_TRIMEND
|
||||||
// region is inaudible decay, loud enough to not chase a reverb's infinite noise
|
// ratio derives from this dB, never the reverse). Daniel-set.
|
||||||
// floor. Daniel-set. Single source of truth: the RENDER_TRIMEND ratio derives from
|
|
||||||
// this dB, never the reverse.
|
|
||||||
inline constexpr double kAutoTrimThresholdDb = -72.0;
|
inline constexpr double kAutoTrimThresholdDb = -72.0;
|
||||||
|
|
||||||
// Max tail rendered past the range end. The runaway guard: a non-decaying or
|
// Runaway guard: max tail rendered past the range end, so a non-decaying or
|
||||||
// looping signal never crosses the trim threshold, so this caps the render.
|
// looping signal doesn't render forever. Daniel-set; shared by offline+realtime.
|
||||||
// Daniel-set. Shared by the offline (T1) and future realtime (T2) tail paths.
|
|
||||||
inline constexpr double kMaxTailSeconds = 8.0;
|
inline constexpr double kMaxTailSeconds = 8.0;
|
||||||
inline constexpr double kMaxTailMs = 8000.0;
|
inline constexpr double kMaxTailMs = 8000.0;
|
||||||
|
|
||||||
// Derived linear amplitude ratio for RENDER_TRIMEND. The header (~3062) documents
|
// Derived linear amplitude ratio for RENDER_TRIMEND (header ~3062: an amplitude
|
||||||
// RENDER_TRIMEND as an amplitude ratio ("0.5 means -6.02 dB"), i.e. 10^(dB/20).
|
// ratio, "0.5 means -6.02 dB", i.e. 10^(dB/20)) from kAutoTrimThresholdDb.
|
||||||
// Derived from kAutoTrimThresholdDb so the dB stays the single source of truth and
|
// Function not constant: std::pow isn't constexpr before C++26.
|
||||||
// a future config change to the dB does not require hand-recomputing the ratio.
|
|
||||||
//
|
|
||||||
// std::pow is not constexpr before C++26, so this is a function, not a constant.
|
|
||||||
// For -72 dB: 10^(-72/20) = 10^(-3.6) ~= 0.00025119 (the value the DAW confirm targets).
|
|
||||||
double autoTrimEndRatio();
|
double autoTrimEndRatio();
|
||||||
|
|
||||||
// The three tail states (docs/product/capture-tail.md §The three tail states):
|
// The three tail states — see src/core/capture/CLAUDE.md.
|
||||||
// None — exact bounds, no tail. Byte-identical to the pre-tail capture. The
|
|
||||||
// default and the ONLY mode for null-test / verify captures.
|
|
||||||
// Auto — generous 8 s tail then trim trailing silence to -72 dB (surgical
|
|
||||||
// normalize). The user-facing tail-on option (panel toggle).
|
|
||||||
// Manual — a fixed tail length (clamped to the 8 s cap), no trim.
|
|
||||||
enum class TailMode {
|
enum class TailMode {
|
||||||
None,
|
None,
|
||||||
Auto,
|
Auto,
|
||||||
@@ -100,9 +60,9 @@ enum class TailMode {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// The RENDER_* values a tail mode drives, in addition to the exact STARTPOS/ENDPOS
|
// The RENDER_* values a tail mode drives, in addition to the exact STARTPOS/ENDPOS
|
||||||
// the backend already sets. `trimEnd` is meaningful only when the trim-end normalize
|
// the backend already sets. `trimEnd` is meaningful only when the trim-end
|
||||||
// bit is set (Auto); it is 0 otherwise. This is the pure mapping — the backend reads
|
// normalize bit is set (Auto). The backend reads these straight onto
|
||||||
// these four fields straight onto GetSetProjectInfo.
|
// GetSetProjectInfo.
|
||||||
struct TailRenderSettings {
|
struct TailRenderSettings {
|
||||||
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1)
|
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1)
|
||||||
double tailMs = 0.0; // RENDER_TAILMS
|
double tailMs = 0.0; // RENDER_TAILMS
|
||||||
@@ -110,54 +70,36 @@ struct TailRenderSettings {
|
|||||||
double trimEnd = 0.0; // RENDER_TRIMEND (only used when trim bit set)
|
double trimEnd = 0.0; // RENDER_TRIMEND (only used when trim bit set)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Maps a tail mode (+ the requested manual tail ms) to its RENDER_* values.
|
// Maps a tail mode (+ requested manual tail ms, used only for Manual) to its
|
||||||
// `manualTailMs` is used ONLY for TailMode::Manual (ignored otherwise). Manual is
|
// RENDER_* values. Manual is clamped to kMaxTailMs regardless of source.
|
||||||
// clamped to kMaxTailMs — the runaway guard applies whether the length came from
|
|
||||||
// the Auto default or an explicit request (spec §Manual override). Pure + tested.
|
|
||||||
TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs);
|
TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs);
|
||||||
|
|
||||||
// The REALTIME record-window end (in project seconds) a tail mode records to, given
|
// The realtime record-window end (project seconds): realtime does NOT drive
|
||||||
// the request's exact range end (docs/product/capture-tail.md §The realtime path).
|
// RENDER_*, it records a generous window and trims later, so this is where the
|
||||||
// Realtime does NOT drive RENDER_*; it records a generous window and trims later, so
|
// transport actually stops. None -> exact rangeEndSeconds; Auto -> +8s runaway
|
||||||
// the window end is where the transport actually stops:
|
// cap; Manual -> + clamp(manualTailMs, kMaxTailMs)/1000.
|
||||||
// None -> rangeEndSeconds (exact — no extra recording).
|
|
||||||
// Auto -> rangeEndSeconds + kMaxTailSeconds (the 8 s runaway cap; trimmed later).
|
|
||||||
// Manual -> rangeEndSeconds + clamp(manualTailMs, kMaxTailMs)/1000 (fixed, no trim).
|
|
||||||
// `manualTailMs` is used ONLY for Manual. Pure so the mode->window arithmetic (and
|
|
||||||
// the Manual clamp) is unit-tested outside the DAW; the backend applies the returned
|
|
||||||
// end to the record time selection. Shared -72 dB / 8 s constants are the same ones
|
|
||||||
// the offline tail uses (single source of truth).
|
|
||||||
double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
|
double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
|
||||||
double manualTailMs);
|
double manualTailMs);
|
||||||
|
|
||||||
// The RENDER_SETTINGS value for a given source mode. `supported` is false only
|
// The RENDER_SETTINGS value for a given source mode. `supported` is false only
|
||||||
// for SourceMode::Realtime (that is the M8 backend, not offline render).
|
// for SourceMode::Realtime (that backend doesn't use offline render).
|
||||||
struct RenderSettingsChoice {
|
struct RenderSettingsChoice {
|
||||||
int settings = kRenderMasterMix;
|
int settings = kRenderMasterMix;
|
||||||
bool supported = true; // false => not an offline-render source (e.g. Realtime)
|
bool supported = true; // false => not an offline-render source (e.g. Realtime)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Maps a source mode to its RENDER_SETTINGS value (which content the render
|
// Maps a source mode to its RENDER_SETTINGS value (which content the render
|
||||||
// covers). FX scoping is orthogonal — done by fxBypassPlan, not by these bits.
|
// covers); FX scoping is orthogonal (done by fxBypassPlan). `wetDry` is
|
||||||
// `wetDry` is accepted but ignored for the mapping — retained in CaptureRequest
|
// accepted but ignored — retained as the seam for future dry work. CONFIRMED
|
||||||
// as the seam for future dry work (M10 null test).
|
// (SDK header ~3041): MasterMix/TimeSelection -> 0; SelectedTracks -> &128;
|
||||||
//
|
// SelectedItems -> &32|single-file; RazorArea -> &4096|single-file.
|
||||||
// CONFIRMED (SDK header ~3041):
|
|
||||||
// MasterMix / TimeSelection -> master mix (0).
|
|
||||||
// SelectedTracks -> &128 selected tracks via master.
|
|
||||||
// SelectedItems -> &32 | single-file (one wav, not one-per-item).
|
|
||||||
// RazorArea -> &4096| single-file.
|
|
||||||
RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry);
|
RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry);
|
||||||
|
|
||||||
// --- Capture scope: the FX-scope invariant (Daniel, critical) ----------------
|
// --- Capture scope: the FX-scope invariant ------------------------------------
|
||||||
//
|
//
|
||||||
// Two FX scopes. The render RANGE (razor-else-time) is orthogonal to the scope.
|
// See src/core/capture/CLAUDE.md for the scope contract. There is NO master
|
||||||
// Item -> item/take FX ONLY (no track, no parent/folder, no master FX).
|
// scope; the master track's FX/gain/pan are still NEUTRALIZED as part of the
|
||||||
// Track -> item FX + the selected track's OWN track FX (no parent/folder/master).
|
// out-of-scope chain (bypassMaster below) — master is a bypass target only.
|
||||||
// There is NO master scope: to capture the master you render a track instead. The
|
|
||||||
// master track's FX/gain/pan are still NEUTRALIZED as part of the out-of-scope
|
|
||||||
// chain for both item and track captures (bypassMaster below) — master is a
|
|
||||||
// bypass target, not a capture scope.
|
|
||||||
enum class CaptureScope {
|
enum class CaptureScope {
|
||||||
Item,
|
Item,
|
||||||
Track,
|
Track,
|
||||||
@@ -169,34 +111,28 @@ SourceMode sourceModeForScope(CaptureScope scope);
|
|||||||
|
|
||||||
// --- Range inference: razor-else-time (orthogonal to scope) -------------------
|
// --- Range inference: razor-else-time (orthogonal to scope) -------------------
|
||||||
//
|
//
|
||||||
// Every scope action infers its render range the same way: if a razor area is
|
// Razor-present -> razor union; otherwise time selection. Razor is a range
|
||||||
// present, use the razor union; otherwise use the time selection. Razor is a
|
// source, not a capture mode.
|
||||||
// range SOURCE, not a capture mode (the M7 four-mode model conflated them).
|
|
||||||
enum class RangeSource {
|
enum class RangeSource {
|
||||||
Razor, // a razor area is present -> use its union bound
|
Razor, // a razor area is present -> use its union bound
|
||||||
TimeSelection, // no razor -> use the time selection
|
TimeSelection, // no razor -> use the time selection
|
||||||
};
|
};
|
||||||
|
|
||||||
// Picks the range source. Pure so the "razor wins when present" rule is tested
|
// Picks the range source. Pure so "razor wins when present" is tested without
|
||||||
// without a DAW; the shell supplies whether any razor area was found.
|
// a DAW; the shell supplies whether any razor area was found.
|
||||||
RangeSource inferRangeSource(bool hasRazorArea);
|
RangeSource inferRangeSource(bool hasRazorArea);
|
||||||
|
|
||||||
// --- FX-bypass plan: which tracks' FX to bypass for a scope -------------------
|
// --- FX-bypass plan: which tracks' FX to bypass for a scope -------------------
|
||||||
//
|
//
|
||||||
// Given a CaptureScope, returns three boolean flags: whether to bypass (a) the
|
// Given a CaptureScope, returns three boolean flags: bypass (a) the captured
|
||||||
// captured track's OWN FX, (b) each of its ancestor (parent/folder) tracks' FX,
|
// track's OWN FX, (b) every ancestor (parent/folder) track's FX, (c) the
|
||||||
// and (c) the master FX. The caller (FxBypassGuard) resolves these flags to
|
// master FX. The caller (FxBypassGuard, shell) walks the ancestor chain via
|
||||||
// concrete MediaTrack* by walking the ancestor chain via GetParentTrack and
|
// GetParentTrack, clears I_FXEN on each flagged track (RAII restore), and also
|
||||||
// clears I_FXEN on each flagged track, snapshotting first (RAII restore).
|
// neutralizes D_VOL/D_PAN/D_WIDTH/D_PANLAW/I_PANMODE to unity/center on the
|
||||||
//
|
// same set (I_PANMODE is load-bearing: in pan mode 6, D_PAN/D_WIDTH are
|
||||||
// SCOPE BOUNDARY: I_FXEN bypasses a track's FX plugins but NOT its volume/pan.
|
// ignored entirely, so forcing it is what makes the other neutralizations
|
||||||
// The guard (FxBypassGuard, main.cpp) therefore ALSO neutralizes the fader GAIN
|
// take effect) — I_FXEN alone doesn't touch a track's volume/pan. This plan
|
||||||
// (D_VOL -> unity) of every track in this same bypass set, so a Track/Item
|
// selects the set; the guard applies both the FX bypass and the neutralize.
|
||||||
// capture rendered via master does NOT bake in the parent/folder/master fader
|
|
||||||
// level (Daniel: the capture is likely re-routed through that chain later). PAN
|
|
||||||
// is deliberately left untouched (D_PAN is coupled to D_WIDTH/D_PANLAW — a clean
|
|
||||||
// neutralize is non-trivial; flagged as a follow-up, not half-done). This plan
|
|
||||||
// selects the SET; the guard applies both the FX bypass and the gain neutralize.
|
|
||||||
struct FxBypassPlan {
|
struct FxBypassPlan {
|
||||||
bool bypassSelfFx = false; // the captured track's own FX
|
bool bypassSelfFx = false; // the captured track's own FX
|
||||||
bool bypassAncestorFx = false; // every ancestor (parent/folder) track's FX
|
bool bypassAncestorFx = false; // every ancestor (parent/folder) track's FX
|
||||||
@@ -213,38 +149,24 @@ struct RazorRange {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Parses ONE track's P_RAZOREDITS string (SDK header ~2899): space-separated
|
// Parses ONE track's P_RAZOREDITS string (SDK header ~2899): space-separated
|
||||||
// TRIPLES of <start> <end> <envGuidString>. The envelope GUID is "" (an empty
|
// TRIPLES of <start> <end> <envGuidString>, envGuid == `""` for a track-audio
|
||||||
// quoted string, i.e. the literal two chars `""`) for a track-audio area and a
|
// area vs a GUID for an envelope-lane area. Returns only track-audio ranges
|
||||||
// GUID like {…} for an envelope-lane area.
|
// (envelope-lane triples skipped); malformed trailing tokens are ignored, not
|
||||||
//
|
// fatal; a range with end <= start is dropped.
|
||||||
// Returns only the track-audio ranges (envelope-lane triples are skipped — razor
|
|
||||||
// captures target track audio, not envelope lanes). Malformed/short trailing tokens are ignored, not fatal.
|
|
||||||
// A range with end <= start is dropped (no negative/empty areas leak through).
|
|
||||||
std::vector<RazorRange> parseRazorEdits(const std::string& razorString);
|
std::vector<RazorRange> parseRazorEdits(const std::string& razorString);
|
||||||
|
|
||||||
// The union bound (min start, max end) of a set of razor ranges — the exact
|
// The union bound (min start, max end) of a set of razor ranges — the exact
|
||||||
// window the offline render must cover so every area is inside the rendered file.
|
// window the offline render must cover. {0,0} for empty input ("no razor area").
|
||||||
// Returns {0,0} for an empty input (caller treats that as "no razor area").
|
|
||||||
RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges);
|
RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges);
|
||||||
|
|
||||||
// --- Capture-action taxonomy (the bindable set main.cpp registers) -----------
|
// --- Capture-action taxonomy (the bindable set main.cpp registers) -----------
|
||||||
//
|
//
|
||||||
// One row per bindable SCOPE action: item and track. The range each captures
|
// One row per bindable scope action (item/track); range inference and tail
|
||||||
// (razor-else-time) is inferred at fire time, not a mode. TAIL is NOT a per-action
|
// mode are read at fire time, not baked into the row. The row stores only the
|
||||||
// variant — the tail MODE (None/Auto/Manual) is a panel SETTING the capture reads
|
// channel-agnostic command-id SUFFIX + description PHRASE; the registering
|
||||||
// at fire time (see tail_control + bank_panel), so a single pair of actions covers
|
// shell composes the full channel-qualified id/name via app_version.
|
||||||
// every tail state. Bounded, discoverable, NO dialogs (the tool's no-clutter ethos).
|
|
||||||
//
|
//
|
||||||
// Phase V (V4): the row stores the channel-AGNOSTIC pieces — a command-id SUFFIX (the
|
// commandSuffix is FOREVER-STABLE (user keybindings key off the composed id).
|
||||||
// tail after the family prefix) and a description PHRASE (the label after the "ReaSampler:
|
|
||||||
// " lead). The registering shell composes the full, channel-qualified id/name via
|
|
||||||
// app_version's channelCommandId / channelActionName (commandIdPrefix + suffix /
|
|
||||||
// actionDisplayPrefix + phrase). This keeps the pure table free of any channel branch:
|
|
||||||
// stable rebuilds the exact shipped id "CEREBELLUM_REASAMPLER_CAPTURE_TRACK" from
|
|
||||||
// prefix + "CAPTURE_TRACK"; beta yields "CEREBELLUM_REASAMPLER_BETA_CAPTURE_TRACK".
|
|
||||||
//
|
|
||||||
// commandSuffix is FOREVER-STABLE (user keybindings key off the composed id) — never
|
|
||||||
// change a shipped value. baseName feeds the file stem (sanitized by capture_paths).
|
|
||||||
struct CaptureActionDef {
|
struct CaptureActionDef {
|
||||||
const char* commandSuffix; // e.g. "CAPTURE_TRACK" — FOREVER-STABLE (composed w/ prefix)
|
const char* commandSuffix; // e.g. "CAPTURE_TRACK" — FOREVER-STABLE (composed w/ prefix)
|
||||||
const char* descriptionPhrase; // e.g. "capture selected track(s)" — Actions-list phrase
|
const char* descriptionPhrase; // e.g. "capture selected track(s)" — Actions-list phrase
|
||||||
@@ -252,14 +174,11 @@ struct CaptureActionDef {
|
|||||||
CaptureScope scope; // FX scope (item / track)
|
CaptureScope scope; // FX scope (item / track)
|
||||||
};
|
};
|
||||||
|
|
||||||
// The capture-action table. Iterated by main.cpp to register the family and route
|
// The capture-action table. Iterated by main.cpp to register the family and
|
||||||
// each fired command back to its definition. Kept here (pure) so the taxonomy is
|
// route each fired command back to its definition.
|
||||||
// one testable list, not scattered registration code.
|
|
||||||
//
|
//
|
||||||
// Two rows: CAPTURE_ITEM / CAPTURE_TRACK. There is no master capture — to capture
|
// Two rows: CAPTURE_ITEM / CAPTURE_TRACK. There is no master capture — to
|
||||||
// the master you render a track. Razor is an inferred range, not a mode, and each
|
// capture the master you render a track.
|
||||||
// scope enforces its FX-scope invariant via fxBypassPlanFor. The tail mode each
|
|
||||||
// capture applies is read from the docked-panel setting, not baked into the row.
|
|
||||||
const std::vector<CaptureActionDef>& captureActionTable();
|
const std::vector<CaptureActionDef>& captureActionTable();
|
||||||
|
|
||||||
} // namespace reasampler::capture
|
} // namespace reasampler::capture
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// tail_control — pure implementation. See tail_control.h. NO REAPER / SWELL / vendor.
|
// tail_control — pure implementation. See tail_control.h.
|
||||||
|
|
||||||
#include "core/capture/tail_control.h"
|
#include "core/capture/tail_control.h"
|
||||||
|
|
||||||
@@ -19,14 +19,10 @@ TailMode cycleTailMode(TailMode current) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
double clampManualMs(double manualMs) {
|
double clampManualMs(double manualMs) {
|
||||||
// Same runaway guard the pure tailRenderSettingsFor applies to Manual: floor a
|
|
||||||
// negative request to 0, cap at the 8 s ceiling.
|
|
||||||
return std::clamp(manualMs, 0.0, kMaxTailMs);
|
return std::clamp(manualMs, 0.0, kMaxTailMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
double adjustManualMs(double current, int notches, double stepMs) {
|
double adjustManualMs(double current, int notches, double stepMs) {
|
||||||
// Clamp the stepped value so both scroll directions saturate at the bounds rather
|
|
||||||
// than running away (the same [0, kMaxTailMs] guard clampManualMs enforces).
|
|
||||||
return clampManualMs(current + notches * stepMs);
|
return clampManualMs(current + notches * stepMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +31,8 @@ std::string tailToggleLabel(const TailSetting& setting) {
|
|||||||
case TailMode::None: return "Tail: Off";
|
case TailMode::None: return "Tail: Off";
|
||||||
case TailMode::Auto: return "Tail: Auto";
|
case TailMode::Auto: return "Tail: Auto";
|
||||||
case TailMode::Manual: {
|
case TailMode::Manual: {
|
||||||
// Append the CLAMPED length in seconds to one decimal so the readout can
|
// Clamped so the readout can't show an over-cap value even if
|
||||||
// never show an over-cap value even if manualMs was stored past the cap.
|
// manualMs was stored past the cap.
|
||||||
const double seconds = clampManualMs(setting.manualMs) / 1000.0;
|
const double seconds = clampManualMs(setting.manualMs) / 1000.0;
|
||||||
char buf[32];
|
char buf[32];
|
||||||
std::snprintf(buf, sizeof(buf), "Tail: Manual %.1fs", seconds);
|
std::snprintf(buf, sizeof(buf), "Tail: Manual %.1fs", seconds);
|
||||||
@@ -46,17 +42,9 @@ std::string tailToggleLabel(const TailSetting& setting) {
|
|||||||
return "Tail: Off"; // unreachable for a valid enum; fail to the safe default
|
return "Tail: Off"; // unreachable for a valid enum; fail to the safe default
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// --- JSON round-trip ---------------------------------------------------------
|
||||||
// JSON round-trip
|
// manualMs round-trips exactly (json::numToStr uses the shortest %.17g-class
|
||||||
// ---------------------------------------------------------------------------
|
// form for doubles); deserialize returns nullopt on any parse failure.
|
||||||
//
|
|
||||||
// The setting is a flat object of one enum + one double, riding the shared
|
|
||||||
// core/json layer (Q-W1, T2-02: the former substring-scan valueAfterKey reader —
|
|
||||||
// the fifth hand-rolled JSON decoder — is retired). manualMs is emitted with 17
|
|
||||||
// significant digits (%.17g) — the shortest form that round-trips every IEEE-754
|
|
||||||
// double exactly — so deserialize(serialize(x)) == x holds bit-for-bit.
|
|
||||||
// deserialize stays forgiving in outcome: any parse failure returns nullopt so
|
|
||||||
// the caller falls back to a default, exactly as an absent ext-state key does.
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// tail_control — the REAPER-free logic behind the docked bank_panel's tail-mode
|
// tail_control — the REAPER-free logic behind the docked bank_panel's tail-mode
|
||||||
// toggle. The panel shell (shell/panel/) owns the SWELL window, LICE drawing, and
|
// toggle. The panel shell owns the SWELL window, LICE drawing, and click
|
||||||
// click hit-testing; what is NOT DAW-bound — the cycle order, the manual-length
|
// hit-testing; the cycle order, manual-length clamp, and label text live here.
|
||||||
// clamp, and the toggle's label text — lives here so it is unit-tested outside the
|
|
||||||
// DAW (CLAUDE.md §load-bearing split). Mirror of bank_grid / mode_switch.
|
|
||||||
//
|
|
||||||
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
|
|
||||||
// only (plus render_settings for the pure TailMode enum). Builds and unit-tests
|
|
||||||
// without REAPER.
|
|
||||||
|
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -16,54 +10,40 @@
|
|||||||
|
|
||||||
namespace reasampler::capture {
|
namespace reasampler::capture {
|
||||||
|
|
||||||
// The Manual-mode starting length. 2 s is a musically useful default tail (a bar of
|
// The Manual-mode starting length: 2s, a musically useful default (a bar of
|
||||||
// reverb throw at a moderate tempo) that is well under the 8 s cap. Also the value a
|
// reverb throw at moderate tempo), well under the 8s cap. Also the fallback
|
||||||
// project with no stored tail setting (older / never-adjusted) falls back to on load.
|
// for a project with no stored tail setting.
|
||||||
inline constexpr double kDefaultManualTailMs = 2000.0;
|
inline constexpr double kDefaultManualTailMs = 2000.0;
|
||||||
|
|
||||||
// The fine-adjust step per scroll-wheel notch in Manual mode. 250 ms is coarse enough
|
// Fine-adjust step per scroll-wheel notch in Manual mode. Daniel-set.
|
||||||
// that a few notches cover the useful range, fine enough to dial a length precisely.
|
|
||||||
// Daniel-set. The panel maps one wheel notch to +/- this many ms via adjustManualMs.
|
|
||||||
inline constexpr double kManualStepMs = 250.0;
|
inline constexpr double kManualStepMs = 250.0;
|
||||||
|
|
||||||
// The panel's current tail setting: the mode plus the length used ONLY when the
|
// The panel's current tail setting: mode + the length used only when Manual.
|
||||||
// mode is Manual. Held as in-memory panel/session state (shell/panel), default
|
// Default None so a capture with no explicit choice stays exact-bounds.
|
||||||
// None so a capture with no explicit choice stays exact-bounds / byte-identical to
|
// `manualMs` is clamped to kMaxTailMs before it ever reaches a CaptureRequest.
|
||||||
// today. `manualMs` is a stored default a future fine-adjust UI can tune; it is
|
|
||||||
// clamped to the 8 s cap (kMaxTailMs) before it ever reaches a CaptureRequest.
|
|
||||||
struct TailSetting {
|
struct TailSetting {
|
||||||
TailMode mode = TailMode::None;
|
TailMode mode = TailMode::None;
|
||||||
double manualMs = kDefaultManualTailMs;
|
double manualMs = kDefaultManualTailMs;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cycles the tail mode: None -> Auto -> Manual -> None. Pure so the wrap order is
|
// Cycles the tail mode: None -> Auto -> Manual -> None.
|
||||||
// pinned by a test and the panel's click handler owns no enum arithmetic of its own.
|
|
||||||
// An out-of-range value (unreachable for a valid enum) cycles back to None.
|
|
||||||
TailMode cycleTailMode(TailMode current);
|
TailMode cycleTailMode(TailMode current);
|
||||||
|
|
||||||
// The effective manual length a Manual capture uses: `manualMs` clamped to
|
// The effective manual length a Manual capture uses: clamped to [0, kMaxTailMs].
|
||||||
// [0, kMaxTailMs] (the runaway guard the pure tailRenderSettingsFor also applies).
|
// Exposed so the panel can show the clamped value. Meaningful only for Manual.
|
||||||
// Exposed so the panel can show the clamped value and main.cpp hands a pre-clamped
|
|
||||||
// tailMs into the CaptureRequest. Meaningful only for TailMode::Manual.
|
|
||||||
double clampManualMs(double manualMs);
|
double clampManualMs(double manualMs);
|
||||||
|
|
||||||
// Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped to
|
// Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped
|
||||||
// [0, kMaxTailMs]. Positive notches lengthen, negative shorten. Pure so the fine-adjust
|
// to [0, kMaxTailMs]. Meaningful only for TailMode::Manual.
|
||||||
// arithmetic (and its clamp at both bounds) is unit-tested; the panel wheel handler
|
|
||||||
// owns no arithmetic of its own. Meaningful only for TailMode::Manual.
|
|
||||||
double adjustManualMs(double current, int notches, double stepMs);
|
double adjustManualMs(double current, int notches, double stepMs);
|
||||||
|
|
||||||
// The toggle's label for a setting, e.g. "Tail: Off", "Tail: Auto". In Manual mode the
|
// The toggle's label, e.g. "Tail: Off", "Tail: Auto", or (Manual, clamped
|
||||||
// clamped length is appended in seconds to one decimal, e.g. "Tail: Manual 2.0s" —
|
// length to one decimal) "Tail: Manual 2.0s".
|
||||||
// Off/Auto carry no length. Pure so the exact strings (and the Manual format) are
|
|
||||||
// test-pinned, including the boundary lengths (0.0s, 8.0s).
|
|
||||||
std::string tailToggleLabel(const TailSetting& setting);
|
std::string tailToggleLabel(const TailSetting& setting);
|
||||||
|
|
||||||
// JSON round-trip of a TailSetting (mode + manualMs), for persist to store the tail
|
// JSON round-trip of a TailSetting, for persist to store per-project. Pure/
|
||||||
// setting per-project alongside the bank and view model. Kept pure/testable here —
|
// testable here, mirroring bank_model's serialize/deserialize; deserialize
|
||||||
// the natural home, mirroring bank_model's serialize/deserialize. serialize emits a
|
// returns nullopt on malformed input so the caller falls back to a default.
|
||||||
// compact object; deserialize returns std::nullopt on malformed input so the caller
|
|
||||||
// (persist) falls back to a default setting, exactly as an absent key does.
|
|
||||||
std::string serializeTailSetting(const TailSetting& setting);
|
std::string serializeTailSetting(const TailSetting& setting);
|
||||||
std::optional<TailSetting> deserializeTailSetting(const std::string& json);
|
std::optional<TailSetting> deserializeTailSetting(const std::string& json);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// wav_codec — pure implementation. See wav_codec.h. NO REAPER / SWELL / vendor.
|
// wav_codec — pure implementation. See wav_codec.h. The one RIFF chunk
|
||||||
//
|
// traversal lives here (nextWavChunk); layout parse and content hash both
|
||||||
// The ONE RIFF chunk traversal lives here (nextWavChunk); the layout parse and the
|
// walk with it, so their view of the container cannot drift.
|
||||||
// content hash both walk with it, so their view of the container cannot drift.
|
|
||||||
|
|
||||||
#include "core/capture/wav_codec.h"
|
#include "core/capture/wav_codec.h"
|
||||||
|
|
||||||
@@ -12,8 +11,7 @@ namespace reasampler::capture {
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
// Little-endian readers. Bounds are checked by the caller before each read; these
|
// Little-endian readers. Caller checks bounds before each read (off + N <= size).
|
||||||
// assume `off + N <= bytes.size()`. memcpy avoids alignment/aliasing UB.
|
|
||||||
std::uint16_t readU16LE(const std::vector<std::uint8_t>& b, std::size_t off) {
|
std::uint16_t readU16LE(const std::vector<std::uint8_t>& b, std::size_t off) {
|
||||||
return static_cast<std::uint16_t>(b[off] | (b[off + 1] << 8));
|
return static_cast<std::uint16_t>(b[off] | (b[off + 1] << 8));
|
||||||
}
|
}
|
||||||
@@ -28,7 +26,7 @@ bool tagEquals(const std::vector<std::uint8_t>& b, std::size_t off, const char*
|
|||||||
return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0;
|
return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// WAVE format tags we accept as 32-bit float (see wav_codec.h FORMAT ASSUMPTION).
|
// WAVE format tags we accept as 32-bit float (see wav_codec.h).
|
||||||
constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003;
|
constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003;
|
||||||
constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE;
|
constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE;
|
||||||
|
|
||||||
@@ -37,19 +35,16 @@ constexpr std::uint64_t kFnvOffsetBasis = 14695981039346656037ULL;
|
|||||||
constexpr std::uint64_t kFnvPrime = 1099511628211ULL;
|
constexpr std::uint64_t kFnvPrime = 1099511628211ULL;
|
||||||
|
|
||||||
std::string fnvHex(std::uint64_t h) {
|
std::string fnvHex(std::uint64_t h) {
|
||||||
// 16-digit lowercase hex (zero-padded) for a fixed-length string.
|
char buf[17]; // 16 hex digits, zero-padded
|
||||||
char buf[17];
|
|
||||||
std::snprintf(buf, sizeof(buf), "%016llx", static_cast<unsigned long long>(h));
|
std::snprintf(buf, sizeof(buf), "%016llx", static_cast<unsigned long long>(h));
|
||||||
return std::string(buf);
|
return std::string(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- The ONE RIFF chunk traversal --------------------------------------------
|
// --- The one RIFF chunk traversal --------------------------------------------
|
||||||
//
|
//
|
||||||
// One sub-chunk of a RIFF/WAVE container as the walk sees it: header at
|
// One sub-chunk of a RIFF/WAVE container: header at `headerOffset` (id(4) +
|
||||||
// `headerOffset` (id(4) + size(4)), body at `bodyOffset` with declared `bodySize`.
|
// size(4)), body at `bodyOffset`/`bodySize`. `bodyInBounds` false means the
|
||||||
// `bodyInBounds` is whether the declared body fits inside the buffer — a chunk
|
// declared body runs past the buffer — still reported, but must not be read.
|
||||||
// whose declared size lies past the end is still REPORTED (callers decide how to
|
|
||||||
// treat it) but its body must not be read.
|
|
||||||
struct WavChunkView {
|
struct WavChunkView {
|
||||||
std::size_t headerOffset = 0;
|
std::size_t headerOffset = 0;
|
||||||
std::size_t bodyOffset = 0;
|
std::size_t bodyOffset = 0;
|
||||||
@@ -60,9 +55,8 @@ struct WavChunkView {
|
|||||||
// Advances one chunk. `pos` starts at 12 (after "RIFF" size "WAVE"); each call
|
// Advances one chunk. `pos` starts at 12 (after "RIFF" size "WAVE"); each call
|
||||||
// fills `out` and moves `pos` past the chunk's body, honoring RIFF even-byte
|
// fills `out` and moves `pos` past the chunk's body, honoring RIFF even-byte
|
||||||
// padding. Returns false when no further chunk header fits. If the padded advance
|
// padding. Returns false when no further chunk header fits. If the padded advance
|
||||||
// would overrun the buffer, the chunk is still reported (return true) and `pos` is
|
// would overrun the buffer, the chunk is still reported (return true) and `pos`
|
||||||
// parked past the end so the NEXT call returns false — exactly the process-then-
|
// is parked past the end so the next call returns false.
|
||||||
// break shape the pre-consolidation walkers shared.
|
|
||||||
bool nextWavChunk(const std::vector<std::uint8_t>& bytes, std::size_t& pos,
|
bool nextWavChunk(const std::vector<std::uint8_t>& bytes, std::size_t& pos,
|
||||||
WavChunkView& out) {
|
WavChunkView& out) {
|
||||||
if (pos + 8 > bytes.size()) return false;
|
if (pos + 8 > bytes.size()) return false;
|
||||||
@@ -100,8 +94,8 @@ WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
|
|||||||
std::uint32_t sampleRate = 0;
|
std::uint32_t sampleRate = 0;
|
||||||
std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible
|
std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible
|
||||||
|
|
||||||
// Walk the sub-chunks after "WAVE" (offset 12) with the shared traversal. A
|
// Walk the sub-chunks after "WAVE" (offset 12). A malformed/truncated file
|
||||||
// malformed/truncated file is "invalid", never an OOB read.
|
// is "invalid", never an OOB read.
|
||||||
std::size_t pos = 12;
|
std::size_t pos = 12;
|
||||||
WavChunkView c;
|
WavChunkView c;
|
||||||
while (nextWavChunk(bytes, pos, c)) {
|
while (nextWavChunk(bytes, pos, c)) {
|
||||||
@@ -112,11 +106,9 @@ WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
|
|||||||
channels = readU16LE(bytes, c.bodyOffset + 2);
|
channels = readU16LE(bytes, c.bodyOffset + 2);
|
||||||
sampleRate = readU32LE(bytes, c.bodyOffset + 4);
|
sampleRate = readU32LE(bytes, c.bodyOffset + 4);
|
||||||
bitsPerSample = readU16LE(bytes, c.bodyOffset + 14);
|
bitsPerSample = readU16LE(bytes, c.bodyOffset + 14);
|
||||||
// For WAVE_FORMAT_EXTENSIBLE (0xFFFE), read the SubFormat GUID's leading
|
// WAVE_FORMAT_EXTENSIBLE: the real format lives in the SubFormat GUID's
|
||||||
// 2-byte tag at body offset 24 to distinguish float (0x0003) from PCM
|
// leading 2-byte tag at body offset 24, not in fmtTag itself. Body must
|
||||||
// integer (0x0001) and all other sub-formats. Body must be >= 40 bytes to
|
// reach offset 24+16; otherwise leave the tag at 0 (rejected).
|
||||||
// reach GUID offset 24 + 16 bytes of GUID, and the full GUID must fit in
|
|
||||||
// the buffer; otherwise we leave extensibleSubFormatTag at 0 (rejected).
|
|
||||||
if (fmtTag == kWaveFormatExtensible) {
|
if (fmtTag == kWaveFormatExtensible) {
|
||||||
if (c.bodySize >= 40 && c.bodyOffset + 40 <= bytes.size()) {
|
if (c.bodySize >= 40 && c.bodyOffset + 40 <= bytes.size()) {
|
||||||
extensibleSubFormatTag = readU16LE(bytes, c.bodyOffset + 24);
|
extensibleSubFormatTag = readU16LE(bytes, c.bodyOffset + 24);
|
||||||
@@ -124,16 +116,13 @@ WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
|
|||||||
}
|
}
|
||||||
haveFmt = true;
|
haveFmt = true;
|
||||||
} else if (tagEquals(bytes, c.headerOffset, "data")) {
|
} else if (tagEquals(bytes, c.headerOffset, "data")) {
|
||||||
// The data chunk: PCM starts at bodyOffset, declared length bodySize.
|
// Reject if the declared body runs past the buffer (truncated/lying
|
||||||
// Reject if it runs past the buffer (truncated / lying header).
|
// header), or if data arrived before fmt.
|
||||||
if (!c.bodyInBounds) return out;
|
if (!c.bodyInBounds) return out;
|
||||||
if (!haveFmt) return out; // data before fmt — not a WAV we parse
|
if (!haveFmt) return out;
|
||||||
|
|
||||||
// Plain IEEE-float tag (0x0003): accept as-is.
|
// Extensible tag (0xFFFE) is float only when its SubFormat sub-tag is
|
||||||
// Extensible tag (0xFFFE): accept only when the SubFormat tag read from
|
// also IEEE-float (0x0003) — PCM-integer-in-extensible must be rejected.
|
||||||
// the GUID at body offset 24 is also 0x0003 (IEEE float). SubFormat tag
|
|
||||||
// 0x0001 (PCM integer) or anything else with bitsPerSample==32 is NOT
|
|
||||||
// float and must be rejected to prevent mis-decoding as float.
|
|
||||||
const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) ||
|
const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) ||
|
||||||
(fmtTag == kWaveFormatExtensible &&
|
(fmtTag == kWaveFormatExtensible &&
|
||||||
extensibleSubFormatTag == kWaveFormatIeeeFloat);
|
extensibleSubFormatTag == kWaveFormatIeeeFloat);
|
||||||
@@ -279,12 +268,6 @@ std::string hashBytes(const std::uint8_t* data, std::size_t len) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
|
std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
|
||||||
// Walk the RIFF/WAVE container (the shared traversal) and feed only the `fmt `
|
|
||||||
// body and `data` body through FNV-1a, prefixed with the domain-separation tag
|
|
||||||
// byte 'W' (0x57). Any render-varying metadata chunks (bext, iXML, LIST, SMED,
|
|
||||||
// etc.) are skipped. If the file does not parse as RIFF/WAVE with both fmt and
|
|
||||||
// data chunks, fall back to whole-file hashBytes (no prefix) so an unrecognized
|
|
||||||
// file still gets a hash.
|
|
||||||
if (isRiffWave(bytes)) {
|
if (isRiffWave(bytes)) {
|
||||||
std::uint64_t h = kFnvOffsetBasis;
|
std::uint64_t h = kFnvOffsetBasis;
|
||||||
auto feedByte = [&](std::uint8_t b) {
|
auto feedByte = [&](std::uint8_t b) {
|
||||||
@@ -295,23 +278,18 @@ std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
|
|||||||
bool haveFmt = false;
|
bool haveFmt = false;
|
||||||
bool haveData = false;
|
bool haveData = false;
|
||||||
|
|
||||||
// Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a
|
feedByte(static_cast<std::uint8_t>('W')); // domain-separation prefix
|
||||||
// whole-file hash of different bytes that happen to be the same length.
|
|
||||||
feedByte(static_cast<std::uint8_t>('W'));
|
|
||||||
|
|
||||||
std::size_t pos = 12;
|
std::size_t pos = 12;
|
||||||
WavChunkView c;
|
WavChunkView c;
|
||||||
while (nextWavChunk(bytes, pos, c)) {
|
while (nextWavChunk(bytes, pos, c)) {
|
||||||
if (tagEquals(bytes, c.headerOffset, "fmt ")) {
|
if (tagEquals(bytes, c.headerOffset, "fmt ")) {
|
||||||
// Feed the entire fmt body (all fields, including format tag, channels,
|
|
||||||
// sample rate, bits-per-sample — everything that defines the audio format).
|
|
||||||
if (c.bodyInBounds) {
|
if (c.bodyInBounds) {
|
||||||
for (std::uint32_t i = 0; i < c.bodySize; ++i)
|
for (std::uint32_t i = 0; i < c.bodySize; ++i)
|
||||||
feedByte(bytes[c.bodyOffset + i]);
|
feedByte(bytes[c.bodyOffset + i]);
|
||||||
haveFmt = true;
|
haveFmt = true;
|
||||||
}
|
}
|
||||||
} else if (tagEquals(bytes, c.headerOffset, "data")) {
|
} else if (tagEquals(bytes, c.headerOffset, "data")) {
|
||||||
// Feed the entire PCM payload.
|
|
||||||
if (c.bodyInBounds) {
|
if (c.bodyInBounds) {
|
||||||
for (std::uint32_t i = 0; i < c.bodySize; ++i)
|
for (std::uint32_t i = 0; i < c.bodySize; ++i)
|
||||||
feedByte(bytes[c.bodyOffset + i]);
|
feedByte(bytes[c.bodyOffset + i]);
|
||||||
|
|||||||
+38
-103
@@ -1,39 +1,9 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// wav_codec — the ONE pure owner of the WAV/RIFF byte format (Q-W3, audit §4e:
|
// wav_codec — the pure owner of the WAV/RIFF byte format: chunk walker, layout
|
||||||
// T2-08 / T4-10 / T4-23 consolidation). Chunk walker + layout parse + float32
|
// parse, float32 build, size-field patch, and the WAV-aware content hash — one
|
||||||
// build + size-field patch + the WAV-aware content hash, in one tested module.
|
// chunk traversal shared by all of them so hashing and decoding cannot desync.
|
||||||
//
|
// Handles 32-bit float WAV only (RIFF/WAVE, `fmt ` tag 3 or 0xFFFE-extensible
|
||||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
// w/ float subformat, float32 `data`); anything else parses as invalid.
|
||||||
// vendor/ includes. Standard library only. Builds and unit-tests without REAPER.
|
|
||||||
//
|
|
||||||
// Before this module, RIFF container knowledge (chunk-header arithmetic, even-byte
|
|
||||||
// padding, size fields) was minted at four sites: wav_trim's layout parse,
|
|
||||||
// capture_paths' content-hash chunk walk, ingest's hand-built float32 writer, and
|
|
||||||
// capture_realtime's in-place size patch. A drift in any one (e.g. pad-byte
|
|
||||||
// handling) would desynchronize hashing from decoding — the dedup-by-hash and
|
|
||||||
// null-test invariants both sit on this. Now every walker/builder/patcher is here,
|
|
||||||
// on ONE chunk-traversal implementation.
|
|
||||||
//
|
|
||||||
// WHY TRIM EXISTS (docs/product/capture-tail.md §The realtime path). The realtime
|
|
||||||
// backend records a generous tail window, then trims the trailing decay by
|
|
||||||
// truncating the recorded WAV at a frame boundary. Truncating a WAV correctly is
|
|
||||||
// not "chop the bytes": the RIFF container's size fields (the top-level RIFF chunk
|
|
||||||
// size and the `data` sub-chunk size) must be patched to the kept byte count, or
|
|
||||||
// the file is a corrupt / mis-lengthed WAV. That header arithmetic — chunk walking,
|
|
||||||
// format verification, and the size-field patch offsets — is exactly the fiddly,
|
|
||||||
// easy-to-get-wrong logic the discipline unit-tests OUTSIDE the DAW. The REAPER
|
|
||||||
// shell does only the file I/O: read the bytes, call the pure parse, run the decay
|
|
||||||
// scan, call the pure plan, patch + write the truncated bytes.
|
|
||||||
//
|
|
||||||
// FORMAT ASSUMPTION (flagged for DAW-verify). We record 32-bit float WAV
|
|
||||||
// (capture.cpp kRenderFormatWavFloat32; realtime records via REAPER's project
|
|
||||||
// record format, which the manual procedure sets to WAV/32-bit-float). The parser
|
|
||||||
// therefore verifies canonical PCM/IEEE-float WAV: a RIFF/WAVE container, a `fmt `
|
|
||||||
// chunk declaring 32-bit float (format tag 3, or tag 0xFFFE WAVE_FORMAT_EXTENSIBLE
|
|
||||||
// with 32 bits), and a `data` chunk of interleaved little-endian float32. Anything
|
|
||||||
// else (a different depth, a non-WAV, a compressed source) is reported invalid and
|
|
||||||
// the shell SKIPS the trim (keeps the untrimmed window) rather than corrupting a
|
|
||||||
// file it does not understand. This is deliberately conservative.
|
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
@@ -49,22 +19,20 @@ using audio::AudioSample;
|
|||||||
// --- Layout parse ------------------------------------------------------------
|
// --- Layout parse ------------------------------------------------------------
|
||||||
|
|
||||||
// The parsed geometry of a canonical 32-bit-float WAV. `valid` is false when the
|
// The parsed geometry of a canonical 32-bit-float WAV. `valid` is false when the
|
||||||
// bytes are not a WAV we can safely trim (see FORMAT ASSUMPTION); every other field
|
// bytes are not a WAV we can safely trim; every other field is meaningful only
|
||||||
// is meaningful only when valid.
|
// when valid.
|
||||||
struct WavLayout {
|
struct WavLayout {
|
||||||
bool valid = false;
|
bool valid = false;
|
||||||
|
|
||||||
std::uint16_t channelCount = 0; // from `fmt ` (the interleave stride)
|
std::uint16_t channelCount = 0; // from `fmt ` (interleave stride)
|
||||||
std::uint32_t sampleRate = 0; // from `fmt ` (for frame<->seconds, if needed)
|
std::uint32_t sampleRate = 0;
|
||||||
|
|
||||||
// The `data` chunk: byte offset of its first PCM byte within the file, and its
|
// The `data` chunk: PCM byte offset + declared length.
|
||||||
// declared PCM byte length. frameCount = dataByteLength / (channelCount * 4).
|
// frameCount = dataByteLength / (channelCount * 4).
|
||||||
std::size_t dataByteOffset = 0;
|
std::size_t dataByteOffset = 0;
|
||||||
std::size_t dataByteLength = 0;
|
std::size_t dataByteLength = 0;
|
||||||
|
|
||||||
// Byte offset of the two little-endian uint32 size fields the truncate patch
|
// Offsets of the two LE uint32 size fields the truncate patch rewrites.
|
||||||
// rewrites: the top-level RIFF chunk size (bytes 4..7) and the `data` sub-chunk
|
|
||||||
// size (the 4 bytes immediately before dataByteOffset).
|
|
||||||
std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file
|
std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file
|
||||||
std::size_t dataSizeFieldOffset = 0;
|
std::size_t dataSizeFieldOffset = 0;
|
||||||
|
|
||||||
@@ -74,19 +42,15 @@ struct WavLayout {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parses a WAV byte buffer's header geometry. Returns {valid=false} for anything
|
// Parses a WAV byte buffer's header geometry; {valid=false} for anything not a
|
||||||
// that is not a canonical 32-bit-float RIFF/WAVE with a `fmt ` and a `data` chunk,
|
// canonical float32 RIFF/WAVE, or a `data` length running past the buffer.
|
||||||
// or whose declared `data` length runs past the buffer. Does NOT copy PCM — it only
|
// Does not copy PCM, only locates it. Pure + total (no throw, no UB).
|
||||||
// locates it (extractFloatFrames does the copy). Pure + total (no throw, no UB).
|
|
||||||
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes);
|
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes);
|
||||||
|
|
||||||
// Copies `frameCount` interleaved float frames starting at `startFrame` out of the
|
// Copies `frameCount` interleaved float frames starting at `startFrame` out of
|
||||||
// WAV's `data` region into a flat [f0c0,f0c1,...] buffer (the shape peaks consumes).
|
// the WAV's `data` region into a flat [f0c0,f0c1,...] buffer, clamped to frames
|
||||||
// Clamps to the frames the buffer actually holds — never reads past `data`. Returns
|
// actually present; never reads past `data`. Reads little-endian via memcpy —
|
||||||
// empty for an invalid layout or an out-of-range start. The floats are read
|
// target is x86/ARM-LE only, no big-endian byte-swap.
|
||||||
// little-endian via std::memcpy (no aliasing UB); on a big-endian host they would
|
|
||||||
// need a byte-swap — flagged, not handled, because the target (Windows/macOS/Linux
|
|
||||||
// on x86/ARM-LE) is little-endian and REAPER writes LE WAV.
|
|
||||||
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
|
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
|
||||||
const WavLayout& layout,
|
const WavLayout& layout,
|
||||||
std::size_t startFrame,
|
std::size_t startFrame,
|
||||||
@@ -94,10 +58,8 @@ std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& byt
|
|||||||
|
|
||||||
// --- Truncate plan + size-field patch ---------------------------------------
|
// --- Truncate plan + size-field patch ---------------------------------------
|
||||||
|
|
||||||
// The plan to truncate a parsed WAV to `keptFrames` frames: the new total file byte
|
// The plan to truncate a parsed WAV to `keptFrames` frames. `valid` is false if
|
||||||
// length and the two size-field values to patch. `valid` is false if the layout is
|
// the layout is invalid or keptFrames exceeds the file's frames (never grow).
|
||||||
// invalid or keptFrames exceeds the file's frames (never GROW a file — the caller
|
|
||||||
// clamps beforehand; this guards it too).
|
|
||||||
struct WavTruncatePlan {
|
struct WavTruncatePlan {
|
||||||
bool valid = false;
|
bool valid = false;
|
||||||
|
|
||||||
@@ -109,64 +71,37 @@ struct WavTruncatePlan {
|
|||||||
// the 8-byte "RIFF"+size prefix)
|
// the 8-byte "RIFF"+size prefix)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Computes the truncate plan to keep exactly `keptFrames` frames of a parsed WAV.
|
// Computes the truncate plan to keep exactly `keptFrames` frames. The shell
|
||||||
// keptFrames == layout.frameCount() is a valid no-op plan (file unchanged). Pure +
|
// applies it: patch the two size fields (patchU32LE), then truncate to
|
||||||
// total. The shell applies it: patch the two size fields in the byte buffer
|
// newFileByteLength.
|
||||||
// (patchU32LE), then truncate the file to newFileByteLength.
|
|
||||||
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames);
|
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames);
|
||||||
|
|
||||||
// Patches a little-endian uint32 into a byte buffer at `off` — the RIFF/data size
|
// Patches a little-endian uint32 into a byte buffer at `off`. Caller guarantees
|
||||||
// fields the truncate plan names. The caller guarantees off + 4 <= bytes.size()
|
// off + 4 <= bytes.size() (the plan's offsets came from a valid parse of the same
|
||||||
// (the plan's offsets came from a valid parse of the same buffer).
|
// buffer).
|
||||||
void patchU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v);
|
void patchU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v);
|
||||||
|
|
||||||
// --- Float32 WAV build -------------------------------------------------------
|
// --- Float32 WAV build -------------------------------------------------------
|
||||||
|
|
||||||
// Builds a minimal canonical 32-bit-float RIFF/WAVE byte buffer from interleaved
|
// Builds a minimal canonical float32 RIFF/WAVE byte buffer from interleaved
|
||||||
// double samples: RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT,
|
// double samples (narrowed to float by cast). Round-trips through
|
||||||
// 16-byte body), data chunk (interleaved little-endian float32). `nch` channels,
|
// parseWavLayout/extractFloatFrames.
|
||||||
// `rate` Hz, `frameCount` frames (total samples = frameCount * nch). Each double is
|
|
||||||
// narrowed to float by cast — the bank contract is 32-bit float (see FORMAT
|
|
||||||
// ASSUMPTION above); the reduction is intentional. The output round-trips through
|
|
||||||
// parseWavLayout/extractFloatFrames. The ingest shell decodes any non-canonical
|
|
||||||
// source through REAPER's PCM_source, then writes the bank copy with this.
|
|
||||||
std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
|
std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
|
||||||
std::size_t frameCount,
|
std::size_t frameCount,
|
||||||
const std::vector<double>& interleaved);
|
const std::vector<double>& interleaved);
|
||||||
|
|
||||||
// --- Content identity (dedup hashes) -----------------------------------------
|
// --- Content identity (dedup hashes) -----------------------------------------
|
||||||
|
|
||||||
// Computes a deterministic FNV-1a 64-bit content hash over `len` bytes at `data`
|
// Deterministic FNV-1a 64-bit content hash over `len` bytes, as 16-char lowercase
|
||||||
// and returns it as a 16-character lowercase hex string. Designed to fill
|
// hex. Fills Sample::contentHash for the confirm-on-last-reference dedup guardrail.
|
||||||
// Sample::contentHash so the confirm-on-last-reference guardrail
|
|
||||||
// (BankBook::hashReferencedElsewhere) can distinguish "no other bank holds this
|
|
||||||
// file" from "another bank holds the same file." An empty buffer returns the bare
|
|
||||||
// FNV-1a 64-bit offset basis in hex (a stable, non-empty sentinel that two empty
|
|
||||||
// files would share, but real WAV files are never empty).
|
|
||||||
std::string hashBytes(const std::uint8_t* data, std::size_t len);
|
std::string hashBytes(const std::uint8_t* data, std::size_t len);
|
||||||
|
|
||||||
// WAV-aware content hash: hashes only the audio-defining content of a 32-bit-float
|
// WAV-aware content hash: hashes only the `fmt ` body + `data` payload, skipping
|
||||||
// RIFF/WAVE file — the `fmt ` chunk body + the `data` chunk payload — skipping all
|
// other chunks. WHY: REAPER's offline renderer embeds a render-varying `bext`
|
||||||
// other RIFF chunks (e.g. `bext` origination timestamp, `iXML`, `LIST`/`INFO`, SMED).
|
// timestamp chunk even with no BWF metadata requested, so two renders of
|
||||||
//
|
// identical audio would otherwise hash differently and never dedup. Prefixed
|
||||||
// WHY: REAPER's offline renderer embeds render-varying metadata chunks (at minimum a
|
// with tag byte 'W' so it can't collide with a same-size hashBytes result.
|
||||||
// `bext` chunk containing the origination date/time) even when the format config blob
|
// Falls back to whole-file hashBytes (no prefix) for a file that doesn't parse.
|
||||||
// requests no BWF metadata. Two renders of identical audio therefore differ in those
|
|
||||||
// bytes, making whole-file hashes diverge and preventing dedup collapse.
|
|
||||||
//
|
|
||||||
// DOMAIN SEPARATION: the FNV-1a input is prefixed with the tag byte 'W' (0x57) before
|
|
||||||
// the fmt/data bytes are fed in, so a content hash can never equal a whole-file
|
|
||||||
// hashBytes result for a different file of the same size.
|
|
||||||
//
|
|
||||||
// FALLBACK: if `bytes` does not parse as a valid RIFF/WAVE with both a `fmt ` and a
|
|
||||||
// `data` chunk, the function falls back to whole-file hashBytes (no prefix tag) —
|
|
||||||
// identical to calling hashBytes(bytes.data(), bytes.size()). This ensures that an
|
|
||||||
// unrecognized or malformed file still gets a non-empty hash rather than silently
|
|
||||||
// skipping dedup.
|
|
||||||
//
|
|
||||||
// Called by both capture commit paths (offline and realtime) and the ingest import
|
|
||||||
// in place of the raw hashBytes call. Walks the container with the SAME chunk
|
|
||||||
// traversal parseWavLayout uses, so hashing and decoding can never desynchronize.
|
|
||||||
std::string hashWavContent(const std::vector<std::uint8_t>& bytes);
|
std::string hashWavContent(const std::vector<std::uint8_t>& bytes);
|
||||||
|
|
||||||
} // namespace reasampler::capture
|
} // namespace reasampler::capture
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# 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, the one set of play params, pitch shifting,
|
||||||
|
velocity curve, and master-gain taper math.
|
||||||
|
- **`map/`** — the capture resolution + `SampleData` build, 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 (the band-stack allocator and its band
|
||||||
|
interiors, 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, 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.
|
||||||
|
|
||||||
|
### One capture = one parameter set
|
||||||
|
|
||||||
|
The instrument holds ONE loaded capture and ONE set of playback parameters governing it
|
||||||
|
across the whole keyboard. There are no zones, no per-zone divergence, and no keymap of
|
||||||
|
captures: every playback parameter edits in exactly one place, and no gesture can express
|
||||||
|
per-zone divergence. The root note survives as a first-class parameter of that one set.
|
||||||
|
|
||||||
|
- **No key-range concept.** The loaded capture answers every note 0..127, repitched from
|
||||||
|
its root, with key-tracking applied. A user-settable low/high playable range is
|
||||||
|
re-addable later as two ordinary parameters if it is ever missed.
|
||||||
|
- **Migration is adopt-the-first-zone.** A saved multi-zone instance lifts by taking zone
|
||||||
|
one's capture and zone one's parameters; the rest drop, touching no file and no bank
|
||||||
|
entry. Single-zone instances lift losslessly. The sounds-identical bar is deliberately
|
||||||
|
relaxed for a genuinely multi-zone instance.
|
||||||
|
|
||||||
|
### 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`).
|
||||||
|
- **Performance choices live in the instrument.** 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 parameter
|
||||||
|
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 `ComponentState`, never written to `Sample` or the bank.
|
||||||
|
|
||||||
|
### The pure core (D3 — the load-bearing split)
|
||||||
|
|
||||||
|
The sampler's voice engine, envelope math, velocity mapping, and repitch/interpolation 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 (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 envelope fields of the one parameter set, 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.
|
||||||
|
|
||||||
|
### Parameter ownership and persistence (D-B)
|
||||||
|
|
||||||
|
- **Key-tracking** — 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** — the one non-back-compat surface in S-VIEW: an
|
||||||
|
already-saved instance 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/`
|
||||||
|
|
||||||
|
- The engine is the `sampler_core` CMake target over FOUR headers and TWO TUs, split on its own responsibility seam — cold note routing vs the hot per-sample render:
|
||||||
|
- `play_params.h` — the value layer: `PlayParams`/`AdsrParams`/`TriggerParams`/`PitchEnvParams`/`FilterParams`, the per-instance mode enums (`ChannelMode`/`VoiceMode`/`MonoTrigger`), and `SampleData` (the ONE loaded capture: decoded PCM + root + loop + start + keyTrack + velocity curve + play params). Shared by the engine, the codec, and the editor, so a UI/codec TU reading a param struct doesn't recompile when a `Voice` member changes. `FilterParams` stores the filter module's own `FilterSettings` by value rather than a parallel copy of its normalized positions.
|
||||||
|
- `envelopes.h` — the three per-frame evaluators (`AdsrEnvelope` AHDSR, `TriggerEnvelope` fade shape, `PitchEnvelope` AD offset), CONCRETE and fully header-inline. Never give them a common base or a virtual `tick()`: they are called per-voice-per-sample. The filter envelope is a SECOND `AdsrEnvelope` instance on the voice, not a fourth class.
|
||||||
|
- `voice.h` / `voice.cpp` — one voice. The per-SAMPLE render half (`advanceFrame` and everything it calls) is INLINE IN THE HEADER by RT constraint; the per-NOTE half (note-on setup incl. the Preserve ring prime, legato retune, gate-off, the off-thread shifter presize) is out of line in the TU. The voice owns its own `VoiceFilter` and filter envelope, run between the pitch stage and the amp multiply — see `engine/filter/CLAUDE.md`.
|
||||||
|
- `voice_engine.h` / `voice_engine.cpp` — `VoiceEngine`: note routing, bounded-stealing allocation, user-parameterized voice count (1–32, default 16), `VoiceMode` Poly/Mono (last-note held-note stack, `MonoTrigger` Retrigger/Legato), two-tier panic (CC 123 = all-notes-off release, CC 120 = immediate hard-stop including Trigger one-shots), and the block render loops. 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.
|
||||||
|
- `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` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **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). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
|
||||||
|
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v9), 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 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. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default.
|
||||||
|
- `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.
|
||||||
|
- `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`) — the shared geometry VOCABULARY every instrument UI module speaks: the `core::ui::Rect` alias, `contains()`, and `OverlayArea` (a one-field `Rect` wrapper, no implicit conversion from `Rect`). Header-only (an INTERFACE CMake target), so it carries no layout of its own.
|
||||||
|
- `sample_bands` — **THE band-stack allocator**, and the only module that owns the Sample face's vertical inventory — including `kEditorMinWidth`/`kEditorMinHeight`, the editor's client-area floor, which IS its default size (the shell's `checkSizeConstraint` and opening `ViewRect` both read it; the face grows, never shrinks below what the stack is laid out for). Three bands top-to-bottom (CHROME toolbar+control row / WAVEFORM elastic, floored at two stacked lanes / DECKS bottom-anchored at the knob deck's own wrapped height), plus the waveform band's lane split (`waveformLanes` takes a resolved `LaneSplit`, not a raw bool — only `waveformSurface` folds the source-channel-count decision in). A shared READ-ONLY surface for every band owner — a band's interior module lays out inside the rect it is handed and never re-allocates the stack.
|
||||||
|
- `sample_chrome` — the CHROME band's interior: the toolbar row (title + the whole right-anchored control run — preview, velocity knob cell, curve button, channel toggle, Browse) over the strip row, which the piano strip owns outright. The title takes what the run leaves; the strip takes its whole row, inset only by the shared band pad so it lines up with the waveform band beneath.
|
||||||
|
- `keyboard_strip` — piano-keyboard strip: true white/black key geometry (whites tiled at one width, blacks overlaid at one width and height, straddling their boundary), hit-test resolving black-over-white by zone, root-marker rect, the absolute-position drag resolver, and MIDI note naming under the C4 convention. **Same-class keys are one integer width by construction; the residue of an indivisible band width (`w % 75`, up to 74 px) lands in symmetric end margins, never in a key** — uniform widths and gap-free edge-to-edge tiling cannot both hold, and uniformity wins.
|
||||||
|
- `waveform_view` — the WAVEFORM band's interior: `waveformSurface` resolves the drawn lane(s) (two stacked lanes, L over R, only when the mode is stereo AND the source has a second channel — a mono source under stereo mode is dual-mono and draws one lane) plus **the** overlay area, and `laneEnvelope` splits one multi-channel envelope pass per lane. Also maps frame span linearly across a rect; generic named draggable markers with drag-delta resolver, clamp, and zero-crossing snap.
|
||||||
|
- **Overlay contract (consumed by later waveform work).** `WaveformSurface::overlay` — equivalently the standalone `waveformOverlayArea(band)` — is the FULL band in both modes. Everything riding the waveform (the amp-envelope trace and its node handles, the start/loop markers, the loop region) draws ONCE into it, spanning both stacked lanes; hit-testing resolves against the same area so a grab in the lower lane reaches them. Anything drawn or hit-tested per lane is a duplicate and a defect — structurally enforced: `overlay` is the distinct `OverlayArea` type (`editor_geometry`), not `Rect`, so every overlay-consuming API (`frameToX`/`markerAtPoint`/`resolveDragFrame`, `envelope_edit`'s `nodeAtPoint`/`resolveNodeDrag`, `envelope_overlay`'s `buildEnvelopePolyline`) rejects a lane rect at compile time rather than silently accepting one.
|
||||||
|
- `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.
|
||||||
|
- `deck_groups` — WHICH groups the Sample face's deck carries, split from `knob_deck`'s HOW they lay out: the `DeckParam` control-id space (the editor's `ParamControl` is an alias of it), the `DeckGroupId` list, `sampleDeckGroups` in signal-flow order (**pitch → filter → amp**, then voice/master), and the deck's bipolar-knob law. Reads `PlayMode` for the AMP group's Gate/Trigger face, which is why this and not `knob_deck` is the module that touches the engine's value layer.
|
||||||
|
- `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 parameter surface** — per root `CLAUDE.md`'s FB2 note, the `Knob` primitive (the knob-deck grammar) is now the only live consumer of that half of `param_slider`. Don't assume `param_slider`'s SLIDER row type is still drawn.
|
||||||
|
- **The engine's per-sample path is inline ON PURPOSE.** `Voice::advanceFrame` and the three evaluators in `envelopes.h` live in headers so `VoiceEngine::render`'s inner loop — in another TU, with no LTO configured — still inlines the whole stack. Moving either out of line, or giving the evaluators a virtual `tick()`, puts a call on the hottest loop in the program.
|
||||||
|
- **The band-stack allocator is the ONLY vertical-inventory owner.** A band's interior module (`sample_chrome`, `knob_deck`, the waveform painters) lays out inside the rect it is handed. A band owner that re-derives its own top/bottom has forked the stack.
|
||||||
|
- **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.
|
||||||
|
- **`keyboard_strip`'s width-uniformity guarantee is client-pixel only.** Its test sweep
|
||||||
|
covers client-pixel widths (including multiples standing in for larger client areas);
|
||||||
|
nothing in the instrument implements `IPlugViewContentScaleSupport`, so host-side DPI
|
||||||
|
scaling of the plugin window — which would resample the uniform integer key widths at the
|
||||||
|
physical-pixel level — is unverified.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
add_subdirectory(engine)
|
||||||
|
add_subdirectory(map)
|
||||||
|
add_subdirectory(ui)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# The shifter is hand-rolled rather than WDL_SimplePitchShifter because that header drags
|
||||||
|
# <windows.h> in via wdltypes.h, which cannot enter the pure engine.
|
||||||
|
reasampler_pure_library(pitch_shift SOURCES pitch_shift.cpp LINK PUBLIC peaks)
|
||||||
|
# Links only pitch_shift: linking more would break the plain-data-boundary proof — and
|
||||||
|
# specifically the compile-time proof it does not drag in the WDL <windows.h> chain.
|
||||||
|
reasampler_test(pitch_shift LINK pitch_shift)
|
||||||
|
|
||||||
|
reasampler_pure_library(velocity_curve SOURCES velocity_curve.cpp)
|
||||||
|
# Links only velocity_curve, deliberately not editor_geometry: the proof the engine can
|
||||||
|
# depend on the curve without inheriting the editor's layout types.
|
||||||
|
reasampler_test(velocity_curve LINK velocity_curve)
|
||||||
|
|
||||||
|
reasampler_pure_library(master_gain SOURCES master_gain.cpp)
|
||||||
|
reasampler_test(master_gain LINK master_gain)
|
||||||
|
|
||||||
|
# Declared before sampler_core because the voice now runs one per sounding note.
|
||||||
|
add_subdirectory(filter)
|
||||||
|
|
||||||
|
# Two TUs on the engine's own responsibility seam (per-note setup vs. note routing and
|
||||||
|
# block render). The per-sample render half stays inline in voice.h precisely so this TU
|
||||||
|
# boundary costs the hot path nothing.
|
||||||
|
reasampler_pure_library(sampler_core
|
||||||
|
SOURCES voice.cpp voice_engine.cpp
|
||||||
|
LINK PUBLIC peaks pitch_shift velocity_curve filter)
|
||||||
|
# Links only sampler_core: linking more would break the plain-data-boundary proof — a VST3
|
||||||
|
# or REAPER type reaching the core would fail to compile or link here.
|
||||||
|
reasampler_test(sampler_core LINK sampler_core)
|
||||||
|
|
||||||
|
# The filter's own seams are covered by the four targets in filter/; this one covers the
|
||||||
|
# integration: pipeline order, per-voice independence, and the off-by-default bit-identity.
|
||||||
|
reasampler_test(sampler_filter LINK sampler_core)
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
#pragma once
|
||||||
|
// envelopes.h — the three per-frame envelope evaluators (AHDSR amplitude, Trigger fade
|
||||||
|
// shape, AD pitch offset). Concrete classes, every body defined in-class: these are called
|
||||||
|
// per-voice-per-sample from Voice::advanceFrame, so they must inline into the render loop.
|
||||||
|
// NEVER give them a common base or a virtual tick() — that vtable lands on the hottest
|
||||||
|
// inner loop in the program (root CLAUDE.md, structural heuristic 3).
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "core/instrument/engine/play_params.h"
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
// AHDSR amplitude envelope, sample-based (times in frames), linear segments. A gate:
|
||||||
|
// noteOn() enters Attack; noteOff() enters Release from wherever it is.
|
||||||
|
//
|
||||||
|
// Segment math:
|
||||||
|
// Attack: 0 -> 1 over attackFrames
|
||||||
|
// 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 (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:
|
||||||
|
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
|
||||||
|
|
||||||
|
void configure(const AdsrParams& params) { params_ = params; }
|
||||||
|
|
||||||
|
// Gate on: (re)start from Attack.
|
||||||
|
void noteOn() {
|
||||||
|
stage_ = Stage::Attack;
|
||||||
|
level_ = 0.0;
|
||||||
|
framesInStage_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate off: enter Release from the CURRENT level — release-before-sustain releases from
|
||||||
|
// the partial attack/decay level, not from sustainLevel.
|
||||||
|
void noteOff() {
|
||||||
|
if (stage_ == Stage::Idle || stage_ == Stage::Finished || stage_ == Stage::Release) {
|
||||||
|
return; // already released / not sounding.
|
||||||
|
}
|
||||||
|
releaseFrom_ = level_;
|
||||||
|
stage_ = Stage::Release;
|
||||||
|
framesInStage_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advances one frame and returns the amplitude for THIS frame (before advancing).
|
||||||
|
// Once Release completes the envelope latches Finished and returns 0.0 forever (until
|
||||||
|
// the next noteOn). A single, monotonic per-frame step — the caller pulls one value per
|
||||||
|
// output frame.
|
||||||
|
double tick() {
|
||||||
|
switch (stage_) {
|
||||||
|
case Stage::Idle:
|
||||||
|
case Stage::Finished:
|
||||||
|
level_ = 0.0;
|
||||||
|
return 0.0;
|
||||||
|
|
||||||
|
case Stage::Attack: {
|
||||||
|
if (params_.attackFrames <= 0) {
|
||||||
|
level_ = 1.0;
|
||||||
|
} else {
|
||||||
|
level_ = static_cast<double>(framesInStage_) /
|
||||||
|
static_cast<double>(params_.attackFrames);
|
||||||
|
if (level_ > 1.0) level_ = 1.0;
|
||||||
|
}
|
||||||
|
const double out = level_;
|
||||||
|
++framesInStage_;
|
||||||
|
if (framesInStage_ >= params_.attackFrames) {
|
||||||
|
// holdFrames == 0 falls straight through Hold on the next tick to Decay.
|
||||||
|
stage_ = Stage::Hold;
|
||||||
|
framesInStage_ = 0;
|
||||||
|
level_ = 1.0;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
case Stage::Hold: {
|
||||||
|
// 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;
|
||||||
|
level_ = 1.0;
|
||||||
|
// Single re-dispatch into Decay (bounded: Hold->Decay only, not general
|
||||||
|
// recursion).
|
||||||
|
return tick();
|
||||||
|
}
|
||||||
|
level_ = 1.0;
|
||||||
|
const double out = level_;
|
||||||
|
++framesInStage_;
|
||||||
|
if (framesInStage_ >= params_.holdFrames) {
|
||||||
|
stage_ = Stage::Decay;
|
||||||
|
framesInStage_ = 0;
|
||||||
|
level_ = 1.0;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
case Stage::Decay: {
|
||||||
|
if (params_.decayFrames <= 0) {
|
||||||
|
level_ = params_.sustainLevel;
|
||||||
|
} else {
|
||||||
|
const double t = static_cast<double>(framesInStage_) /
|
||||||
|
static_cast<double>(params_.decayFrames);
|
||||||
|
level_ = 1.0 + (params_.sustainLevel - 1.0) * t;
|
||||||
|
}
|
||||||
|
const double out = level_;
|
||||||
|
++framesInStage_;
|
||||||
|
if (framesInStage_ >= params_.decayFrames) {
|
||||||
|
stage_ = Stage::Sustain;
|
||||||
|
framesInStage_ = 0;
|
||||||
|
level_ = params_.sustainLevel;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
case Stage::Sustain:
|
||||||
|
level_ = params_.sustainLevel;
|
||||||
|
return level_;
|
||||||
|
|
||||||
|
case Stage::Release: {
|
||||||
|
if (params_.releaseFrames <= 0) {
|
||||||
|
level_ = 0.0;
|
||||||
|
stage_ = Stage::Finished;
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
const double t = static_cast<double>(framesInStage_) /
|
||||||
|
static_cast<double>(params_.releaseFrames);
|
||||||
|
level_ = releaseFrom_ * (1.0 - t);
|
||||||
|
if (level_ < 0.0) level_ = 0.0;
|
||||||
|
const double out = level_;
|
||||||
|
++framesInStage_;
|
||||||
|
if (framesInStage_ >= params_.releaseFrames) {
|
||||||
|
stage_ = Stage::Finished;
|
||||||
|
level_ = 0.0;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0.0; // unreachable; silences a warning.
|
||||||
|
}
|
||||||
|
|
||||||
|
Stage stage() const { return stage_; }
|
||||||
|
bool finished() const { return stage_ == Stage::Finished; }
|
||||||
|
double level() const { return level_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
AdsrParams params_;
|
||||||
|
Stage stage_ = Stage::Idle;
|
||||||
|
double level_ = 0.0;
|
||||||
|
std::int64_t framesInStage_ = 0;
|
||||||
|
double releaseFrom_ = 0.0; // level at the moment noteOff() was called
|
||||||
|
};
|
||||||
|
|
||||||
|
// A stateless-shape amplitude function over the play span, evaluated at a source-frame
|
||||||
|
// 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:
|
||||||
|
// `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) {
|
||||||
|
playLength_ = playLengthFrames > 0 ? playLengthFrames : 0;
|
||||||
|
curve_ = curve;
|
||||||
|
finished_ = (playLength_ <= 0);
|
||||||
|
|
||||||
|
// Clamp the fades so fadeIn + fadeOut <= playLength (fade-out anchored to the end).
|
||||||
|
// A negative fade is treated as 0. When both fades together exceed the play length,
|
||||||
|
// shrink the fade-out first (the head fade-in is the more perceptually load-bearing
|
||||||
|
// onset ramp), then the fade-in — never letting either go negative or the sum exceed
|
||||||
|
// the span.
|
||||||
|
std::int64_t fi = fadeInFrames > 0 ? fadeInFrames : 0;
|
||||||
|
std::int64_t fo = fadeOutFrames > 0 ? fadeOutFrames : 0;
|
||||||
|
if (fi > playLength_) fi = playLength_;
|
||||||
|
if (fi + fo > playLength_) fo = playLength_ - fi; // fo >= 0 since fi <= playLength_
|
||||||
|
fadeIn_ = fi;
|
||||||
|
fadeOut_ = fo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if (finished_ || sourceOffset < 0.0 ||
|
||||||
|
sourceOffset >= static_cast<double>(playLength_)) {
|
||||||
|
// At/past the play length the one-shot is done; the voice also frees on
|
||||||
|
// readPos >= playEnd.
|
||||||
|
if (sourceOffset >= static_cast<double>(playLength_)) finished_ = true;
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade-in: 0->1 over [0, fadeIn_). Fade-out: 1->0 over
|
||||||
|
// [playLength_-fadeOut_, playLength_). Unity between. The two ramps never overlap
|
||||||
|
// (configure clamps fadeIn_ + fadeOut_ <= length). The offset is fractional (the read
|
||||||
|
// head is fractional under repitch), so the ramps are smooth rather than stepped.
|
||||||
|
double amp = 1.0;
|
||||||
|
const double foStart = static_cast<double>(playLength_ - fadeOut_);
|
||||||
|
if (fadeIn_ > 0 && sourceOffset < static_cast<double>(fadeIn_)) {
|
||||||
|
const double phase = sourceOffset / static_cast<double>(fadeIn_); // 0..1
|
||||||
|
amp = (curve_ == FadeCurve::EqualPower)
|
||||||
|
? std::sin(phase * 1.5707963267948966) // sin(phase*pi/2): constant power
|
||||||
|
: phase;
|
||||||
|
} else if (fadeOut_ > 0 && sourceOffset >= foStart) {
|
||||||
|
const double phase = (sourceOffset - foStart) / static_cast<double>(fadeOut_);
|
||||||
|
amp = (curve_ == FadeCurve::EqualPower)
|
||||||
|
? std::cos(phase * 1.5707963267948966) // cos(phase*pi/2): constant power
|
||||||
|
: (1.0 - phase);
|
||||||
|
}
|
||||||
|
return amp;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool finished() const { return finished_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::int64_t playLength_ = 0;
|
||||||
|
std::int64_t fadeIn_ = 0;
|
||||||
|
std::int64_t fadeOut_ = 0;
|
||||||
|
FadeCurve curve_ = kDefaultFadeCurve;
|
||||||
|
bool finished_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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; }
|
||||||
|
|
||||||
|
double tick() {
|
||||||
|
if (!params_.enabled) return 0.0;
|
||||||
|
|
||||||
|
const std::int64_t a = params_.attackFrames > 0 ? params_.attackFrames : 0;
|
||||||
|
const std::int64_t d = params_.decayFrames > 0 ? params_.decayFrames : 0;
|
||||||
|
const double peak = params_.peakSemitones;
|
||||||
|
|
||||||
|
double offset;
|
||||||
|
if (pos_ < a) {
|
||||||
|
// Attack: 0 -> peak over attackFrames (rise into the peak).
|
||||||
|
offset = peak * (static_cast<double>(pos_) / static_cast<double>(a));
|
||||||
|
} else if (pos_ < a + d) {
|
||||||
|
// Decay: peak -> 0 over decayFrames (settle to base pitch).
|
||||||
|
const double t = static_cast<double>(pos_ - a) / static_cast<double>(d);
|
||||||
|
offset = peak * (1.0 - t);
|
||||||
|
} else {
|
||||||
|
offset = 0.0; // past attack+decay: at base pitch forever.
|
||||||
|
}
|
||||||
|
++pos_;
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
PitchEnvParams params_;
|
||||||
|
std::int64_t pos_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# src/core/instrument/engine/filter — the per-voice resonant filter
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The pure per-voice filter a sounding voice runs: a Zavalishin TPT/SVF with a continuous
|
||||||
|
morph under one of two laws — HP→BP→LP or HP→notch→LP — and a drive stage. No REAPER, no
|
||||||
|
VST3, no allocation, no I/O. Everything
|
||||||
|
here lives in `reasampler::instrument::engine::filter`, nested per the
|
||||||
|
directory-mirrors-namespace convention, which keeps `FilterSettings` and friends out of
|
||||||
|
`reasampler::instrument::engine` proper where `play_params.h` lives — and `play_params.h`
|
||||||
|
now stores a `FilterSettings` by value, so that separation is load-bearing rather than
|
||||||
|
merely tidy. Five files, one responsibility each:
|
||||||
|
|
||||||
|
- `filter_params` — the control domain: normalized [0,1] knob position → cutoff Hz, Q, and
|
||||||
|
drive depth, plus the exact inverses for cutoff and Q.
|
||||||
|
- `filter_coeffs` — the DSP domain: `SvfCoeffs` and the TPT coefficient solve from
|
||||||
|
(cutoff Hz, Q, sample rate).
|
||||||
|
- `filter_morph` — the morph domain: `MorphLaw`, normalized position → per-tap weights under
|
||||||
|
the selected law, and the fold of those weights into the three multipliers the kernel
|
||||||
|
applies.
|
||||||
|
- `filter_saturate` — `softLimit`, the drive stage's shaper. Header-only inline; it sits
|
||||||
|
inside the per-sample recursion.
|
||||||
|
- `voice_filter` — `FilterSettings` and `VoiceFilter`, the concrete per-voice type.
|
||||||
|
`process()` is defined in the header.
|
||||||
|
|
||||||
|
### The cutoff is the only control that re-solves per frame, and it re-solves alone
|
||||||
|
|
||||||
|
`prepare()` is the full solve; `setCutoffNorm()` is the per-frame one. The split exists because
|
||||||
|
**a modulated corner must move continuously** — Daniel's ruling, replacing a retired 2048-step
|
||||||
|
quantizer that staircased the sweep in ~5.8-cent jumps — and a full `prepare()` per frame is the
|
||||||
|
wasteful way to buy that. Only `g = tan(pi*fc/sr)` depends on cutoff: Q's parabola, the morph's
|
||||||
|
`cos`/`sin`, and the folded mix (a function of the weights and `k` alone) do not, so
|
||||||
|
`setCutoffNorm` re-derives none of them and reuses the `q_` cached at `prepare()`.
|
||||||
|
|
||||||
|
Measured at 48 kHz, MSVC `/O2`, net of the sweep generator and the kernel:
|
||||||
|
|
||||||
|
| per frame | ns |
|
||||||
|
|---|---|
|
||||||
|
| kernel alone, no re-solve | 2.8 |
|
||||||
|
| full `prepare()` | 56.9 |
|
||||||
|
| `setCutoffNorm`, constant logs recomputed | 22.8 |
|
||||||
|
| `setCutoffNorm`, constant logs hoisted (shipped) | 15.5 |
|
||||||
|
|
||||||
|
The last row is 16 voices of continuously-swept filter at ~1.2% of one core — affordable, which
|
||||||
|
is why nothing approximates `tan` here. The hoist is in `filter_params.cpp`: the sweep endpoints
|
||||||
|
and the Q parabola are functions of compile-time constants, and recomputing those five
|
||||||
|
logarithms per frame cost more than the solve they fed. **Do not put a quantizer back on the
|
||||||
|
control value to save the solve** — make the solve cheaper instead.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
### No vtable on the per-sample path
|
||||||
|
|
||||||
|
The Cortex-M4 source this began as was a virtual hierarchy (`FilterBase` → `Filter` →
|
||||||
|
`Biquad` → `{BiquadHP, BiquadLP}`) whose base class routed the channel loop through
|
||||||
|
pure-virtual `process_channel_frame` / `filter` / `update_feedback` so a `FilterDecorator`
|
||||||
|
chain could wrap it. **None of that came across, and none of it may come back.**
|
||||||
|
`VoiceFilter` is concrete, `process()` is inlined, and there is no `IFilter`, no decorator
|
||||||
|
seam, no virtual `tick()`, and no allocation in `process()` — root `CLAUDE.md`'s structural
|
||||||
|
heuristic 3 names this class of dispatch blowout directly.
|
||||||
|
|
||||||
|
### The rate enters ONLY through `g = tan(pi*fc/sr)`
|
||||||
|
|
||||||
|
There is no reference sample rate, calibration rate, or fallback rate anywhere in this
|
||||||
|
module, and introducing one is the specific regression to guard against. An earlier design
|
||||||
|
carried a `kFilterFeedbackDelaySeconds = 1/48000` tuning constant for a feedback tap; that
|
||||||
|
tap, its ring buffer, and the constant are all deleted. A non-positive rate yields `g == 0`
|
||||||
|
and a bypass mix (signal passes through) — never an invented rate.
|
||||||
|
|
||||||
|
### Why the high-pass feedback tap was right on Q15 hardware and wrong here
|
||||||
|
|
||||||
|
The ported firmware fed a saturated share of an earlier output back into the high-pass
|
||||||
|
input. Its stated rationale — that the HP numerator collapses toward zero at low cutoff,
|
||||||
|
taking the resonance with it — is **inverted**, and the comment asserting it has been
|
||||||
|
removed rather than carried forward. Measurement: the HP `b0` approaches **1** as cutoff
|
||||||
|
falls (0.99987 at 20 Hz); it is the **low-pass** `b0` that collapses (1.7e−06 at 20 Hz).
|
||||||
|
|
||||||
|
The tap was a Q15 fixed-point workaround. At 16-bit fixed point the low-cutoff biquad loses
|
||||||
|
a ~17-bit cancellation and the resonance really does die; the feedback injected it back by
|
||||||
|
another route. float32 survives that cancellation with 7 bits to spare, so on this target
|
||||||
|
the tap did not restore character — it *reduced* it (HP landed 0.4% off the analytic RBJ
|
||||||
|
target with the tap disabled, and 25% off with it enabled), and it introduced both level
|
||||||
|
dependence and rate dependence.
|
||||||
|
|
||||||
|
Daniel's ruling on the level-dependent resonance bloom it produced: *"was a feature on the
|
||||||
|
hardware (one knob colorful HP for master FX), wrong choice for this approach."* Drive is
|
||||||
|
now an explicit user-controlled stage instead of an emergent side effect.
|
||||||
|
|
||||||
|
### The morph is a blend of taps, never a coefficient switch
|
||||||
|
|
||||||
|
An SVF produces high, band, and low from the same state, which is the reason this topology
|
||||||
|
was chosen. `FilterMode` as a discrete enum is retired. HP at 0.0, LP at 1.0, continuous
|
||||||
|
throughout, and both endpoints are exact under either law — only the centre differs.
|
||||||
|
|
||||||
|
The crossfade is **equal-power** in both laws, and that is forced by the topology rather
|
||||||
|
than picked by ear. At the corner the taps are `HP = jQ`, `BP = Q`, `LP = -jQ` — adjacent
|
||||||
|
taps in exact quadrature and HP/LP in exact antiphase, relationships the bilinear transform
|
||||||
|
preserves exactly at the prewarped corner. A `cos`/`sin` pair therefore holds the crossfaded
|
||||||
|
power at unity across the whole sweep; a linear crossfade of a quadrature pair would sag to
|
||||||
|
`1/sqrt(2)` mid-leg, a 3 dB hole that reads as a defect rather than as character.
|
||||||
|
|
||||||
|
### The two morph laws, and why only one of them has a flat corner
|
||||||
|
|
||||||
|
`MorphLaw` is a two-value selector on `FilterSettings`, **defaulting to `HighBandLow`** —
|
||||||
|
that is the reviewed-and-measured law, and it is enumerator 0 so a zero-initialized or absent
|
||||||
|
persisted field lands on it rather than on the SEM leg.
|
||||||
|
|
||||||
|
- **`HighBandLow` (HP→BP→LP, the default).** Two equal-power legs crossfading **adjacent taps
|
||||||
|
only**, BP at the centre. Because adjacent taps are in quadrature, the corner magnitude is
|
||||||
|
algebraically `Q*sqrt(cos² + sin²) = Q` at every position — measured flat to 4e-6 across 65
|
||||||
|
positions. **That flatness guarantee is specific to this law.** Do not weaken the assertion
|
||||||
|
that pins it in order to accommodate the other law.
|
||||||
|
- **`HighNotchLow` (HP→notch→LP, the Oberheim SEM).** One equal-power crossfade weighting HP
|
||||||
|
and LP **together** across the whole sweep, `bp == 0` throughout. The notch is not tuned in:
|
||||||
|
HP and LP sit at exactly +90° and −90° at the corner, so equal weights cancel there by
|
||||||
|
construction. Here the corner magnitude deliberately goes to **zero** at the centre —
|
||||||
|
measured worst case −88 dB on the shipped `{250, 1000, 4000}` Hz cutoff grid, typically −110 to
|
||||||
|
−145 dB. Over the full control range (20 Hz – 20 kHz, Q 0.1 – 10) the worst residual is
|
||||||
|
shallower — −69.8 dB at 192 kHz / 30 Hz / Q=10 — from float conditioning in the folded
|
||||||
|
`x − k·v1` term as `fc/sr → 1e-4` at high Q; it is Q-dependent (Q=0.1 holds −110 dB everywhere)
|
||||||
|
and still an excellent notch, not a broadband defect. `test_filter.cpp`'s null test covers this
|
||||||
|
full range with a Q-scaled threshold rather than the flat −74 dB the shipped grid alone would
|
||||||
|
justify. The fold makes the centre's cancellation structural rather than a runtime near-miss:
|
||||||
|
`m2 = lp - hp` is **exactly** `0.0f` at the centre, because `cos` and `sin` of π/4 differ by
|
||||||
|
about an ulp of *double*, nine orders below float's spacing there, so they narrow to one float.
|
||||||
|
|
||||||
|
SEM's zero is at the **notch frequency**, not a broadband level sag — off the corner the pair
|
||||||
|
is still equal-power, so neither law's legs dip. Measuring that requires dividing by each
|
||||||
|
tap's own analytic response first: at `Q = 0.1` a 2-pole approaches its passband so slowly
|
||||||
|
that the pure low tap still reads 0.896 at 50 Hz, and a raw reading would report a 20% "sag"
|
||||||
|
that is the Q, not the morph.
|
||||||
|
|
||||||
|
**The toggle is free on the hot path, and must stay that way.** `morphWeights` runs at
|
||||||
|
`prepare()` cadence; the law is consumed there and nowhere else. The kernel, `svfCoeffs`, and
|
||||||
|
`morphMix`'s fold are identical between the laws — all a law selects is three floats the
|
||||||
|
kernel was already multiplying by. Verified at the machine-code level, not by inspection: the
|
||||||
|
same TU compiled `/O2` against the pre-toggle and post-toggle headers emits byte-identical
|
||||||
|
assembly for `process()` and `processFrame()`. `VoiceFilter` gained no member and `process()`
|
||||||
|
gained no branch. A design that puts the law selector inside the per-sample path is wrong —
|
||||||
|
rework it rather than paying for it.
|
||||||
|
|
||||||
|
### Drive is a contraction inside the loop, which is what makes it unconditionally stable
|
||||||
|
|
||||||
|
`softLimit(u, depth) = u / sqrt(1 + (depth*u)²)` shapes the **band-pass integrator state**.
|
||||||
|
Three properties carry the design:
|
||||||
|
|
||||||
|
- `depth == 0` makes it algebraically the identity (`x / sqrt(1) == x`, exact in IEEE), so
|
||||||
|
drive 0 is **bit-exact** linear whether or not `softLimit` is actually called. The test
|
||||||
|
asserts bit-identity against the same kernel with the limiter deleted.
|
||||||
|
- `process()` gates the call on `driven_` (`driveDepth_ != 0`, cached at `prepare()`) rather
|
||||||
|
than calling `softLimit` unconditionally. `sqrt`/div sit on the per-sample recursive
|
||||||
|
dependency chain, so out-of-order execution can't hide their latency, and at drive 0 that
|
||||||
|
cost buys nothing. Measured: 11.2 ns/sample unconditional vs 4.1 ns gated — the gated form
|
||||||
|
lands at the limiter-removed floor. `driven_` only changes at `prepare()`, so the branch
|
||||||
|
predicts perfectly. The gate is a perf optimization on top of the bit-identity above, not a
|
||||||
|
substitute for it — deleting the gate would still be correct, just 2.7x slower at rest.
|
||||||
|
- `|softLimit(u, d)| <= |u|` for every depth, so the state update can only shrink the state.
|
||||||
|
The filter cannot gain energy from the drive stage: stability at any Q and any cutoff is
|
||||||
|
structural, and self-oscillation is impossible. This is why the shaper must keep unit slope
|
||||||
|
at the origin — a shaper with gain above 1 there turns the resonator into an oscillator.
|
||||||
|
- It shapes the **state**, not the zero-delay loop. A nonlinearity inside the loop would
|
||||||
|
break the closed-form `a1`/`a2`/`a3` solve and need per-sample Newton iteration.
|
||||||
|
|
||||||
|
Placement is the resonance path because that is where the firmware's character came from,
|
||||||
|
and because the band-pass state sits at zero in the passband and at DC — so drive colours
|
||||||
|
the resonance and leaves the passband transparent (measured 0.98 at max drive). It is not a
|
||||||
|
distortion box in series with the signal; a caller wanting that has every other plugin.
|
||||||
|
|
||||||
|
**Drive × resonance interact by design.** What reaches the shaper is the resonance state,
|
||||||
|
already multiplied by roughly `2*Q`, so the same drive setting bites harder the more
|
||||||
|
resonance is dialled in — and harder on a hotter input. That level dependence is the
|
||||||
|
*point* of an explicit drive control; what Daniel rejected was level dependence nobody
|
||||||
|
asked for. At drive 0 there is none, to 0.0004% over a 1000:1 level range.
|
||||||
|
|
||||||
|
`kFilterDriveDepthMax` (4.0) was set against measurement, not feel: at max drive, full-scale
|
||||||
|
input and max resonance the resonant peak lands ~10 dB under the passband — plainly
|
||||||
|
crushed, which is the asked-for "extreme". Raising it further inverts the filter's shape
|
||||||
|
(21 dB under passband at depth 64), turning the peak the user dialled in into a notch.
|
||||||
|
There is deliberately **no makeup gain** — any law for it would be invented rather than
|
||||||
|
derived, and drive is due an ear pass against the radial dial.
|
||||||
|
|
||||||
|
### The cutoff control is sample-rate-free; the clamp is not
|
||||||
|
|
||||||
|
`filterCutoffHzFromNorm` sweeps a fixed 20 Hz – 20 kHz (three exact decades, so norm 1/3 is
|
||||||
|
200 Hz and 2/3 is 2 kHz) and takes no sample rate. The persisted value is the normalized
|
||||||
|
knob position, so a rate-derived endpoint would make one preset sound different at 44.1k and
|
||||||
|
96k. The Nyquist clamp (`kFilterNyquistFraction`, 0.48) is a property of the bilinear
|
||||||
|
transform — `tan(pi*fc/sr)` diverges at Nyquist — so it lives in `svfCoeffs` where the rate
|
||||||
|
is already a parameter. 20 kHz is under 0.48·sr at 44.1k and above, so the clamp never eats
|
||||||
|
live knob travel there; the source's hardcoded 23 kHz endpoint did exactly that at 44.1k.
|
||||||
|
|
||||||
|
### Q spans 0.1 → 10 with √2 at the center
|
||||||
|
|
||||||
|
Settled by Daniel. The source's `Q = M_SQRT1_2 + resonance` mapping (floored at 0.707, no
|
||||||
|
center anchor) was **rewritten, not ported**. The curve is quadratic in log Q through the
|
||||||
|
three anchors rather than two spliced log segments — same anchors either way, but no slope
|
||||||
|
kink at the center detent. The quadratic term is nonzero only because √2 is not the
|
||||||
|
geometric mean of 0.1 and 10; `filterNormFromQ` divides by it. The SVF consumes it as
|
||||||
|
`k = 1/Q`.
|
||||||
|
|
||||||
|
### Denormal flushing: why conjunctive, honestly
|
||||||
|
|
||||||
|
`process()` flushes **both** integrators to exact zero once both are below
|
||||||
|
`kFilterDenormalFloor` (1e-30). The honest reason is narrower than it sounds: `isSilent()`
|
||||||
|
means "both integrators are exactly zero," so both have to reach zero for that check to mean
|
||||||
|
anything, and the conjunctive test is the cheapest way to guarantee it.
|
||||||
|
|
||||||
|
The stronger claim — that a per-variable flush limit-cycles at the floor — does **not**
|
||||||
|
reproduce on this topology. Measured (Q=10, fc=1kHz, 48k): shipped conjunctive goes silent at
|
||||||
|
sample 10783 with 0 subnormals; a per-variable independent flush goes silent ~180 samples
|
||||||
|
earlier and an either-below-zero-both flush ~970 samples earlier, both also 0 subnormals, no
|
||||||
|
limit cycle, and the same excited RMS. That claim WAS real on the retired Direct Form I state,
|
||||||
|
where the flushed variables (`y1`/`y2`) were the actual filter OUTPUT, so zeroing one injected
|
||||||
|
a discontinuity the resonance then amplified. Here `ic1`/`ic2` are integrator STATE, not
|
||||||
|
output: zeroing one only removes energy, a contraction rather than an injection, so the hazard
|
||||||
|
is structurally absent. The only demonstrable hazard is no flush at all, which never reaches
|
||||||
|
exact zero and grinds through subnormals for thousands of samples on a released voice.
|
||||||
|
|
||||||
|
Keep the conjunctive test regardless — it costs nothing extra and is the right guarantee for
|
||||||
|
`isSilent()` — but don't cite the limit-cycle rationale for TPT; it belongs to the retired
|
||||||
|
topology.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **TPT is what fixed the low-cutoff conditioning defect** — this is a topology change, not
|
||||||
|
a relocation. Direct Form I encoded pole proximity in `a1 → -2`, `a2 → +1` and cancelled
|
||||||
|
them against each other every sample; at `fc/sr ≈ 1e-4` that ~17-bit cancellation moved the
|
||||||
|
measured 20 Hz / 192 kHz LP peak by **-27% on a true-peak scan, -57% measured at the
|
||||||
|
analytic peak frequency** (the degraded pole itself moves, so the two methods diverge), and
|
||||||
|
the error is non-monotone with rate rather than a fixed percentage (+5% high at 96 kHz).
|
||||||
|
TPT encodes the same proximity in `a1`'s small deviation from 1, which float32 resolves:
|
||||||
|
checked against an exact-double evaluation of the same difference equation (which matches
|
||||||
|
the analytic target to within measurement noise), TPT's float32-narrowed coefficients are
|
||||||
|
genuinely ~0.02% low at 48 kHz, widening to ~0.03% low at 192 kHz — real coefficient
|
||||||
|
narrowing, not measurement-window noise, and comfortably inside the test's 0.4% tolerance
|
||||||
|
either way. Do not reintroduce a direct-form kernel.
|
||||||
|
- **`prepare()` deliberately does not clear state** — a live parameter move must glide, not
|
||||||
|
click. Call `reset()` at note-on. **Exception: the non-positive-rate bypass path.** There,
|
||||||
|
`a1=1, a2=a3=0` makes both state updates the exact identity and `bypassMix()` never reads
|
||||||
|
the state at all, so a stale nonzero `ic1`/`ic2` would otherwise latch `isSilent()` false
|
||||||
|
forever with no audible effect either way — `prepare()` clears state on that path only,
|
||||||
|
which costs nothing audibly since bypass ignores it.
|
||||||
|
- **The morph endpoints are asserted on the folded mix, exactly.** `morphWeights` snaps the
|
||||||
|
leg endpoints instead of trusting `cos`/`sin` to land on 0 and 1, which they miss by ~1e-17
|
||||||
|
— enough to leave a -324 dB neighbour tap in what is specified as a pure response.
|
||||||
|
- **A NaN morph position falls back per law, not to one shared value.** Every comparison
|
||||||
|
against NaN is false, so it clamps to neither endpoint: `HighBandLow` lands on pure
|
||||||
|
band-pass, `HighNotchLow` on pure high-pass, since it has no band tap to land on.
|
||||||
|
- **Measuring a null needs a ring-time-adequate settle window.** At `Q = 10` the leftover
|
||||||
|
transient alone reads as −52 dB after 0.15 s and would be mistaken for the noise floor.
|
||||||
|
- **The call site is `Voice::advanceFrame`**, between the pitch stage and the amp multiply.
|
||||||
|
It re-solves the corner on **every frame the modulated cutoff actually moves — unquantized**,
|
||||||
|
so the corner glides rather than staircasing.
|
||||||
|
- **Decay to the denormal floor is a fixed wall-clock time, not a sample count.** A test
|
||||||
|
budget expressed in samples is therefore itself a rate assumption — a fixed 20000 samples
|
||||||
|
is ample at 48k and expires mid-decay at 96k and above.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Control mapping, SVF coefficients, morph weights, and the filter type each get their own
|
||||||
|
# TU; VoiceFilter::process stays header-inline so the kernel still inlines at the call site.
|
||||||
|
reasampler_pure_library(filter SOURCES
|
||||||
|
filter_params.cpp
|
||||||
|
filter_coeffs.cpp
|
||||||
|
filter_morph.cpp
|
||||||
|
voice_filter.cpp)
|
||||||
|
|
||||||
|
# Four test targets along the module's own seams so each asserts one domain. filter_tests
|
||||||
|
# alone owns the analytic reference and the steady-state gain measurement — a forked copy of
|
||||||
|
# a measurement reference is a worse defect than a long file.
|
||||||
|
reasampler_test(filter_params LINK filter)
|
||||||
|
reasampler_test(filter_morph LINK filter)
|
||||||
|
reasampler_test(filter_state LINK filter)
|
||||||
|
reasampler_test(filter LINK filter)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#include "core/instrument/engine/filter/filter_coeffs.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace reasampler::instrument::engine::filter {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// M_PI is not standard C++ and is absent on MSVC without _USE_MATH_DEFINES.
|
||||||
|
constexpr double kPi = 3.14159265358979323846;
|
||||||
|
|
||||||
|
double clampd(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
SvfCoeffs svfCoeffs(float cutoffHz, float q, double sampleRate) {
|
||||||
|
const double qq = clampd(q, kFilterQMin, kFilterQMax);
|
||||||
|
const double k = 1.0 / qq;
|
||||||
|
|
||||||
|
double g = 0.0;
|
||||||
|
if (sampleRate > 0.0) {
|
||||||
|
const double fc = clampd(cutoffHz, kFilterCutoffMinHz, kFilterNyquistFraction * sampleRate);
|
||||||
|
g = std::tan(kPi * fc / sampleRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solved in double and narrowed once. The intermediate g*(g+k) is the term that carries the
|
||||||
|
// pole proximity, so forming it in float would throw away the conditioning TPT just bought.
|
||||||
|
const double a1 = 1.0 / (1.0 + g * (g + k));
|
||||||
|
const double a2 = g * a1;
|
||||||
|
const double a3 = g * a2;
|
||||||
|
|
||||||
|
SvfCoeffs c;
|
||||||
|
c.g = static_cast<float>(g);
|
||||||
|
c.k = static_cast<float>(k);
|
||||||
|
c.a1 = static_cast<float>(a1);
|
||||||
|
c.a2 = static_cast<float>(a2);
|
||||||
|
c.a3 = static_cast<float>(a3);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::engine::filter
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// filter_coeffs.h — Zavalishin topology-preserving-transform state-variable coefficients.
|
||||||
|
// The rate enters ONLY through g = tan(pi*fc/sr); there is no reference or calibration rate
|
||||||
|
// anywhere in this module, and reintroducing one would restore the rate-dependent resonance
|
||||||
|
// the TPT rewrite exists to remove.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "core/instrument/engine/filter/filter_params.h"
|
||||||
|
|
||||||
|
namespace reasampler::instrument::engine::filter {
|
||||||
|
|
||||||
|
// The two-integrator SVF's per-sample constants. a1/a2/a3 are the algebraic solution of the
|
||||||
|
// zero-delay feedback loop, so the kernel needs no iteration.
|
||||||
|
struct SvfCoeffs {
|
||||||
|
float g = 0.0f; // tan(pi*fc/sr) — the ONLY place the sample rate appears
|
||||||
|
float k = 1.0f; // 1/Q, the damping term
|
||||||
|
float a1 = 1.0f;
|
||||||
|
float a2 = 0.0f;
|
||||||
|
float a3 = 0.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Highest fraction of the sample rate the pre-warp stays well-conditioned at: tan() diverges
|
||||||
|
// as fc approaches sr/2.
|
||||||
|
inline constexpr double kFilterNyquistFraction = 0.48;
|
||||||
|
|
||||||
|
// cutoffHz is clamped into [kFilterCutoffMinHz, kFilterNyquistFraction*sampleRate] and q into
|
||||||
|
// [kFilterQMin, kFilterQMax]. A non-positive sampleRate yields g == 0 — we refuse to invent a
|
||||||
|
// rate rather than assume 44.1k.
|
||||||
|
//
|
||||||
|
// Float storage is safe HERE in a way it was not for the retired Direct Form I path. DF1 encoded
|
||||||
|
// pole proximity in a1 -> -2, a2 -> +1 and cancelled them against each other every sample; at
|
||||||
|
// fc/sr ~ 1e-4 that ~17-bit cancellation moved the measured 20 Hz/192 kHz LP peak by -27%
|
||||||
|
// (true-peak scan) to -57% (point measurement at the analytic peak frequency, since the
|
||||||
|
// degraded pole itself moves) -- and the error is non-monotone with rate, not a fixed percentage
|
||||||
|
// (+5% high at 96 kHz). TPT encodes the same proximity in a1's small DEVIATION from 1, which
|
||||||
|
// float resolves: measured against an exact-double evaluation of the same difference equation
|
||||||
|
// (which matches the analytic target to within measurement noise), TPT's float32-narrowed
|
||||||
|
// coefficients land genuinely ~0.02% low at 48 kHz, widening to ~0.03% low at 192 kHz -- both
|
||||||
|
// comfortably inside the test's 0.4% tolerance.
|
||||||
|
SvfCoeffs svfCoeffs(float cutoffHz, float q, double sampleRate);
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::engine::filter
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#include "core/instrument/engine/filter/filter_morph.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace reasampler::instrument::engine::filter {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr double kPi = 3.14159265358979323846;
|
||||||
|
|
||||||
|
struct Pair {
|
||||||
|
double a, b;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Equal-power crossfade, EXACT at both ends by construction rather than by rounding: cos and sin
|
||||||
|
// of the leg's quarter turn are only 1e-17 from 0/1 at the endpoints, and the endpoints have to
|
||||||
|
// be pure taps, not a pure tap plus a -324 dB neighbour.
|
||||||
|
Pair equalPower(double t) {
|
||||||
|
if (!(t > 0.0)) return {1.0, 0.0};
|
||||||
|
if (t >= 1.0) return {0.0, 1.0};
|
||||||
|
const double theta = 0.5 * kPi * t;
|
||||||
|
return {std::cos(theta), std::sin(theta)};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
MorphWeights morphWeights(float norm, MorphLaw law) {
|
||||||
|
const double n = norm < 0.0 ? 0.0 : (norm > 1.0 ? 1.0 : static_cast<double>(norm));
|
||||||
|
|
||||||
|
MorphWeights w;
|
||||||
|
if (law == MorphLaw::HighNotchLow) {
|
||||||
|
// ONE crossfade across the whole sweep rather than two legs, so HP and LP carry weight
|
||||||
|
// together everywhere between the endpoints and are equal at the centre.
|
||||||
|
const Pair p = equalPower(n);
|
||||||
|
w.hp = static_cast<float>(p.a);
|
||||||
|
w.bp = 0.0f;
|
||||||
|
w.lp = static_cast<float>(p.b);
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n <= 0.5) {
|
||||||
|
const Pair p = equalPower(2.0 * n); // HP -> BP
|
||||||
|
w.hp = static_cast<float>(p.a);
|
||||||
|
w.bp = static_cast<float>(p.b);
|
||||||
|
w.lp = 0.0f;
|
||||||
|
} else {
|
||||||
|
const Pair p = equalPower(2.0 * n - 1.0); // BP -> LP
|
||||||
|
w.hp = 0.0f;
|
||||||
|
w.bp = static_cast<float>(p.a);
|
||||||
|
w.lp = static_cast<float>(p.b);
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
MorphMix morphMix(const MorphWeights& w, float k) {
|
||||||
|
MorphMix m;
|
||||||
|
m.m0 = w.hp;
|
||||||
|
m.m1 = w.bp - w.hp * k;
|
||||||
|
m.m2 = w.lp - w.hp;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
MorphMix bypassMix() { return MorphMix{1.0f, 0.0f, 0.0f}; }
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::engine::filter
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// filter_morph.h — the continuous morph: normalized position to tap weights under one of two
|
||||||
|
// laws, and the fold of those weights into the three multipliers the kernel actually applies.
|
||||||
|
// An SVF produces all three taps from one state, so the morph is a blend, never a coefficient
|
||||||
|
// switch. Weights are computed at prepare() cadence; the law never reaches the per-sample path.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace reasampler::instrument::engine::filter {
|
||||||
|
|
||||||
|
// Which shape the sweep traces between its two fixed endpoints. This selects CHARACTER, not
|
||||||
|
// topology — same SVF, same coefficients, same kernel under either law; only the centre differs.
|
||||||
|
//
|
||||||
|
// HighBandLow is enumerator 0 deliberately: a zero-initialized or absent persisted field then
|
||||||
|
// lands on the default rather than on the SEM leg.
|
||||||
|
enum class MorphLaw {
|
||||||
|
// HP -> BP -> LP. Crossfades ADJACENT taps only, so the corner magnitude is flat at Q the
|
||||||
|
// whole way across. The default.
|
||||||
|
HighBandLow,
|
||||||
|
// HP -> notch -> LP, the Oberheim SEM. One crossfade weighting HP and LP together, bp == 0
|
||||||
|
// throughout; the notch falls out of the antiphase cancellation rather than being tuned in.
|
||||||
|
HighNotchLow,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Weight on each SVF tap. Under HighBandLow exactly one of hp/lp is nonzero at a time — that law
|
||||||
|
// crossfades adjacent taps only, never HP against LP. Under HighNotchLow bp is always zero and
|
||||||
|
// hp/lp carry weight together, which is precisely what cuts the notch.
|
||||||
|
struct MorphWeights {
|
||||||
|
float hp = 0.0f;
|
||||||
|
float bp = 0.0f;
|
||||||
|
float lp = 1.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
// HP at 0.0, LP at 1.0 under BOTH laws; the centre is a band-pass under HighBandLow and a notch
|
||||||
|
// under HighNotchLow. Out-of-range norm clamps to the endpoints; NaN clamps to neither (every
|
||||||
|
// comparison against it is false) and lands on the law's degenerate — pure band-pass under
|
||||||
|
// HighBandLow, pure high-pass under HighNotchLow, which has no band tap to land on.
|
||||||
|
//
|
||||||
|
// Equal-power (cos/sin) in both laws rather than linear, and that choice is forced by the
|
||||||
|
// topology rather than picked by ear. At the corner frequency the three taps are HP = jQ,
|
||||||
|
// BP = Q, LP = -jQ, so ADJACENT taps are in exact QUADRATURE there (and the bilinear transform
|
||||||
|
// preserves that exactly at the prewarped corner). Under HighBandLow's cos/sin pair the corner
|
||||||
|
// magnitude is therefore Q*sqrt(cos^2 + sin^2) = Q at every morph position — algebraically flat
|
||||||
|
// across the whole sweep. A linear crossfade of the same quadrature pair would sag to Q/sqrt(2),
|
||||||
|
// a 3 dB hole mid-leg.
|
||||||
|
//
|
||||||
|
// HP and LP are exactly ANTIPHASE at the corner (+90 and -90 degrees), so a law giving both
|
||||||
|
// simultaneous weight cancels there. HighBandLow avoids that by staying adjacent; HighNotchLow
|
||||||
|
// uses it — one equal-power crossfade of HP against LP over the whole sweep puts equal weights
|
||||||
|
// at the centre and the null is exact by construction, not tuned. That is why the corner-flat-at-Q
|
||||||
|
// guarantee is specific to HighBandLow: on the SEM leg the corner magnitude deliberately goes to
|
||||||
|
// zero at the centre. Equal power still holds off the notch frequency, so neither law's legs sag.
|
||||||
|
MorphWeights morphWeights(float norm, MorphLaw law);
|
||||||
|
|
||||||
|
// The kernel applies out = m0*v0 + m1*v1 + m2*v2, where v0 is the input and v1/v2 are the SVF's
|
||||||
|
// band and low outputs. Folding hp = v0 - k*v1 - v2 into the weights here keeps the per-sample
|
||||||
|
// path at three multiplies and spares it ever forming the high tap.
|
||||||
|
struct MorphMix {
|
||||||
|
float m0 = 0.0f;
|
||||||
|
float m1 = 0.0f;
|
||||||
|
float m2 = 1.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
MorphMix morphMix(const MorphWeights& w, float k);
|
||||||
|
|
||||||
|
// Passes the input through untouched, whatever the morph position asks for. Reserved for a
|
||||||
|
// sample rate we cannot form a filter from: silencing an instrument is a worse failure than
|
||||||
|
// ignoring the morph, and at g == 0 a low-pass tap is analytically silent.
|
||||||
|
MorphMix bypassMix();
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::engine::filter
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#include "core/instrument/engine/filter/filter_params.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace reasampler::instrument::engine::filter {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
|
||||||
|
|
||||||
|
// log Q = A + B*n + C*n^2, solved from the three anchor points. C is nonzero precisely
|
||||||
|
// because the center anchor sqrt(2) is not the geometric mean of the endpoints (which is 1);
|
||||||
|
// were they equal the curve would degenerate to a plain log sweep and the inverse below
|
||||||
|
// would divide by zero.
|
||||||
|
struct QCurve {
|
||||||
|
double a, b, c;
|
||||||
|
};
|
||||||
|
|
||||||
|
QCurve solveQCurve() {
|
||||||
|
const double lo = std::log(static_cast<double>(kFilterQMin));
|
||||||
|
const double mid = std::log(static_cast<double>(kFilterQCenter));
|
||||||
|
const double hi = std::log(static_cast<double>(kFilterQMax));
|
||||||
|
return {lo, 4.0 * mid - 3.0 * lo - hi, 2.0 * lo + 2.0 * hi - 4.0 * mid};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Functions of compile-time constants alone, so they resolve once at static init rather than
|
||||||
|
// per call. Load-bearing rather than tidy: a modulated cutoff re-solves EVERY FRAME, and
|
||||||
|
// recomputing these logarithms of literals cost more than the solve they feed.
|
||||||
|
const double kLogCutoffMin = std::log(static_cast<double>(kFilterCutoffMinHz));
|
||||||
|
const double kLogCutoffSpan =
|
||||||
|
std::log(static_cast<double>(kFilterCutoffMaxHz)) - kLogCutoffMin;
|
||||||
|
const QCurve kQCurve = solveQCurve();
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
float filterCutoffHzFromNorm(float norm) {
|
||||||
|
return static_cast<float>(std::exp(kLogCutoffMin + clamp01(norm) * kLogCutoffSpan));
|
||||||
|
}
|
||||||
|
|
||||||
|
float filterNormFromCutoffHz(float hz) {
|
||||||
|
if (!(hz > 0.0f)) return 0.0f;
|
||||||
|
return static_cast<float>(
|
||||||
|
clamp01((std::log(static_cast<double>(hz)) - kLogCutoffMin) / kLogCutoffSpan));
|
||||||
|
}
|
||||||
|
|
||||||
|
float filterQFromNorm(float norm) {
|
||||||
|
const double n = clamp01(norm);
|
||||||
|
return static_cast<float>(std::exp(kQCurve.a + n * (kQCurve.b + kQCurve.c * n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
float filterDriveDepthFromNorm(float norm) {
|
||||||
|
const double n = clamp01(norm);
|
||||||
|
return static_cast<float>(kFilterDriveDepthMax * n * n);
|
||||||
|
}
|
||||||
|
|
||||||
|
float filterNormFromQ(float q) {
|
||||||
|
if (!(q > kFilterQMin)) return 0.0f;
|
||||||
|
if (q >= kFilterQMax) return 1.0f;
|
||||||
|
// Clamping first is load-bearing, not just tidy: the parabola peaks at log Q well below
|
||||||
|
// an arbitrarily large q, so an unclamped out-of-range value has no real root at all.
|
||||||
|
const QCurve& k = kQCurve;
|
||||||
|
const double d = k.b * k.b - 4.0 * k.c * (k.a - std::log(static_cast<double>(q)));
|
||||||
|
if (!(d >= 0.0)) return 0.0f;
|
||||||
|
// Of the two roots only this one lies on the rising branch inside [0,1]; the parabola's
|
||||||
|
// vertex sits well above 1 for the settled anchors.
|
||||||
|
return static_cast<float>(clamp01((-k.b + std::sqrt(d)) / (2.0 * k.c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::engine::filter
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// filter_params.h — control-domain mapping for the voice filter: normalized [0,1] knob
|
||||||
|
// positions to cutoff Hz, Q, and drive depth. Deliberately sample-rate-free — the Nyquist
|
||||||
|
// clamp is a property of the bilinear transform and lives in filter_coeffs, so the persisted
|
||||||
|
// normalized cutoff means the same frequency at every project rate.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace reasampler::instrument::engine::filter {
|
||||||
|
|
||||||
|
// The audio band the cutoff control sweeps: three exact decades, so norm 1/3 is 200 Hz and
|
||||||
|
// norm 2/3 is 2 kHz. NOT derived from the sample rate — a rate-dependent endpoint would make
|
||||||
|
// one saved preset sound different at 44.1k and 96k, and at 44.1k the top of the travel would
|
||||||
|
// be dead against the Nyquist clamp (the ported firmware's 23 kHz endpoint had exactly that
|
||||||
|
// defect). 20 kHz sits under 0.48*sr at 44.1 kHz and above; below that (e.g. 32 kHz, 22.05 kHz)
|
||||||
|
// the clamp still handles it correctly, it just eats the top of the knob travel at those rates.
|
||||||
|
inline constexpr float kFilterCutoffMinHz = 20.0f;
|
||||||
|
inline constexpr float kFilterCutoffMaxHz = 20000.0f;
|
||||||
|
|
||||||
|
// Q spans the full range with Butterworth (sqrt(2)) at the control's center detent.
|
||||||
|
inline constexpr float kFilterQMin = 0.1f;
|
||||||
|
inline constexpr float kFilterQMax = 10.0f;
|
||||||
|
inline constexpr float kFilterQCenter = 1.41421356f;
|
||||||
|
|
||||||
|
// Depth at the top of the drive control. The limiter's knee is at 1/depth, and the resonance
|
||||||
|
// swings the state to roughly 2*Q*level, so this is the range over which drive bites. Chosen
|
||||||
|
// against measurement rather than by feel: at max drive, full-scale input and max resonance the
|
||||||
|
// resonant peak lands ~10 dB under the passband — plainly crushed, which is the asked-for
|
||||||
|
// "extreme". Raising it further inverts the filter's shape (measured 21 dB under passband at
|
||||||
|
// depth 64), turning the peak the user dialled in into a notch.
|
||||||
|
inline constexpr float kFilterDriveDepthMax = 4.0f;
|
||||||
|
|
||||||
|
// Out-of-range norm clamps to the endpoints.
|
||||||
|
float filterCutoffHzFromNorm(float norm);
|
||||||
|
|
||||||
|
// Exact inverse of filterCutoffHzFromNorm over the band; out-of-band Hz clamps to 0 or 1.
|
||||||
|
float filterNormFromCutoffHz(float hz);
|
||||||
|
|
||||||
|
// A single smooth curve — quadratic in log Q — through (0, kFilterQMin),
|
||||||
|
// (0.5, kFilterQCenter), (1, kFilterQMax), rather than two spliced log segments. Same three
|
||||||
|
// anchors either way, but the single curve has no slope kink at the center detent.
|
||||||
|
float filterQFromNorm(float norm);
|
||||||
|
|
||||||
|
// Exact inverse of filterQFromNorm; out-of-range Q clamps to 0 or 1.
|
||||||
|
float filterNormFromQ(float q);
|
||||||
|
|
||||||
|
// Drive depth for the in-loop limiter. Square law, not linear: the knee is 1/depth, so a linear
|
||||||
|
// depth would spend most of the audible travel in the first tenth of the knob. Exactly 0 at
|
||||||
|
// norm 0 — the limiter is then algebraically the identity, which is what makes drive=0 bit-exact
|
||||||
|
// linear rather than merely close.
|
||||||
|
float filterDriveDepthFromNorm(float norm);
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::engine::filter
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// filter_saturate.h — the drive stage's soft limiter. Header-inline: it sits inside the
|
||||||
|
// per-voice per-sample recursion.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace reasampler::instrument::engine::filter {
|
||||||
|
|
||||||
|
// Odd, smooth, strictly monotone, bounded by 1/depth, with unit slope at the origin.
|
||||||
|
//
|
||||||
|
// Three properties are load-bearing and none of them are tuning:
|
||||||
|
// - depth == 0 makes this ALGEBRAICALLY the identity (x / sqrt(1) == x, exact in IEEE), so
|
||||||
|
// drive = 0 is bit-exact linear whether or not the caller special-cases it. (voice_filter.h
|
||||||
|
// gates the call on drive != 0 anyway, but as a perf optimization, not because correctness
|
||||||
|
// needs it.)
|
||||||
|
// - |softLimit(x, d)| <= |x| for every d, so dropping it into the resonance state update can
|
||||||
|
// only ever shrink the state. The filter therefore cannot gain energy from the drive stage:
|
||||||
|
// stability at any Q and any cutoff is structural, not a tuned margin, and it can never
|
||||||
|
// self-oscillate.
|
||||||
|
// - Unit slope at the origin, so the shaper adds no gain of its own at any depth. What reaches
|
||||||
|
// it is the resonance state, already multiplied by roughly 2*Q, which is why drive and
|
||||||
|
// resonance interact: the same drive setting bites harder the more resonance is dialled in.
|
||||||
|
//
|
||||||
|
// The retired feedbackSaturate() is deliberately not carried forward: it had 0.75 slope at the
|
||||||
|
// origin, a fixed +/-2.0 threshold calibrated for firmware excursion levels, and turned over
|
||||||
|
// (non-monotone) past x = 6. That absolute threshold is the origin of the level-dependent
|
||||||
|
// resonance this rewrite removes — do not reintroduce it.
|
||||||
|
inline float softLimit(float x, float depth) {
|
||||||
|
const float s = depth * x;
|
||||||
|
return x / std::sqrt(1.0f + s * s);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::engine::filter
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#include "core/instrument/engine/filter/voice_filter.h"
|
||||||
|
|
||||||
|
namespace reasampler::instrument::engine::filter {
|
||||||
|
|
||||||
|
void VoiceFilter::prepare(const FilterSettings& settings, double sampleRate) {
|
||||||
|
q_ = filterQFromNorm(settings.resonanceNorm);
|
||||||
|
coeffs_ = svfCoeffs(filterCutoffHzFromNorm(settings.cutoffNorm), q_, sampleRate);
|
||||||
|
if (sampleRate > 0.0) {
|
||||||
|
mix_ = morphMix(morphWeights(settings.morphNorm, settings.morphLaw), coeffs_.k);
|
||||||
|
} else {
|
||||||
|
// Bypass: a1=1, a2=a3=0 makes both state updates the exact identity, and bypassMix()
|
||||||
|
// reads only the input, never the state -- so clearing here is audibly free (the state
|
||||||
|
// was already going to be ignored) and prevents a stale nonzero ic1/ic2 from latching
|
||||||
|
// isSilent() false forever, which prepare() otherwise deliberately never does.
|
||||||
|
mix_ = bypassMix();
|
||||||
|
for (State& s : state_) s = State{};
|
||||||
|
}
|
||||||
|
driveDepth_ = filterDriveDepthFromNorm(settings.driveNorm);
|
||||||
|
driven_ = driveDepth_ != 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoiceFilter::setCutoffNorm(float cutoffNorm, double sampleRate) {
|
||||||
|
coeffs_ = svfCoeffs(filterCutoffHzFromNorm(cutoffNorm), q_, sampleRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoiceFilter::reset() {
|
||||||
|
for (State& s : state_) s = State{};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool VoiceFilter::isSilent() const {
|
||||||
|
for (const State& s : state_) {
|
||||||
|
if (s.ic1 != 0.0f || s.ic2 != 0.0f) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::engine::filter
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// voice_filter.h — per-voice TPT state-variable filter with a continuous HP->BP->LP morph and
|
||||||
|
// an in-loop drive stage. Concrete type, no vtable: this sits on the per-voice per-sample path,
|
||||||
|
// so process() is header-inline. No allocation, no virtual dispatch, no I/O in process().
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cassert>
|
||||||
|
#include <type_traits>
|
||||||
|
|
||||||
|
#include "core/instrument/engine/filter/filter_coeffs.h"
|
||||||
|
#include "core/instrument/engine/filter/filter_morph.h"
|
||||||
|
#include "core/instrument/engine/filter/filter_params.h"
|
||||||
|
#include "core/instrument/engine/filter/filter_saturate.h"
|
||||||
|
|
||||||
|
namespace reasampler::instrument::engine::filter {
|
||||||
|
|
||||||
|
// Normalized control positions, as the editor moves them and the persisted state carries them.
|
||||||
|
// morphLaw is the one discrete control here — a two-value selector, not a normalized position —
|
||||||
|
// because its two values are characters to choose between, not points on a continuum.
|
||||||
|
struct FilterSettings {
|
||||||
|
float cutoffNorm = 1.0f;
|
||||||
|
float resonanceNorm = 0.0f;
|
||||||
|
float morphNorm = 1.0f; // 0 = high-pass, 1 = low-pass; the centre is set by morphLaw
|
||||||
|
float driveNorm = 0.0f;
|
||||||
|
MorphLaw morphLaw = MorphLaw::HighBandLow;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Below this the recursion has decayed past -600 dB. Flushing keeps the state out of the
|
||||||
|
// subnormal range, where a ringing-out voice would otherwise stall the FPU for thousands of
|
||||||
|
// samples. Chosen well above FLT_MIN so a flushed state can never re-enter that range.
|
||||||
|
inline constexpr float kFilterDenormalFloor = 1e-30f;
|
||||||
|
|
||||||
|
class VoiceFilter {
|
||||||
|
public:
|
||||||
|
// The instrument's output bus is permanently stereo; one integrator pair per channel.
|
||||||
|
static constexpr int kMaxChannels = 2;
|
||||||
|
|
||||||
|
struct State {
|
||||||
|
float ic1 = 0.0f; // band-pass integrator
|
||||||
|
float ic2 = 0.0f; // low-pass integrator
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recomputes coefficients from the control positions. State is deliberately preserved so a
|
||||||
|
// live parameter move glides instead of clicking; call reset() at note-on.
|
||||||
|
void prepare(const FilterSettings& settings, double sampleRate);
|
||||||
|
|
||||||
|
// Re-solves ONLY the cutoff-dependent coefficients, for a cutoff that moves per frame under
|
||||||
|
// envelope modulation — cheap enough that the corner never needs quantizing (see
|
||||||
|
// filter/CLAUDE.md for the numbers). Neither Q's parabola nor the morph's cos/sin enters
|
||||||
|
// `g`, and the folded mix is a function of the weights and k alone, so a cutoff move
|
||||||
|
// re-derives none of them. State preserved, exactly as prepare(). `sampleRate` must be the
|
||||||
|
// one the last prepare() ran at: prepare() owns the non-positive-rate bypass mix, and this
|
||||||
|
// deliberately leaves that mix alone.
|
||||||
|
void setCutoffNorm(float cutoffNorm, double sampleRate);
|
||||||
|
|
||||||
|
void reset();
|
||||||
|
|
||||||
|
// Hot path. `channel` must be in [0, kMaxChannels).
|
||||||
|
float process(int channel, float x) {
|
||||||
|
assert(channel >= 0 && channel < kMaxChannels);
|
||||||
|
State& s = state_[channel];
|
||||||
|
|
||||||
|
const float v3 = x - s.ic2;
|
||||||
|
const float v1 = coeffs_.a1 * s.ic1 + coeffs_.a2 * v3;
|
||||||
|
const float v2 = s.ic2 + coeffs_.a2 * s.ic1 + coeffs_.a3 * v3;
|
||||||
|
|
||||||
|
// The drive stage, and the only nonlinearity. It shapes the BAND-PASS integrator state
|
||||||
|
// rather than the input because that state IS the resonance: in the passband and at DC
|
||||||
|
// it sits at zero, so drive colours the resonance and leaves the passband transparent.
|
||||||
|
// Placing it on the state rather than inside the zero-delay loop keeps a1/a2/a3 an exact
|
||||||
|
// algebraic solve — a nonlinearity inside the loop would need per-sample Newton
|
||||||
|
// iteration. softLimit is a contraction, so this cannot destabilize the filter.
|
||||||
|
//
|
||||||
|
// Gated on driven_ rather than called unconditionally: sqrt and div sit on this
|
||||||
|
// recursive dependency chain, so out-of-order execution can't hide them, and at drive 0
|
||||||
|
// (the default) that cost buys nothing — softLimit(x, 0) == x algebraically. Measured:
|
||||||
|
// 11.2 ns/sample unconditional vs 4.1 ns gated, matching the limiter-removed floor.
|
||||||
|
// driven_ only changes at prepare(), so the branch predicts perfectly. Bit-identity at
|
||||||
|
// drive 0 holds either way, by algebra — the gate is a perf optimization, not what makes
|
||||||
|
// it exact.
|
||||||
|
const float u = 2.0f * v1 - s.ic1;
|
||||||
|
s.ic1 = driven_ ? softLimit(u, driveDepth_) : u;
|
||||||
|
s.ic2 = 2.0f * v2 - s.ic2;
|
||||||
|
|
||||||
|
// Snap the state once the whole resonator has decayed past -600 dB. isSilent() means
|
||||||
|
// "both integrators are exactly zero," so both must reach zero for that check to be
|
||||||
|
// meaningful — the conjunctive test is the cheapest guarantee of that, not a defense
|
||||||
|
// against a demonstrated limit cycle on this topology (measured: a per-variable flush
|
||||||
|
// and an either-below-zero-both flush both go silent here too, no limit cycle, no
|
||||||
|
// subnormals). That risk was real on the retired Direct Form I state, where a per-sample
|
||||||
|
// flush zeroed y1/y2 — the actual OUTPUT — injecting a step the resonance then amplified.
|
||||||
|
// ic1/ic2 are integrator STATE, not output; zeroing one only removes energy, a
|
||||||
|
// contraction rather than an injection. The only demonstrable hazard here is no flush at
|
||||||
|
// all, which never reaches exact zero and stalls in subnormals for thousands of samples.
|
||||||
|
if (s.ic1 > -kFilterDenormalFloor && s.ic1 < kFilterDenormalFloor &&
|
||||||
|
s.ic2 > -kFilterDenormalFloor && s.ic2 < kFilterDenormalFloor) {
|
||||||
|
s.ic1 = 0.0f;
|
||||||
|
s.ic2 = 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mix_.m0 * x + mix_.m1 * v1 + mix_.m2 * v2;
|
||||||
|
}
|
||||||
|
|
||||||
|
void processFrame(float* samples, int channelCount) {
|
||||||
|
assert(channelCount >= 0 && channelCount <= kMaxChannels);
|
||||||
|
for (int c = 0; c < channelCount; ++c) samples[c] = process(c, samples[c]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// True once every integrator has flushed to exact zero — the voice's filter has stopped
|
||||||
|
// ringing and cannot contribute further output.
|
||||||
|
bool isSilent() const;
|
||||||
|
|
||||||
|
const State& state(int channel) const {
|
||||||
|
assert(channel >= 0 && channel < kMaxChannels);
|
||||||
|
return state_[channel];
|
||||||
|
}
|
||||||
|
const SvfCoeffs& coeffs() const { return coeffs_; }
|
||||||
|
const MorphMix& mix() const { return mix_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
SvfCoeffs coeffs_{};
|
||||||
|
MorphMix mix_{};
|
||||||
|
float q_ = 1.0f; // cached at prepare() so setCutoffNorm need not re-solve Q's parabola
|
||||||
|
float driveDepth_ = 0.0f;
|
||||||
|
bool driven_ = false; // driveDepth_ != 0, cached so process() branches on a bool, not a float compare
|
||||||
|
State state_[kMaxChannels]{};
|
||||||
|
};
|
||||||
|
|
||||||
|
// The port's whole point, enforced by the compiler rather than by review: the source was a
|
||||||
|
// virtual hierarchy dispatching per channel per sample, and this type must never grow one
|
||||||
|
// back. Trivially copyable also means nothing here is heap-owned.
|
||||||
|
static_assert(!std::is_polymorphic_v<VoiceFilter>, "no vtable on the per-sample path");
|
||||||
|
static_assert(std::is_trivially_copyable_v<VoiceFilter>, "state is plain values, never owned");
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::engine::filter
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
namespace reasampler::instrument::engine {
|
namespace reasampler::instrument::engine {
|
||||||
|
|
||||||
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
|
using util::clamp01;
|
||||||
|
|
||||||
double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); }
|
double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); }
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
// master_gain.h — PURE dB<->linear<->knob-taper math for the FB1 post-mixer master gain.
|
// master_gain.h — dB<->linear<->knob-taper math for the post-mixer master gain.
|
||||||
// NO VST3, NO REAPER, NO SWELL/LICE types. The mirror of trigger_seam: one tiny module owns
|
// One shared formula so the drawn needle, the persisted value, and the audio-thread
|
||||||
// the ONE formula both sides of a seam share — here the editor's Gain knob (normalized 0..1)
|
// multiply can't drift. Norm 0 = true zero gain (not an epsilon); persisted value is
|
||||||
// and the processor's stored/applied linear gain — so the drawn needle, the persisted value,
|
// linear gain, the dB taper is a UI-side view of it. Unity (0 dB) sits at ~0.714 norm.
|
||||||
// and the audio-thread multiply can never drift.
|
// RT: the processor applies the linear gain as one multiply over the summed output;
|
||||||
//
|
// these functions themselves run on UI/state threads only.
|
||||||
// THE CONTROL (Daniel, FB1). A post-mixer master gain, range -inf .. +24 dB, dB-scaled taper
|
|
||||||
// with -inf at the BOTTOM of the knob: normalized 0 maps to TRUE ZERO linear gain (silence,
|
|
||||||
// not a tiny epsilon), and the remaining travel maps linearly in dB from kMasterGainMinDb
|
|
||||||
// (the finite taper floor) up to kMasterGainMaxDb. Unity (0 dB) sits at norm
|
|
||||||
// kMasterGainMinDb/(kMasterGainMinDb - kMasterGainMaxDb) ~= 0.714 — most of the throw is
|
|
||||||
// usable trim, the last stretch is boost. The PERSISTED value is the LINEAR gain (a plain
|
|
||||||
// finite double, 0 = silence — no -inf on the wire); the taper is a UI-side view of it.
|
|
||||||
//
|
|
||||||
// RT DISCIPLINE: the processor applies the linear gain as one multiply over the summed
|
|
||||||
// output — these functions run on the UI/state threads only.
|
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
@@ -21,38 +11,28 @@
|
|||||||
|
|
||||||
namespace reasampler::instrument::engine {
|
namespace reasampler::instrument::engine {
|
||||||
|
|
||||||
// The dB taper endpoints. norm 0 is -inf (true zero); norm just above 0 starts at the
|
// norm 0 is -inf (true zero); norm just above 0 starts at the finite floor kMasterGainMinDb
|
||||||
// finite floor kMasterGainMinDb and sweeps linearly in dB to kMasterGainMaxDb at norm 1.
|
// and sweeps linearly in dB to kMasterGainMaxDb at norm 1.
|
||||||
inline constexpr double kMasterGainMinDb = -60.0;
|
inline constexpr double kMasterGainMinDb = -60.0;
|
||||||
inline constexpr double kMasterGainMaxDb = 24.0;
|
inline constexpr double kMasterGainMaxDb = 24.0;
|
||||||
|
|
||||||
// The largest linear gain the control can produce (kMasterGainMaxDb as a ratio, ~15.849).
|
// Largest linear gain the control can produce (kMasterGainMaxDb as a ratio, ~15.849).
|
||||||
double masterGainMaxLinear();
|
double masterGainMaxLinear();
|
||||||
|
|
||||||
// Knob taper: normalized [0,1] -> dB. norm <= 0 -> -infinity; else the linear-in-dB sweep
|
|
||||||
// [kMasterGainMinDb, kMasterGainMaxDb]. norm is clamped to [0,1]. Pure.
|
|
||||||
double masterGainDbFromNorm(double norm);
|
double masterGainDbFromNorm(double norm);
|
||||||
|
|
||||||
// Inverse taper: dB -> normalized [0,1]. -infinity (or any dB at or below kMasterGainMinDb,
|
// Anything at or below kMasterGainMinDb (including -inf) collapses to norm 0 — the finite
|
||||||
// including below-floor values like -80 dB) maps to norm 0 (the -inf bottom detent) — the
|
// sweep only covers the range above the floor.
|
||||||
// finite sweep only covers the range above kMasterGainMinDb; everything at or below it collapses
|
|
||||||
// to the same true-zero bottom. +24 -> 1. Pure.
|
|
||||||
double masterGainNormFromDb(double db);
|
double masterGainNormFromDb(double db);
|
||||||
|
|
||||||
// Knob taper composed with dB->ratio: normalized [0,1] -> LINEAR gain. norm 0 -> exactly
|
|
||||||
// 0.0 (true silence); norm 1 -> masterGainMaxLinear(). Pure.
|
|
||||||
double masterGainLinearFromNorm(double norm);
|
double masterGainLinearFromNorm(double norm);
|
||||||
|
|
||||||
// Inverse: LINEAR gain -> normalized [0,1]. linear <= 0 -> 0 (the -inf bottom); a linear at
|
// linear <= 0, or at/below the kMasterGainMinDb floor, collapses to norm 0 — values between
|
||||||
// or below the kMasterGainMinDb floor (e.g. 0.001 = -60 dB, or anything below) also maps to 0
|
// true-zero and the floor aren't representable on the knob. Out-of-range/non-finite clamps.
|
||||||
// — the floor IS the -inf detent; values between true-zero and the floor cannot be represented
|
|
||||||
// on the knob and collapse to the bottom. unity -> ~0.714; masterGainMaxLinear() -> 1.
|
|
||||||
// Out-of-range/non-finite input clamps. Pure.
|
|
||||||
double masterGainNormFromLinear(double linear);
|
double masterGainNormFromLinear(double linear);
|
||||||
|
|
||||||
// The knob's hover/drag value label for a normalized value: "-inf" at the bottom, else a
|
// "-inf" at the bottom, else a signed one-decimal dB string ("-12.0dB", "+2.4dB").
|
||||||
// signed one-decimal dB string ("-12.0dB", "+0.0dB", "+2.4dB"). Writes at most `len` bytes
|
// Writes at most `len` bytes including the terminator.
|
||||||
// including the terminator. Pure.
|
|
||||||
void formatMasterGainLabel(double norm, char* buf, std::size_t len);
|
void formatMasterGainLabel(double norm, char* buf, std::size_t len);
|
||||||
|
|
||||||
} // namespace reasampler::instrument::engine
|
} // namespace reasampler::instrument::engine
|
||||||
|
|||||||
@@ -1,24 +1,15 @@
|
|||||||
// pitch_shift — pure implementation. See pitch_shift.h for the contract, the S16-F2
|
// pitch_shift — pure implementation. See pitch_shift.h for the contract and regression history.
|
||||||
// route-(b) rationale (WDL drags <windows.h>), and the GA-Preserve root cause that replaced
|
|
||||||
// the naive dual-tap OLA with correlation-aligned splices.
|
|
||||||
// NO VST3 / REAPER / SWELL / vendor includes; standard library only.
|
|
||||||
//
|
//
|
||||||
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per input
|
// Algorithm: a delay ring of 2*window frames. The write head advances one frame per input
|
||||||
// sample (source rate -> duration preserved). ONE active read tap advances by the shift
|
// sample (source rate, duration preserved). One active read tap advances by the shift
|
||||||
// `ratio_` per frame, so its delay behind the writer drifts at (1 - ratio) per frame. When
|
// `ratio_` per frame, so its delay behind the writer drifts at (1 - ratio) per frame. When
|
||||||
// that delay leaves the safe band [dLow, dHigh], the tap is RELOCATED by a nominal jump of
|
// that delay leaves the safe band [dLow, dHigh], the tap is relocated by a nominal jump of
|
||||||
// one window (+window toward older content for up-shifts, -window toward the writer for
|
// one window — clamped to the filled span so it never lands in unwritten silence — refined
|
||||||
// down-shifts) — CLAMPED to the filled span so it can never land in unwritten silence (the
|
// by a cross-correlation search over +/- maxLag plus a parabolic peak interpolation for a
|
||||||
// GA2 onset fix) — refined by a cross-correlation search over +/- maxLag PLUS a parabolic
|
// sub-sample lag (an integer-only lag left +/-0.5-sample errors: a sideband comb at the
|
||||||
// peak interpolation for a SUB-SAMPLE lag, so the relocated read point is waveform-aligned
|
// splice cadence on a repitched pure sine). Old and new taps then crossfade over fadeFrames
|
||||||
// to a fraction of a sample (integer-lag splices left +/-0.5-sample errors: a -59 dB
|
// with a raised-cosine, amplitude-complementary pair (in-phase content sums to unity gain).
|
||||||
// sideband comb at the splice cadence on a repitched pure sine — the GA2 "alias lines" on
|
// At unity ratio the delay is frozen mid-band and no splice ever fires.
|
||||||
// the spectrogram). Old and new taps then crossfade over fadeFrames with a raised-cosine,
|
|
||||||
// amplitude-complementary pair (in-phase content sums to exactly unity gain). For a pure
|
|
||||||
// sine the correlation snaps the jump to an (integer + fraction) period count, so the output
|
|
||||||
// stays a single tone at the shifted frequency — the GA-Preserve acceptance bar. At unity
|
|
||||||
// ratio the delay is frozen mid-band and no splice ever fires: a primed shifter passes the
|
|
||||||
// stream through with ZERO added latency; a silence-warmed one is a clean window delay.
|
|
||||||
|
|
||||||
#include "core/instrument/engine/pitch_shift.h"
|
#include "core/instrument/engine/pitch_shift.h"
|
||||||
|
|
||||||
@@ -55,17 +46,15 @@ void PitchShifter::configure(std::int64_t windowFrames) {
|
|||||||
ringLen_ = 2 * window_;
|
ringLen_ = 2 * window_;
|
||||||
ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f);
|
ring_.assign(static_cast<std::size_t>(ringLen_), 0.0f);
|
||||||
// Geometry (all quarters of the window):
|
// Geometry (all quarters of the window):
|
||||||
// - fadeFrames_: the NOMINAL splice crossfade. This window/4 length is only safe when
|
// - fadeFrames_: nominal splice crossfade; only safe while the outgoing tap can't reach
|
||||||
// the outgoing tap cannot reach the writer before the fade ends; splice() scales the
|
// the writer before the fade ends. splice() scales fadeLen_ down by ratio for up-shifts
|
||||||
// live fade length (fadeLen_) down by the current ratio for up-shifts past ~2x, so
|
// past ~2x so ordinary transpositions (+24 st) never read stale data mid-fade.
|
||||||
// ordinary sampler transpositions (+24 st = ratio 4) never read stale data mid-fade.
|
// - maxLag_: alignment search half-range — one window/4 covers a full period of any tone
|
||||||
// - maxLag_: the alignment search half-range — one window/4 covers a full period of any
|
// down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
|
||||||
// tone down to 4/window cycles-per-frame (~80 Hz at the product's 50 ms window, 44.1k).
|
// - dLow_/dHigh_: safe delay band; unity parks the tap mid-band (window/2 delay).
|
||||||
// - dLow_/dHigh_: the safe delay band; unity parks the tap mid-band (window/2 delay).
|
// - corrFrames_: at an up-splice the reference segment reads forward from the tap at
|
||||||
// - corrFrames_: the correlation segment length. At an up-splice the reference segment
|
// delay ~dLow_, so dLow_-1 is exactly what exists between tap and writer; 512 bounds
|
||||||
// reads FORWARD from the tap at delay ~dLow_, so dLow_-1 frames is exactly what exists
|
// the splice burst.
|
||||||
// between the tap and the writer — the cap expresses that safety rather than leaving
|
|
||||||
// it coincidental. 512 bounds the splice burst.
|
|
||||||
fadeFrames_ = std::max<std::int64_t>(window_ / 4, 1);
|
fadeFrames_ = std::max<std::int64_t>(window_ / 4, 1);
|
||||||
maxLag_ = window_ / 4;
|
maxLag_ = window_ / 4;
|
||||||
dLow_ = window_ / 4;
|
dLow_ = window_ / 4;
|
||||||
@@ -77,10 +66,9 @@ void PitchShifter::configure(std::int64_t windowFrames) {
|
|||||||
|
|
||||||
void PitchShifter::reset() {
|
void PitchShifter::reset() {
|
||||||
if (window_ > 1) {
|
if (window_ > 1) {
|
||||||
// Zero the ring and seed the active tap one window behind the writer — the exact
|
// Seed the active tap one window behind the writer — the exact middle of the safe
|
||||||
// middle of the safe band [dLow, dHigh] = [w/4, 2w - w/4], so unity holds it there
|
// band [dLow, dHigh], so unity holds it there forever with maximal drift room either
|
||||||
// forever and either shift direction has maximal drift room. No history is declared
|
// direction. No history declared (filled_ = 0): follow with prime() or warm().
|
||||||
// (filled_ = 0): follow with prime() or warm() before streaming.
|
|
||||||
std::fill(ring_.begin(), ring_.end(), 0.0f);
|
std::fill(ring_.begin(), ring_.end(), 0.0f);
|
||||||
writePos_ = 0;
|
writePos_ = 0;
|
||||||
posA_ = static_cast<double>(ringLen_ - window_);
|
posA_ = static_cast<double>(ringLen_ - window_);
|
||||||
@@ -104,15 +92,12 @@ void PitchShifter::reset() {
|
|||||||
void PitchShifter::freezeTail() {
|
void PitchShifter::freezeTail() {
|
||||||
if (window_ <= 1 || tailFrozen_) return;
|
if (window_ <= 1 || tailFrozen_) return;
|
||||||
tailFrozen_ = true;
|
tailFrozen_ = true;
|
||||||
// An in-flight crossfade was sized for a RETREATING writer (outgoing tap drains at
|
// An in-flight crossfade was sized for a retreating writer (outgoing tap drains at
|
||||||
// ratio-1 per frame); frozen, the outgoing tap closes at the full ratio. Cap the live
|
// ratio-1 per frame); frozen, it closes at the full ratio instead. Cap the live fade so
|
||||||
// fade so it completes before tap B reaches the parked writer and reads lapped (oldest-
|
// it completes before tap B reaches the parked writer and reads lapped content mid-fade.
|
||||||
// window) content mid-fade. fadePos_ is re-anchored to the same fractional t so gNew is
|
|
||||||
// continuous at the freeze frame (no gain step); see the re-anchor block below.
|
|
||||||
if (fading_) {
|
if (fading_) {
|
||||||
// Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the
|
// Preserve t = fadePos_/fadeLen_ across the shortening so gNew is continuous at the
|
||||||
// freeze frame (no gain step). Compute tOld BEFORE overwriting fadeLen_, then
|
// freeze frame (no gain step). Compute tOld before overwriting fadeLen_.
|
||||||
// re-anchor fadePos_ to the same fractional position in the new (shorter) fade.
|
|
||||||
const double tOld =
|
const double tOld =
|
||||||
static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
|
static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
|
||||||
double dB = static_cast<double>(writePos_) - posB_;
|
double dB = static_cast<double>(writePos_) - posB_;
|
||||||
@@ -133,8 +118,8 @@ void PitchShifter::freezeTail() {
|
|||||||
|
|
||||||
void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
|
void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
|
||||||
if (window_ <= 1) return; // pass-through needs no priming
|
if (window_ <= 1) return; // pass-through needs no priming
|
||||||
// Clamp to one window: the intended call primes exactly window() frames, and delay ==
|
// Clamp to one window: delay == count must stay inside the safe band so the seed itself
|
||||||
// count must stay inside the safe band so the seed does not itself trigger a splice.
|
// never triggers a splice.
|
||||||
if (count < 0) count = 0;
|
if (count < 0) count = 0;
|
||||||
if (count > window_) count = window_;
|
if (count > window_) count = window_;
|
||||||
std::fill(ring_.begin(), ring_.end(), 0.0f);
|
std::fill(ring_.begin(), ring_.end(), 0.0f);
|
||||||
@@ -189,24 +174,21 @@ double PitchShifter::readTap(double pos) const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
||||||
// Relocate the active tap by `nominalJump` frames of ADDED delay (+window_ = jump toward
|
// Relocate the active tap by `nominalJump` frames of added delay (+window_ = toward older
|
||||||
// older content, -window_ = jump toward the writer), refined by a correlation search so
|
// content, -window_ = toward the writer), refined by a correlation search so the relocated
|
||||||
// the relocated read point is waveform-aligned with the outgoing tap's upcoming content.
|
// read point is waveform-aligned with the outgoing tap's upcoming content. Search is coarse
|
||||||
// The search is coarse (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best,
|
// (step 4 over +/- maxLag_) then fine (+/- 3 around the coarse best, then a parabolic
|
||||||
// then a parabolic sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_
|
// sub-sample peak): a bounded burst of ~ (maxLag_/2 + 9) * corrFrames_ multiply-adds, once
|
||||||
// multiply-adds, once per splice.
|
// per splice.
|
||||||
const std::int64_t d = static_cast<std::int64_t>(delay);
|
const std::int64_t d = static_cast<std::int64_t>(delay);
|
||||||
|
|
||||||
// GA2 onset fix: an up-jump may only relocate into VALID history. The deepest slot the
|
// An up-jump may only relocate into valid history. The deepest slot the search (plus the
|
||||||
// search (and the +/-1-lag parabolic refinement calls at bestLag ± 1, and the interpolator's
|
// parabola's +/-1 probe and the interpolator's read-ahead) can touch is d + jump + maxLag + 2,
|
||||||
// read-ahead) can touch is delay d + jump + maxLag + 2 (maxLag from the coarse/fine search,
|
// so the cap is filled_ - d - maxLag_ - 1 (one sample looser than that derived bound, not
|
||||||
// +1 for the parabola's outer ± 1 probe, +1 for the interpolator's i1 = i0+1 read-ahead),
|
// extra margin — ring indexing wraps via modulo everywhere regardless). In steady state
|
||||||
// so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample LOOSER
|
// (filled_ == ringLen_) this exceeds window_ and the nominal jump is untouched; near a primed
|
||||||
// than that derived cap (not extra margin); ring indexing wraps via modulo everywhere, so
|
// onset it shrinks the jump to what real history exists. The floor of 1 only fires on the
|
||||||
// this never runs off the physical ring_ array. In steady state (filled_ == ringLen_) this is
|
// degenerate reset-without-prime path.
|
||||||
// > window_ and the nominal jump is untouched; near a primed onset it shrinks the jump to
|
|
||||||
// what real history exists (still many source periods with a full-window prime). The floor of
|
|
||||||
// 1 is only reachable on the documented degenerate reset-without-prime path — garbage-tolerant.
|
|
||||||
std::int64_t jump = nominalJump;
|
std::int64_t jump = nominalJump;
|
||||||
if (jump > 0) {
|
if (jump > 0) {
|
||||||
const std::int64_t maxJump = filled_ - d - maxLag_ - 1;
|
const std::int64_t maxJump = filled_ - d - maxLag_ - 1;
|
||||||
@@ -233,12 +215,10 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
|||||||
if (++ia >= ringLen_) ia = 0;
|
if (++ia >= ringLen_) ia = 0;
|
||||||
if (++ic >= ringLen_) ic = 0;
|
if (++ic >= ringLen_) ic = 0;
|
||||||
}
|
}
|
||||||
// NORMALIZED cross-correlation (standard SOLA): a raw dot product is biased toward
|
// Normalized cross-correlation: a raw dot product biases toward the higher-energy lag,
|
||||||
// the higher-energy lag, so on a decaying tail every up-splice would prefer the
|
// so on a decaying tail every up-splice would prefer the loudest candidate over the
|
||||||
// loudest candidate over the best-ALIGNED one — a small level step per splice that
|
// best-aligned one. The reference segment's energy is constant across lags, so dividing
|
||||||
// the amplitude-complementary fade cannot hide. The reference segment's energy is
|
// by sqrt(Ec) alone ranks identically to the full normalized form.
|
||||||
// constant across lags, so dividing by sqrt(Ec) alone ranks identically to the full
|
|
||||||
// normalized form. A zero-energy candidate scores 0 (splicing into silence is benign).
|
|
||||||
return ec > 0.0 ? s / std::sqrt(ec) : 0.0;
|
return ec > 0.0 ? s / std::sqrt(ec) : 0.0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -261,13 +241,11 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SUB-SAMPLE peak (GA2 alias fix): the integer-lag best leaves a residual misalignment of
|
// Sub-sample peak: the integer-lag best leaves a residual misalignment of up to half a
|
||||||
// up to half a sample; at the splice cadence that residual phase-modulates a pure tone
|
// sample, which at the splice cadence phase-modulates a pure tone into an audible sideband
|
||||||
// into a ~-59 dB sideband comb (the DAW spectrogram "alias lines"). A parabola through
|
// comb. A parabola through the scores at bestLag-1/bestLag/bestLag+1 locates the peak to a
|
||||||
// the scores at bestLag-1/bestLag/bestLag+1 locates the correlation peak to a fraction of
|
// fraction of a sample; readTap()'s linear interpolation realizes it. The denominator is
|
||||||
// a sample; readTap()'s linear interpolation realizes the fractional tap position. The
|
// negative at a genuine peak — flat correlation (DC/silence) keeps the integer lag, benign.
|
||||||
// denominator is negative at a genuine peak — anything else (flat correlation: DC or
|
|
||||||
// silence) keeps the integer lag, which is already benign there.
|
|
||||||
double frac = 0.0;
|
double frac = 0.0;
|
||||||
{
|
{
|
||||||
const double sM = scoreAt(bestLag - 1);
|
const double sM = scoreAt(bestLag - 1);
|
||||||
@@ -287,22 +265,17 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
|||||||
while (p < 0.0) p += len;
|
while (p < 0.0) p += len;
|
||||||
while (p >= len) p -= len;
|
while (p >= len) p -= len;
|
||||||
posA_ = p;
|
posA_ = p;
|
||||||
// RATIO-SCALED fade length. At an up-splice the OUTGOING tap starts at ~dLow_ delay and
|
// Ratio-scaled fade length. At an up-splice the outgoing tap keeps draining toward the
|
||||||
// keeps draining toward the writer at (ratio - 1) per output frame; the nominal window/4
|
// writer at (ratio - 1) per frame; the nominal window/4 fade only keeps it behind the
|
||||||
// fade only keeps it behind the writer for ratios up to 2. Beyond that (e.g. +24 st =
|
// writer for ratios up to 2 — beyond that (e.g. +24 st = ratio 4) it would cross mid-fade
|
||||||
// ratio 4, an ordinary sampler transposition) it would cross mid-fade and play stale
|
// and play stale read-ahead data. Cap the live fade at the drain headroom actually
|
||||||
// read-ahead data at substantial gain — a periodic seam. So cap the live fade at the
|
// available, minus 2 (trigger undershoot + interpolator read-ahead margin). Down-shifts
|
||||||
// frames of drain headroom actually available, minus 2 (1 for the trigger's sub-dLow_
|
// drain at (1 - ratio) < 1 per frame and can't reach the ring end within window/4 frames,
|
||||||
// undershoot, 1 for the interpolator's read-ahead). Ratios <= ~2 keep the full nominal
|
// so they always keep the full fade.
|
||||||
// fade; ratio 4 gets ~window/12 — shorter but still a smooth burst. Down-shifts grow the
|
|
||||||
// outgoing delay at (1 - ratio) < 1 per frame and cannot reach the ring end within
|
|
||||||
// window/4 frames, so they always keep the full fade. A pitch-envelope ratio slew
|
|
||||||
// mid-fade is covered by the same margin for any realistic per-frame bias.
|
|
||||||
//
|
//
|
||||||
// TAIL-FROZEN (GA3): with the writer parked, the outgoing tap closes on it at the FULL
|
// Tail-frozen: with the writer parked, the outgoing tap closes on it at the full ratio in
|
||||||
// ratio (there is no retreating write head), in EITHER shift direction — so the drain
|
// either shift direction, so the drain rate is ratio_ instead of (ratio_ - 1) and the cap
|
||||||
// rate is ratio_ instead of (ratio_ - 1), and the cap applies at every ratio (unity
|
// applies at every ratio (including unity, since delay now drains at unity too).
|
||||||
// included: splices fire in the frozen tail because the delay now drains at unity too).
|
|
||||||
fadeLen_ = fadeFrames_;
|
fadeLen_ = fadeFrames_;
|
||||||
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0);
|
const double drainRate = tailFrozen_ ? ratio_ : (ratio_ - 1.0);
|
||||||
if (drainRate > 0.0) {
|
if (drainRate > 0.0) {
|
||||||
@@ -316,16 +289,15 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
|
|||||||
}
|
}
|
||||||
fading_ = true;
|
fading_ = true;
|
||||||
fadePos_ = 0;
|
fadePos_ = 0;
|
||||||
// Record the decision for a linked follower channel (T1-01): the follower applies this
|
// Record the decision for a linked follower channel — applied verbatim there so both
|
||||||
// verbatim so both channels share one lag and one splice schedule.
|
// channels share one lag and one splice schedule.
|
||||||
lastSplice_ = SpliceEvent{true, jump, bestLag, frac, fadeLen_};
|
lastSplice_ = SpliceEvent{true, jump, bestLag, frac, fadeLen_};
|
||||||
}
|
}
|
||||||
|
|
||||||
void PitchShifter::applySplice(const SpliceEvent& ev) {
|
void PitchShifter::applySplice(const SpliceEvent& ev) {
|
||||||
// Follower half of the T1-01 linked lag: relocate + fade with the master's decision, no
|
// Follower half of the linked lag: relocate + fade with the master's decision, no
|
||||||
// correlation search of our own. The master's jump was clamped against ITS filled_/delay,
|
// correlation search of our own — the master's jump/fade derive from shared geometry +
|
||||||
// which match ours by the lockstep contract (identical configure/prime/ratio history);
|
// ratio, which match ours by the lockstep contract (identical configure/prime/ratio history).
|
||||||
// the fade length likewise derives only from shared geometry + ratio.
|
|
||||||
posB_ = posA_;
|
posB_ = posA_;
|
||||||
double p = posA_ - static_cast<double>(ev.jump) + static_cast<double>(ev.lag) + ev.frac;
|
double p = posA_ - static_cast<double>(ev.jump) + static_cast<double>(ev.lag) + ev.frac;
|
||||||
const double len = static_cast<double>(ringLen_);
|
const double len = static_cast<double>(ringLen_);
|
||||||
@@ -347,24 +319,21 @@ AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& maste
|
|||||||
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) {
|
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) {
|
||||||
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
|
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
|
||||||
|
|
||||||
// Copy the linked decision BEFORE clearing lastSplice_ (guards a self-aliased pointer;
|
// Copy the linked decision before clearing lastSplice_ (guards a self-aliased pointer).
|
||||||
// 5 plain fields, negligible on the RT path).
|
|
||||||
const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{};
|
const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{};
|
||||||
lastSplice_ = SpliceEvent{}; // cleared every frame; set again if this frame splices
|
lastSplice_ = SpliceEvent{}; // cleared every frame; set again if this frame splices
|
||||||
|
|
||||||
// 1. Write the incoming sample at the write head (source rate). One more slot of the
|
// Tail-frozen: the source is exhausted, `in` is padding, not stream — write nothing (the
|
||||||
// ring now holds valid history (capped at the ring length once it has wrapped).
|
// ring keeps its all-real final two windows) and hold the write head; read/splice/fade
|
||||||
// TAIL-FROZEN (GA3): the source is exhausted — `in` is padding, not stream. Write
|
// below run unchanged over the frozen content.
|
||||||
// NOTHING (the ring keeps its all-real final two windows) and hold the write head;
|
|
||||||
// the read/splice/fade machinery below runs unchanged over the frozen content.
|
|
||||||
if (!tailFrozen_) {
|
if (!tailFrozen_) {
|
||||||
ring_[static_cast<std::size_t>(writePos_)] = in;
|
ring_[static_cast<std::size_t>(writePos_)] = in;
|
||||||
if (filled_ < ringLen_) ++filled_;
|
if (filled_ < ringLen_) ++filled_;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Read the active tap; while a splice fade is live, crossfade against the outgoing tap.
|
// Read the active tap; while a splice fade is live, crossfade against the outgoing tap.
|
||||||
// Raised-cosine COMPLEMENTARY gains (gNew + gOld == 1): correlation-aligned content is
|
// Raised-cosine complementary gains (gNew + gOld == 1): correlation-aligned content is in
|
||||||
// in phase, so the sum holds unity amplitude through the fade (equal-power would bulge).
|
// phase, so the sum holds unity amplitude through the fade (equal-power would bulge).
|
||||||
double out = readTap(posA_);
|
double out = readTap(posA_);
|
||||||
if (fading_) {
|
if (fading_) {
|
||||||
const double t = static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
|
const double t = static_cast<double>(fadePos_) / static_cast<double>(fadeLen_);
|
||||||
@@ -372,22 +341,17 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
|
|||||||
out = gNew * out + (1.0 - gNew) * readTap(posB_);
|
out = gNew * out + (1.0 - gNew) * readTap(posB_);
|
||||||
if (++fadePos_ >= fadeLen_) fading_ = false;
|
if (++fadePos_ >= fadeLen_) fading_ = false;
|
||||||
} else if (linked != nullptr) {
|
} else if (linked != nullptr) {
|
||||||
// 3a. FOLLOWER (T1-01): no trigger test, no search — splice exactly when and how the
|
// Follower: no trigger test, no search — splice exactly when and how the master did
|
||||||
// master channel did this frame. Lockstep state means our own trigger would have
|
// this frame (lockstep means our own trigger would have fired the same frame anyway).
|
||||||
// fired on the same frame; applying the master's decision keeps the two rings
|
|
||||||
// sample-aligned (one shared lag, one shared schedule).
|
|
||||||
if (linkedEv.fired) {
|
if (linkedEv.fired) {
|
||||||
applySplice(linkedEv);
|
applySplice(linkedEv);
|
||||||
} else {
|
} else {
|
||||||
// Self-healing fallback (review rider): the master not firing normally means this
|
// Self-healing fallback: if the processor ever renders a mono block mid-note, this
|
||||||
// channel's own trigger wouldn't fire either (lockstep). But if the processor ever
|
// follower is skipped for that block while the master keeps advancing, and could
|
||||||
// renders a mono block mid-note, this follower channel is skipped for that block
|
// never resync via the `linkedEv.fired` path alone. So also check this follower's
|
||||||
// while the master keeps advancing — its writePos_/filled_ falls behind and, with
|
// own tap distance against the safe band and splice via its own search when it has
|
||||||
// only the `if (linkedEv.fired)` path above, could never resync. So check this
|
// left [dLow_, dHigh_] — never triggers in the normal (non-mono-block) case, since
|
||||||
// follower's OWN tap distance against the safe band and splice via its own search
|
// the master's trigger always fires first.
|
||||||
// when it has left [dLow_, dHigh_], exactly as the master would. Reuses splice() —
|
|
||||||
// no allocation, no new RT cost. In the normal (non-mono-block) case this branch
|
|
||||||
// never triggers: the master's trigger fires first and this whole `if` is false.
|
|
||||||
double d = static_cast<double>(writePos_) - posA_;
|
double d = static_cast<double>(writePos_) - posA_;
|
||||||
const double len = static_cast<double>(ringLen_);
|
const double len = static_cast<double>(ringLen_);
|
||||||
while (d < 0.0) d += len;
|
while (d < 0.0) d += len;
|
||||||
@@ -399,10 +363,10 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 3. Splice scheduling: relocate when the active tap's delay leaves the safe band.
|
// Splice scheduling: relocate when the active tap's delay leaves the safe band.
|
||||||
// Up-shifts (ratio > 1) drain the delay toward 0 -> jump one window OLDER; down-
|
// Up-shifts drain the delay toward 0 -> jump one window older; down-shifts grow it
|
||||||
// shifts grow it toward the ring length -> jump one window TOWARD the writer. At
|
// toward the ring length -> jump one window toward the writer. At unity the delay is
|
||||||
// unity the delay is frozen at window/2 and neither trigger ever fires.
|
// frozen at window/2 and neither trigger ever fires.
|
||||||
double d = static_cast<double>(writePos_) - posA_;
|
double d = static_cast<double>(writePos_) - posA_;
|
||||||
const double len = static_cast<double>(ringLen_);
|
const double len = static_cast<double>(ringLen_);
|
||||||
while (d < 0.0) d += len;
|
while (d < 0.0) d += len;
|
||||||
@@ -414,8 +378,7 @@ AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Advance heads: write head one frame (source rate; parked while tail-frozen),
|
// Advance heads: write head one frame (parked while tail-frozen), tap(s) by the shift ratio.
|
||||||
// tap(s) by the shift ratio.
|
|
||||||
if (!tailFrozen_) {
|
if (!tailFrozen_) {
|
||||||
++writePos_;
|
++writePos_;
|
||||||
if (writePos_ >= ringLen_) writePos_ = 0;
|
if (writePos_ >= ringLen_) writePos_ = 0;
|
||||||
|
|||||||
@@ -1,66 +1,34 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// pitch_shift — a PURE, per-voice, duration-preserving pitch shifter: the S16 "Preserve"
|
// pitch_shift — per-voice, duration-preserving pitch shifter (the Preserve engine's DSP core).
|
||||||
// engine's DSP core. Time-domain delay-line shifter with CORRELATION-ALIGNED SPLICES
|
// Time-domain delay-line with correlation-aligned splices (SOLA-style): one active read tap
|
||||||
// (SOLA-style): one active read tap chases the write head at the shift ratio; when it drifts
|
// chases the write head at the shift ratio; when it drifts out of its safe delay band it is
|
||||||
// out of its safe delay band it is relocated by a nominal window jump REFINED BY A
|
// relocated by a nominal window jump, refined by a cross-correlation search so the new read
|
||||||
// CROSS-CORRELATION SEARCH so the new read point is waveform-aligned, then the old and new
|
// point is waveform-aligned, then old/new taps crossfade (raised-cosine). Source and output are
|
||||||
// taps are crossfaded (raised-cosine, amplitude-complementary). Source is consumed 1:1 and
|
// both consumed/produced 1:1 — only pitch changes, duration is held (unlike the Varispeed
|
||||||
// output produced 1:1 (duration held); only the PITCH changes — an octave up plays the same
|
// `readPos_ += ratio_` resample path).
|
||||||
// wall-clock length as the root note, unlike the Varispeed `readPos_ += ratio_` resample path.
|
|
||||||
//
|
//
|
||||||
// WHY CORRELATED SPLICES (GA-Preserve fix, 2026-07). The first S16 implementation was the
|
// Regression history — do not revert any of these:
|
||||||
// naive two-tap OLA: taps hard-locked half a window apart, Hann-crossfaded by write-head
|
// - Correlated splices, vs. the original two-tap OLA (taps hard-locked w/2 apart, Hann
|
||||||
// distance. Its taps read the same stream at delays differing by exactly w/2, so their outputs
|
// crossfaded by write-head distance): that fixed offset gave the two taps a fixed relative
|
||||||
// carried a FIXED relative phase of 2*pi*f_src*(w/2) — arbitrary and source-frequency-
|
// phase, so near-anti-phase source frequencies (roughly half of them) nearly cancelled at
|
||||||
// dependent. Near anti-phase (roughly half of all frequencies) every crossfade midpoint
|
// every crossfade midpoint — a repitched pure sine came out mangled while unity stayed clean.
|
||||||
// nearly CANCELLED: deep periodic AM + phase slew = strong sidebands. A repitched pure sine
|
// Splices must be phase-aligned (snapped to the best waveform match), not just distance-fired.
|
||||||
// came out mangled ("multiple partials" on a spectrogram) while the root stayed clean (unity
|
// - Hand-rolled, not WDL_SimplePitchShifter: its include chain pulls <windows.h> unconditionally,
|
||||||
// freezes the crossfade). The fix is structural: splices must be PHASE-ALIGNED, so each jump
|
// which cannot enter this REAPER/VST3-free core (sampler_core_tests links neither SDK). Swap
|
||||||
// is snapped to the best waveform match within a bounded lag search — a pure sine's jump
|
// to WDL, if ever wanted, happens at the shell, never in this pure core.
|
||||||
// lands on an integer period count and the output stays a single shifted tone.
|
// - prime() fills the ring with real upcoming source before streaming starts, not silence: a
|
||||||
|
// silence-warmed ring made every early splice land in zeros — burst/gap/burst stutter at
|
||||||
|
// note onset. Since the caller owns the whole decoded sample up front, prime() can know the
|
||||||
|
// future and gives output frame 0 == source frame 0 with zero structural latency at any ratio.
|
||||||
|
// - freezeTail() parks the write head once the source is exhausted instead of feeding the last
|
||||||
|
// real sample as a DC plateau: splices against a flat plateau are unalignable and produced
|
||||||
|
// ring-modulation-like troughs near the note end. Freezing keeps late splices aligned against
|
||||||
|
// the ring's real frozen tail.
|
||||||
//
|
//
|
||||||
// WHY A HAND-ROLLED PURE MODULE, NOT WDL (S16-F2, decided at build). The spec's lean was
|
// RT discipline: configure() sizes the ring once, off the audio thread. prime()/warm() only
|
||||||
// route (a) `WDL_SimplePitchShifter`. But its include chain
|
// copy into the pre-sized ring (bounded, allocation-free). process() does no allocation and no
|
||||||
// (simple_pitchshift.h -> queue.h -> heapbuf.h -> wdltypes.h) does `#ifdef _WIN32 ->
|
// locks; the correlation search is a bounded burst that fires once per splice cadence
|
||||||
// #include <windows.h>` unconditionally, which CANNOT enter the pure sampler_core module
|
// (window / |ratio-1| frames), never per frame.
|
||||||
// (CLAUDE.md load-bearing split: NO vendor/host/SDK types; sampler_core_tests links neither
|
|
||||||
// SDK and compiles outside the DAW). So the Preserve DSP lands as route (b): a house-native
|
|
||||||
// pure module alongside peaks / wav_codec, CTest-testable, RT-disciplined. Same
|
|
||||||
// PitchEngine::Preserve contract behind the seam — if WDL is ever preferred it swaps in at
|
|
||||||
// the SHELL, never in the pure core.
|
|
||||||
//
|
|
||||||
// WHY PRIME WITH REAL CONTENT (GA2-Preserve onset fix, 2026-07). Splices RELOCATE the tap
|
|
||||||
// into ring HISTORY — at note onset a silence-warmed ring has none, so every early splice
|
|
||||||
// jumped into zeros: a burst/gap/burst stutter for the first ~2 windows of every off-root
|
|
||||||
// note (the DAW "zero-sample gaps in the first few ms"; at +48 st the ~300 Hz gap cadence
|
|
||||||
// reads as a square-ish buzz). But this engine is NOT a streaming context: the caller owns
|
|
||||||
// the whole decoded sample, so the FUTURE of the stream is known at note-on. `prime()`
|
|
||||||
// pre-fills the ring with the actual first window of upcoming source and parks the tap on
|
|
||||||
// its oldest frame — output frame 0 IS source frame 0 (zero structural latency at every
|
|
||||||
// ratio), and `splice()` clamps its jump to the really-filled span so no splice can ever
|
|
||||||
// land in unwritten silence.
|
|
||||||
//
|
|
||||||
// WHY FREEZE THE TAIL (GA3-Preserve tail fix, 2026-07). GA2's prime fixed the ONSET; the
|
|
||||||
// mirror problem lived at the note END. When the source ran out, the caller held the LAST
|
|
||||||
// REAL SAMPLE as the feed — a DC plateau with no waveform for the correlation to align on.
|
|
||||||
// Splices landing in or referenced against it were unalignable, so the tap alternated
|
|
||||||
// real-tone / dead-DC at the splice cadence, the dead fraction growing as the plateau
|
|
||||||
// displaced real ring history (the DAW report: periodic troughs "almost like ring
|
|
||||||
// modulation", ~1:20 tone-to-silence at the very end). freezeTail() removes the padding at
|
|
||||||
// the source: the WRITER parks, the ring keeps its all-real final two windows, and the
|
|
||||||
// aligned-splice machinery recycles that frozen tail — a continuous tone until the caller's
|
|
||||||
// own note end. See freezeTail() below.
|
|
||||||
//
|
|
||||||
// PURE MODULE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes. Standard library only.
|
|
||||||
// Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core /
|
|
||||||
// wav_codec does the same).
|
|
||||||
//
|
|
||||||
// RT DISCIPLINE (S16 hard constraint). `configure()` sizes the ring ONCE (off the audio
|
|
||||||
// thread, at voice allocation). `prime()` / `warm()` only copy into the pre-sized ring
|
|
||||||
// (bounded, allocation-free — safe on the audio thread at note-on). `process()` does
|
|
||||||
// NO allocation and NO locks — it reads/writes the pre-sized ring only. The splice-time
|
|
||||||
// correlation search is a bounded burst of multiply-adds (coarse+refine over a fixed lag
|
|
||||||
// range) that fires once per splice cadence (window / |ratio-1| frames), never per frame.
|
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
@@ -72,100 +40,76 @@ namespace reasampler::instrument::engine {
|
|||||||
|
|
||||||
using audio::AudioSample;
|
using audio::AudioSample;
|
||||||
|
|
||||||
// The splice decision made by the most recent process()/processLinked() call — the LINKED-LAG
|
// The splice decision made by the most recent process()/processLinked() call — the linked-lag
|
||||||
// stereo contract (Q-W0 T1-01). A stereo voice runs channel 0 as the MASTER (full correlation
|
// stereo contract. A stereo voice runs channel 0 as the master (full correlation search) and
|
||||||
// search) and channel 1 as the FOLLOWER: after the master's process() for a frame, the caller
|
// channel 1 as the follower: after the master's process() for a frame, the caller passes
|
||||||
// passes master.lastSplice() to the follower's processLinked() for the SAME frame, and the
|
// master.lastSplice() to the follower's processLinked() for the same frame, and the follower
|
||||||
// follower applies exactly this decision instead of running its own search. Both channels
|
// applies exactly this decision instead of running its own search. Both channels therefore
|
||||||
// therefore share one lag and one splice schedule (standard stereo SOLA) — per-channel
|
// share one lag and one splice schedule — independent per-channel searches drew an inter-channel
|
||||||
// independent searches re-drew an inter-channel offset of up to +/-maxLag at every splice:
|
// offset of up to +/-maxLag at every splice, causing stereo image wander and comb coloration on
|
||||||
// stereo image wander at the splice cadence plus comb coloration on any mono sum.
|
// a mono sum.
|
||||||
struct SpliceEvent {
|
struct SpliceEvent {
|
||||||
bool fired = false; // a splice was scheduled on this frame
|
bool fired = false; // a splice was scheduled on this frame
|
||||||
std::int64_t jump = 0; // the CLAMPED nominal jump actually applied (signed)
|
std::int64_t jump = 0; // the clamped nominal jump actually applied (signed)
|
||||||
std::int64_t lag = 0; // correlation best integer lag
|
std::int64_t lag = 0; // correlation best integer lag
|
||||||
double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5]
|
double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5]
|
||||||
std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen
|
std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen
|
||||||
};
|
};
|
||||||
|
|
||||||
// A per-channel time-domain splice-aligned pitch shifter. One instance transposes ONE channel;
|
// A per-channel time-domain splice-aligned pitch shifter. A stereo voice owns two, linked:
|
||||||
// a stereo voice owns two, LINKED: channel 0 is the master, channel 1 follows its splice
|
// channel 0 is the master, channel 1 follows its splice decisions via processLinked() so the
|
||||||
// decisions via processLinked() (see SpliceEvent above) so the two rings stay sample-aligned.
|
// two rings stay sample-aligned.
|
||||||
//
|
//
|
||||||
// The default-constructed shifter is INERT: with no configure() it passes input through
|
// Default-constructed is inert: with no configure() it passes input through unchanged (ratio
|
||||||
// unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is
|
// 1.0, empty ring), so a Varispeed voice that never touches it sees no behavior change.
|
||||||
// byte-identical to the pre-S16 engine.
|
|
||||||
class PitchShifter {
|
class PitchShifter {
|
||||||
public:
|
public:
|
||||||
// Size the delay ring for `windowFrames` (the nominal splice-jump length; the ring is 2x
|
// Sizes the delay ring for `windowFrames` (the ring is 2x that for splice/search headroom).
|
||||||
// that for splice/search headroom) and derive the fade/search geometry. `windowFrames`
|
// <= 1 degrades to pass-through. Off the audio thread (allocates); resets all running state.
|
||||||
// <= 1 degrades to pass-through (no ring), so a degenerate configure never divides by
|
// A larger window means fewer splices and a deeper alignment search; a primed shifter has no
|
||||||
// zero or wraps a zero span. Called OFF the audio thread (allocates). Resets all running
|
// added latency regardless of window size (see prime()).
|
||||||
// state. A larger window = fewer splices and a deeper alignment search; a PRIMED shifter
|
|
||||||
// has no added latency regardless (see prime()); the shell picks it from kPreserveWindowMs.
|
|
||||||
void configure(std::int64_t windowFrames);
|
void configure(std::int64_t windowFrames);
|
||||||
|
|
||||||
// Pre-fill the ring with the first `count` frames of the UPCOMING source stream and park
|
// Pre-fills the ring with the first `count` frames of the upcoming source stream and parks
|
||||||
// the tap on src[0] (delay == count, mid safe band at count == window()). The caller then
|
// the tap on src[0]; the caller then feeds process() the stream continuing at src[count].
|
||||||
// feeds process() the stream CONTINUING at src[count]. Output frame 0 is src[0]: ZERO
|
// `count` is clamped to [0, window()]. If the playable source is shorter than one window,
|
||||||
// structural latency at every ratio, and splices always have `count` frames of real
|
// prime only the real span and call freezeTail() immediately after — never pad with silence
|
||||||
// history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()].
|
// and declare it valid; padded zeros are splice targets and reintroduce the onset gap.
|
||||||
// When the PLAYABLE source is shorter than one window, prime only the real span and call
|
// RT-safe: bounded copy, no allocation. No-op when unconfigured; ratio is left untouched.
|
||||||
// freezeTail() immediately after (Q-W0 T1-03): the GA3 machinery then recycles the real
|
|
||||||
// short tail. Do NOT pad with silence and declare it valid — padded zeros inside the ring
|
|
||||||
// are splice targets, re-creating the pre-GA2 burst/gap onset on sub-window material.
|
|
||||||
// RT-safe: bounded copy into the pre-sized ring, no allocation. No-op when unconfigured.
|
|
||||||
// The current shift ratio is left untouched.
|
|
||||||
void prime(const AudioSample* src, std::int64_t count);
|
void prime(const AudioSample* src, std::int64_t count);
|
||||||
|
|
||||||
// prime()-with-silence: zero the ring, park the tap one window behind the writer, and
|
// Silence-prime: zero the ring, park the tap one window behind the writer, declare that
|
||||||
// declare that window of silence as valid history. Kept for callers with no access to the
|
// window silence as valid history. Kept for callers with no access to the upcoming stream
|
||||||
// upcoming stream (a silence-primed up-shift plays ~a window of silence before speaking —
|
// (a silence-primed up-shift plays ~a window of silence before speaking; the Voice path uses
|
||||||
// the pre-GA2 onset; the Voice path uses prime() instead). At unity a warmed shifter is a
|
// prime() instead). At unity a warmed shifter is a bit-exact window() delay.
|
||||||
// bit-exact window() delay. No-op when unconfigured.
|
|
||||||
void warm();
|
void warm();
|
||||||
|
|
||||||
// The pitch shift ratio: 2^((note - root)/12) plus any per-frame pitch-envelope bias.
|
// 2^((note - root)/12) plus any per-frame pitch-envelope bias; 1.0 = no shift, no splices
|
||||||
// 1.0 = no shift (pass-through-equivalent output, no splices ever fire). Set per frame is
|
// ever fire. Cheap enough to set per frame. Values <= 0 are ignored (kept at the last valid
|
||||||
// fine (cheap); the tap advance simply uses the current value. Values <= 0 are ignored
|
// ratio) so a bad input never runs the tap backward or stalls it.
|
||||||
// (kept at the last valid ratio) so a bad input never runs the tap backward or stalls it.
|
|
||||||
void setShiftRatio(double ratio);
|
void setShiftRatio(double ratio);
|
||||||
|
|
||||||
// Transform ONE input frame into ONE output frame (duration-preserving: 1 in, 1 out).
|
// Transforms one input frame into one output frame (1 in, 1 out). RT-safe: reads/writes the
|
||||||
// RT-safe: reads/writes the pre-sized ring only, no allocation, no lock. When unconfigured
|
// pre-sized ring only, no allocation, no lock. Unconfigured returns `in` unchanged. Otherwise
|
||||||
// (window <= 1) returns `in` unchanged (pass-through). Otherwise writes `in` at the write
|
// writes `in` at the write head, reads the active tap (crossfading against the outgoing tap
|
||||||
// head, reads the active tap (crossfading against the outgoing tap while a splice fade is
|
// during a splice fade), then advances the write head and tap(s). When the active tap leaves
|
||||||
// live), then advances the write head by one and the tap(s) by the shift ratio. When the
|
// its safe delay band, a correlation-aligned splice is scheduled.
|
||||||
// active tap leaves its safe delay band, a correlation-aligned splice is scheduled.
|
|
||||||
AudioSample process(AudioSample in);
|
AudioSample process(AudioSample in);
|
||||||
|
|
||||||
// FOLLOWER-mode process (Q-W0 T1-01, the stereo linked lag): identical to process()
|
// Follower-mode process: identical to process() except the splice decision isn't computed
|
||||||
// except the splice decision is NOT computed here — when `master.fired` is true this
|
// here — when `master.fired` is true this frame splices with exactly the master's
|
||||||
// frame splices with exactly the master's jump/lag/frac/fadeLen; otherwise no splice is
|
// jump/lag/frac/fadeLen. The caller must process the master channel first each frame and
|
||||||
// considered. The caller must process the master channel FIRST each frame and pass its
|
// pass its lastSplice() here; both shifters must be configured/primed/ratio'd identically so
|
||||||
// lastSplice() here, with both shifters configured/primed/ratio'd identically — their
|
// their ring state advances in lockstep. RT-safe: same guarantees as process().
|
||||||
// ring state then advances in lockstep, so the follower's own trigger would have fired
|
|
||||||
// on the same frame anyway; skipping its search only removes the second correlation
|
|
||||||
// burst (strictly cheaper, never costlier). RT-safe: same guarantees as process().
|
|
||||||
AudioSample processLinked(AudioSample in, const SpliceEvent& master);
|
AudioSample processLinked(AudioSample in, const SpliceEvent& master);
|
||||||
|
|
||||||
// The splice decision made by the most recent process()/processLinked() call (fired ==
|
|
||||||
// false when that frame spliced nothing). Feed to a follower channel's processLinked().
|
|
||||||
const SpliceEvent& lastSplice() const { return lastSplice_; }
|
const SpliceEvent& lastSplice() const { return lastSplice_; }
|
||||||
|
|
||||||
// TAIL WIND-DOWN (GA3, 2026-07). Call when the SOURCE STREAM IS EXHAUSTED — no real frame
|
// Call once the source stream is exhausted — no real frame remains to feed process().
|
||||||
// remains to feed process(). Freezes the WRITE head: subsequent process() calls ignore
|
// Freezes the write head: subsequent process() calls ignore input and write nothing, but
|
||||||
// their input and write nothing, but read, splice, and crossfade exactly as before over
|
// read/splice/crossfade as before over the ring's frozen (all-real) final two windows, so
|
||||||
// the ring's frozen (all-real) final two windows. WHY: the pre-GA3 tail held the last
|
// every late splice stays waveform-aligned against real content instead of a DC plateau.
|
||||||
// real sample as the feed — a DC plateau with no waveform to correlate on. Splices
|
// Idempotent; RT-safe (flag + bounded arithmetic); cleared by reset()/prime()/warm().
|
||||||
// landing in or referenced against it were unalignable, so the tap alternated real-tone /
|
|
||||||
// dead-DC at the splice cadence (the DAW "ring modulation" troughs, growing toward the
|
|
||||||
// note end as the plateau displaced real history). With the writer frozen the padding
|
|
||||||
// never enters the ring: every splice stays waveform-aligned against real content and
|
|
||||||
// the output remains a continuous tone — the final <= one window recycles the frozen
|
|
||||||
// tail (correlation-aligned, crossfaded) instead of decaying into chopped DC, and the
|
|
||||||
// caller's own note end (its output-frame anchor) bounds how long that lasts. Idempotent;
|
|
||||||
// RT-safe (flag + bounded arithmetic, no allocation); cleared by reset()/prime()/warm().
|
|
||||||
void freezeTail();
|
void freezeTail();
|
||||||
|
|
||||||
bool tailFrozen() const { return tailFrozen_; }
|
bool tailFrozen() const { return tailFrozen_; }
|
||||||
@@ -188,8 +132,8 @@ private:
|
|||||||
// the writer (the caller just computed it for the trigger test). Records the decision in
|
// the writer (the caller just computed it for the trigger test). Records the decision in
|
||||||
// lastSplice_ for a linked follower channel.
|
// lastSplice_ for a linked follower channel.
|
||||||
void splice(std::int64_t nominalJump, double delay);
|
void splice(std::int64_t nominalJump, double delay);
|
||||||
// Apply a master channel's already-computed splice decision verbatim (no search) —
|
// Applies a master channel's already-computed splice decision verbatim (no search) —
|
||||||
// the follower half of the T1-01 linked-lag contract. Mirrors it into lastSplice_.
|
// the follower half of the linked-lag contract. Mirrors it into lastSplice_.
|
||||||
void applySplice(const SpliceEvent& ev);
|
void applySplice(const SpliceEvent& ev);
|
||||||
// Shared body of process()/processLinked(); `linked` null = master mode (own trigger +
|
// Shared body of process()/processLinked(); `linked` null = master mode (own trigger +
|
||||||
// search), non-null = follower mode (splice iff linked->fired, with linked's decision).
|
// search), non-null = follower mode (splice iff linked->fired, with linked's decision).
|
||||||
@@ -210,21 +154,19 @@ private:
|
|||||||
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4)
|
std::int64_t maxLag_ = 0; // correlation search half-range (window_/4)
|
||||||
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so
|
std::int64_t corrFrames_ = 0; // correlation segment length (dLow_-1, capped at 512, so
|
||||||
// the reference read forward from the tap stays behind
|
// the reference read forward from the tap stays behind
|
||||||
// the writer BY CONSTRUCTION at an up-splice)
|
// the writer by construction at an up-splice)
|
||||||
std::int64_t dLow_ = 0; // splice trigger: active-tap delay below this (up-shift)
|
std::int64_t dLow_ = 0; // splice trigger: active-tap delay below this (up-shift)
|
||||||
std::int64_t dHigh_ = 0; // splice trigger: active-tap delay above this (down-shift)
|
std::int64_t dHigh_ = 0; // splice trigger: active-tap delay above this (down-shift)
|
||||||
std::int64_t filled_ = 0; // frames of VALID history behind the writer (prime count
|
std::int64_t filled_ = 0; // frames of valid history behind the writer; splice()
|
||||||
// + frames streamed, capped at ringLen_). splice() clamps
|
// clamps its up-jump to this so it never lands in
|
||||||
// its up-jump to this so no splice lands in unwritten
|
// unwritten silence
|
||||||
// silence — the GA2 onset-gap fix.
|
|
||||||
double ratio_ = 1.0; // current shift ratio (>0)
|
double ratio_ = 1.0; // current shift ratio (>0)
|
||||||
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame (T1-01):
|
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame; cleared
|
||||||
// cleared at the top of every frame, set on a splice
|
// at the top of every frame, set on a splice
|
||||||
bool tailFrozen_ = false; // GA3 wind-down: writer frozen (source exhausted); the tap
|
bool tailFrozen_ = false; // writer frozen (source exhausted); tap recycles the
|
||||||
// recycles the ring's frozen real tail, splices still
|
// frozen real tail, drains toward the writer at ratio_
|
||||||
// aligned. With the writer parked, a tap drains toward it
|
// (not ratio_-1) per frame — splice() scales the fade
|
||||||
// at ratio_ (not ratio_-1) per frame — splice() scales the
|
// by that rate
|
||||||
// live fade by that rate.
|
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace reasampler::instrument::engine
|
} // namespace reasampler::instrument::engine
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#pragma once
|
||||||
|
// play_params.h — the instrument's one set of playback-parameter value structs plus the
|
||||||
|
// per-instance mode enums, shared by the engine, sample_map, the ComponentState codec, and
|
||||||
|
// the editor. Split out of the engine headers so a UI/codec TU reading a param struct
|
||||||
|
// doesn't recompile when a Voice/VoiceEngine member changes. The per-frame evaluators live
|
||||||
|
// in envelopes.h; the engine in voice.h / voice_engine.h.
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/audio/peaks.h"
|
||||||
|
#include "core/instrument/engine/filter/voice_filter.h"
|
||||||
|
#include "core/instrument/engine/velocity_curve.h"
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
using audio::AudioSample;
|
||||||
|
using instrument::engine::VelocityCurve;
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
|
||||||
|
// 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. RETRIGGER restarts amp/pitch envelopes on every new
|
||||||
|
// mono note. LEGATO keeps the envelope running across a takeover (pitch moves without a
|
||||||
|
// re-attack). With one loaded capture every takeover is same-sample, so Legato always glides.
|
||||||
|
enum class MonoTrigger { Retrigger, Legato };
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
std::int64_t decayFrames = 0;
|
||||||
|
double sustainLevel = 1.0; // 0..1
|
||||||
|
std::int64_t releaseFrames = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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. Default Gate so an instrument with no params set plays as before.
|
||||||
|
enum class PlayMode { Gate, 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;
|
||||||
|
std::int64_t fadeOutFrames = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
|
||||||
|
// Product default is Preserve, but applied at the state boundary (the codec's read path /
|
||||||
|
// the editor's default params), NOT here: PlayParams.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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
std::int64_t decayFrames = 0;
|
||||||
|
double peakSemitones = 0.0; // signed depth at the peak
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-voice resonant filter, off by default (enabled=false -> the render path skips it
|
||||||
|
// entirely -> bit-identical to the un-filtered engine). Holds the filter module's OWN
|
||||||
|
// normalized control positions verbatim rather than a parallel set, so no control range is
|
||||||
|
// re-derived here; `filter_params.h` owns every law that maps them to Hz/Q/depth.
|
||||||
|
//
|
||||||
|
// The three modulation depths below land in that same normalized cutoff domain and sum
|
||||||
|
// before a single clamp; all three are zero/neutral by default.
|
||||||
|
struct FilterParams {
|
||||||
|
bool enabled = false;
|
||||||
|
instrument::engine::filter::FilterSettings settings;
|
||||||
|
double modAmount = 0.0; // bipolar [-1,+1], envelope -> cutoff
|
||||||
|
double velAmount = 0.0; // bipolar [-1,+1], velocity -> cutoff
|
||||||
|
double keyTrack = 0.0; // octaves of cutoff per octave of (note - root)
|
||||||
|
AdsrParams env; // the same staged AHDSR the amp runs; frames
|
||||||
|
// Shapes velocity before velAmount scales it. Linear rather than the amp's flat() default
|
||||||
|
// because a flat curve under a depth control would make every velocity the same offset;
|
||||||
|
// the no-op at rest is velAmount == 0, not the curve. NOTE: this default only governs a
|
||||||
|
// FRESH FilterParams — the shared codec's corrupt/truncated-point-list repair
|
||||||
|
// (VelocityCurve::fromPoints, used for both this curve and the amp's) still degrades to
|
||||||
|
// flat() regardless, since that repair has no curve-specific fallback.
|
||||||
|
VelocityCurve velocityCurve = VelocityCurve::linear();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bundle a voice reads at start(). Defaults reproduce the bare engine (Gate, hold-0 AHDSR,
|
||||||
|
// Varispeed, pitch envelope off, filter off) — core regression tests rely on this; the
|
||||||
|
// Preserve product default is layered on at (de)serialization, see kDefaultPitchEngine.
|
||||||
|
struct PlayParams {
|
||||||
|
PlayMode playMode = PlayMode::Gate;
|
||||||
|
AdsrParams adsr;
|
||||||
|
TriggerParams trigger;
|
||||||
|
PitchEngine pitchEngine = PitchEngine::Varispeed;
|
||||||
|
PitchEnvParams pitchEnv;
|
||||||
|
FilterParams filter;
|
||||||
|
};
|
||||||
|
|
||||||
|
// [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;
|
||||||
|
std::int64_t end = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The one loaded capture the core plays: decoded PCM plus every parameter governing playback.
|
||||||
|
// The shell decodes the on-disk WAV and fills this; the core never touches a file.
|
||||||
|
//
|
||||||
|
// 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 the read head / rootNote / loop, so repitch and loop stay per-frame identical
|
||||||
|
// across channels. `rootNote` is the MIDI note the file was recorded at — unity ratio there.
|
||||||
|
struct SampleData {
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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 repitch engines via the voice's baseRatio_.
|
||||||
|
double keyTrack = 1.0;
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
PlayParams play;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing decoded -> nothing to play; the engine refuses a note-on rather than starting a
|
||||||
|
// voice on an empty read span.
|
||||||
|
bool playable() const { return !frames.empty(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,605 +0,0 @@
|
|||||||
#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.
|
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
|
|
||||||
#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)
|
|
||||||
|
|
||||||
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.
|
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// 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).
|
|
||||||
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_.
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
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).
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
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_.
|
|
||||||
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).
|
|
||||||
//
|
|
||||||
// Segment math (all linear ramps):
|
|
||||||
// Attack: 0 -> 1 over attackFrames
|
|
||||||
// Hold: hold 1 over holdFrames (S15: NEW stage between A and D)
|
|
||||||
// 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.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class AdsrEnvelope {
|
|
||||||
public:
|
|
||||||
enum class Stage { Idle, Attack, Hold, Decay, Sustain, Release, Finished };
|
|
||||||
|
|
||||||
void configure(const AdsrParams& params) { params_ = params; }
|
|
||||||
|
|
||||||
// Gate on: (re)start from Attack.
|
|
||||||
void noteOn();
|
|
||||||
// Gate off: enter Release from the current level.
|
|
||||||
void noteOff();
|
|
||||||
|
|
||||||
// Advances one frame and returns the amplitude for THIS frame (before advancing).
|
|
||||||
// Once Release completes the envelope latches Finished and returns 0.0 forever
|
|
||||||
// (until the next noteOn). A single, monotonic per-frame step — the caller pulls
|
|
||||||
// one value per output frame.
|
|
||||||
double tick();
|
|
||||||
|
|
||||||
Stage stage() const { return stage_; }
|
|
||||||
bool finished() const { return stage_ == Stage::Finished; }
|
|
||||||
double level() const { return level_; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
AdsrParams params_;
|
|
||||||
Stage stage_ = Stage::Idle;
|
|
||||||
double level_ = 0.0;
|
|
||||||
std::int64_t framesInStage_ = 0;
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
double amplitudeAt(double sourceOffset);
|
|
||||||
|
|
||||||
bool finished() const { return finished_; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::int64_t playLength_ = 0;
|
|
||||||
std::int64_t fadeIn_ = 0;
|
|
||||||
std::int64_t fadeOut_ = 0;
|
|
||||||
FadeCurve curve_ = kDefaultFadeCurve;
|
|
||||||
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.
|
|
||||||
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:
|
|
||||||
PitchEnvParams params_;
|
|
||||||
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.
|
|
||||||
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)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// A single voice: one active note playing one repitched, enveloped sample. Reads
|
|
||||||
// the sample by fractional frame position with linear interpolation, advancing by
|
|
||||||
// the pitch ratio; loops the sustain region for held notes past the loop end.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
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.
|
|
||||||
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).
|
|
||||||
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).
|
|
||||||
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.
|
|
||||||
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).
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
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).
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
bool sustainLoopUsable() const;
|
|
||||||
|
|
||||||
bool active_ = false;
|
|
||||||
bool releasing_ = false;
|
|
||||||
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 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).
|
|
||||||
PlayMode playMode_ = PlayMode::Gate;
|
|
||||||
AdsrEnvelope env_;
|
|
||||||
TriggerEnvelope trigEnv_;
|
|
||||||
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
|
|
||||||
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.
|
|
||||||
//
|
|
||||||
// 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()).
|
|
||||||
PitchEngine pitchEngine_ = PitchEngine::Varispeed;
|
|
||||||
PitchEnvelope pitchEnv_;
|
|
||||||
PitchShifter shiftL_;
|
|
||||||
PitchShifter shiftR_;
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
bool declickPending_ = false;
|
|
||||||
bool declickActive_ = false;
|
|
||||||
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
|
|
||||||
double declickRefR_ = 0.0;
|
|
||||||
double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
|
|
||||||
double lastOutL_ = 0.0;
|
|
||||||
double lastOutR_ = 0.0;
|
|
||||||
|
|
||||||
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).
|
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
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
|
|
||||||
// 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.
|
|
||||||
//
|
|
||||||
// `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.
|
|
||||||
//
|
|
||||||
// `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.
|
|
||||||
VoiceEngine(std::size_t maxVoices, const Keymap& keymap,
|
|
||||||
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
|
|
||||||
VoiceMode voiceMode = VoiceMode::Poly,
|
|
||||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger,
|
|
||||||
bool takeoverDeclick = false);
|
|
||||||
|
|
||||||
// MIDI note-on. Resolves the note+velocity to a zone; if none matches (out of
|
|
||||||
// zone) it is a defined no-op (no voice consumed). Otherwise allocates a free
|
|
||||||
// voice, or steals one per the policy above. Returns the index of the voice used,
|
|
||||||
// or kNoVoice for an out-of-zone (unplayed) note.
|
|
||||||
std::size_t noteOn(int note, int velocity);
|
|
||||||
|
|
||||||
// MIDI note-off. Releases the most-recently-started active, non-releasing voice
|
|
||||||
// playing `note` (so a re-triggered same note releases the newest first, leaving
|
|
||||||
// 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).
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
void render(std::vector<AudioSample>& out, std::size_t frameCount);
|
|
||||||
|
|
||||||
// Count of currently active voices (for tests / diagnostics).
|
|
||||||
std::size_t activeVoiceCount() const;
|
|
||||||
|
|
||||||
std::size_t maxVoices() const { return voices_.size(); }
|
|
||||||
|
|
||||||
static constexpr std::size_t kNoVoice = static_cast<std::size_t>(-1);
|
|
||||||
|
|
||||||
private:
|
|
||||||
// Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen
|
|
||||||
// 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
|
|
||||||
// (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.
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
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::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
|
|
||||||
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.
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
@@ -2,20 +2,19 @@
|
|||||||
|
|
||||||
#include "core/instrument/engine/velocity_curve.h"
|
#include "core/instrument/engine/velocity_curve.h"
|
||||||
|
|
||||||
#include <algorithm> // std::max, std::min, std::abs, std::stable_sort
|
#include <algorithm>
|
||||||
#include <cmath> // std::fabs
|
#include <cmath>
|
||||||
#include <utility> // std::move
|
#include <utility>
|
||||||
|
|
||||||
namespace reasampler::instrument::engine {
|
namespace reasampler::instrument::engine {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
|
||||||
double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); }
|
double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); }
|
||||||
double clampAmp(double a) { return std::clamp(a, kAmpMin, kAmpMax); }
|
double clampAmp(double a) { return std::clamp(a, kAmpMin, kAmpMax); }
|
||||||
|
|
||||||
// Pixel<->box maps (mirror of envelope_edit's timeToX/levelToY). X spans the width for [0,127]; Y
|
// X spans the width for [0,127]; Y spans (height-1) rows for amp [0,1] with amp 1 at the TOP
|
||||||
// spans (height-1) rows for amp [0,1] with amp 1 at the TOP (y increases downward).
|
// (pixel y increases downward, so this axis is inverted relative to amp).
|
||||||
double velPerPixel(const VelocityCurve::Box& box) {
|
double velPerPixel(const VelocityCurve::Box& box) {
|
||||||
const int w = std::max(0, box.width);
|
const int w = std::max(0, box.width);
|
||||||
if (w <= 0) return 0.0;
|
if (w <= 0) return 0.0;
|
||||||
@@ -35,7 +34,6 @@ int velToX(const VelocityCurve::Box& box, double velocity) {
|
|||||||
int ampToY(const VelocityCurve::Box& box, double amp) {
|
int ampToY(const VelocityCurve::Box& box, double amp) {
|
||||||
const int h = std::max(0, box.height);
|
const int h = std::max(0, box.height);
|
||||||
if (h <= 1) return box.top;
|
if (h <= 1) return box.top;
|
||||||
// amp 1 at top (box.top), amp 0 at bottom (box.top + h - 1).
|
|
||||||
const double frac = (clampAmp(amp) - kAmpMin) / (kAmpMax - kAmpMin);
|
const double frac = (clampAmp(amp) - kAmpMin) / (kAmpMax - kAmpMin);
|
||||||
return box.top + static_cast<int>((1.0 - frac) * static_cast<double>(h - 1) + 0.5);
|
return box.top + static_cast<int>((1.0 - frac) * static_cast<double>(h - 1) + 0.5);
|
||||||
}
|
}
|
||||||
@@ -44,19 +42,19 @@ int ampToY(const VelocityCurve::Box& box, double amp) {
|
|||||||
|
|
||||||
VelocityCurve VelocityCurve::flat() {
|
VelocityCurve VelocityCurve::flat() {
|
||||||
VelocityCurve c;
|
VelocityCurve c;
|
||||||
c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}}; // y = 1 everywhere (R10-F1 Option A)
|
c.points_ = {{kVelMin, kAmpMax}, {kVelMax, kAmpMax}};
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
VelocityCurve VelocityCurve::linear() {
|
VelocityCurve VelocityCurve::linear() {
|
||||||
VelocityCurve c;
|
VelocityCurve c;
|
||||||
c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}}; // y = velocity/127
|
c.points_ = {{kVelMin, kAmpMin}, {kVelMax, kAmpMax}};
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
|
VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
|
||||||
// Box-clamp every point, then stable-sort by velocity (X-order; stable so coincident-X points
|
// Stable sort so coincident-X points keep their wire order (eval stays well-defined for
|
||||||
// keep their wire order). A stable sort keeps the eval well-defined for duplicate-X knots.
|
// duplicate-X knots).
|
||||||
for (VelocityPoint& p : pts) {
|
for (VelocityPoint& p : pts) {
|
||||||
p.velocity = clampVelocity(p.velocity);
|
p.velocity = clampVelocity(p.velocity);
|
||||||
p.amp = clampAmp(p.amp);
|
p.amp = clampAmp(p.amp);
|
||||||
@@ -65,18 +63,16 @@ VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
|
|||||||
[](const VelocityPoint& a, const VelocityPoint& b) {
|
[](const VelocityPoint& a, const VelocityPoint& b) {
|
||||||
return a.velocity < b.velocity;
|
return a.velocity < b.velocity;
|
||||||
});
|
});
|
||||||
// Fewer than 2 usable points -> can't span [0,127] as a function; fall back to the flat default.
|
|
||||||
if (pts.size() < 2) return flat();
|
if (pts.size() < 2) return flat();
|
||||||
// Force endpoints present at velocity 0 and 127 (they must exist for eval to be total).
|
|
||||||
if (pts.front().velocity > kVelMin) {
|
if (pts.front().velocity > kVelMin) {
|
||||||
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().amp});
|
pts.insert(pts.begin(), VelocityPoint{kVelMin, pts.front().amp});
|
||||||
} else {
|
} else {
|
||||||
pts.front().velocity = kVelMin; // snap a near-0 first point exactly onto the endpoint
|
pts.front().velocity = kVelMin;
|
||||||
}
|
}
|
||||||
if (pts.back().velocity < kVelMax) {
|
if (pts.back().velocity < kVelMax) {
|
||||||
pts.push_back(VelocityPoint{kVelMax, pts.back().amp});
|
pts.push_back(VelocityPoint{kVelMax, pts.back().amp});
|
||||||
} else {
|
} else {
|
||||||
pts.back().velocity = kVelMax; // snap a near-127 last point exactly onto the endpoint
|
pts.back().velocity = kVelMax;
|
||||||
}
|
}
|
||||||
VelocityCurve c;
|
VelocityCurve c;
|
||||||
c.points_ = std::move(pts);
|
c.points_ = std::move(pts);
|
||||||
@@ -85,19 +81,12 @@ VelocityCurve VelocityCurve::fromPoints(std::vector<VelocityPoint> pts) {
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
// Fritsch–Carlson monotone-cubic tangent for one interior knot i, given the secant slopes of the
|
// Fritsch-Carlson monotone-cubic tangent: a sign change (or flat) neighbour is a local extremum,
|
||||||
// two adjacent segments (dPrev = secant into knot i, dNext = secant out of knot i). Returns the
|
// so the tangent pins to 0 to avoid overshoot; otherwise the weighted-harmonic-mean tangent,
|
||||||
// limited tangent that keeps the cubic Hermite piece monotone and inside the data range.
|
// which for collinear knots (dPrev==dNext) reduces exactly to the shared secant — this is what
|
||||||
//
|
// makes the spline reproduce a straight line to ~1e-15 for linear()-style input.
|
||||||
// The rule: a tangent whose adjacent secants have opposite signs (or either is flat) is a local
|
|
||||||
// extremum — pin the tangent to 0 so the curve does not overshoot past the knot. Otherwise use the
|
|
||||||
// weighted-harmonic-mean tangent (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().
|
|
||||||
double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) {
|
double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double spanNext) {
|
||||||
if (dPrev * dNext <= 0.0) return 0.0; // sign change or a flat neighbour -> local extremum
|
if (dPrev * dNext <= 0.0) return 0.0;
|
||||||
// Weighted harmonic mean of the two secants (weights = the two segment widths). Collinear case:
|
|
||||||
// dPrev==dNext==d makes this (w1+w2)*d / ((w1+w2)/... ) collapse to d exactly.
|
|
||||||
const double w1 = 2.0 * spanNext + spanPrev;
|
const double w1 = 2.0 * spanNext + spanPrev;
|
||||||
const double w2 = spanNext + 2.0 * spanPrev;
|
const double w2 = spanNext + 2.0 * spanPrev;
|
||||||
return (w1 + w2) / (w1 / dPrev + w2 / dNext);
|
return (w1 + w2) / (w1 / dPrev + w2 / dNext);
|
||||||
@@ -106,33 +95,23 @@ double fritschCarlsonTangent(double dPrev, double dNext, double spanPrev, double
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
double VelocityCurve::eval(double velocity) const {
|
double VelocityCurve::eval(double velocity) const {
|
||||||
if (points_.empty()) return kAmpMax; // degenerate (shouldn't occur) -> flat unity
|
if (points_.empty()) return kAmpMax;
|
||||||
if (points_.size() == 1) return clampAmp(points_[0].amp); // 1-point -> that point's amp
|
if (points_.size() == 1) return clampAmp(points_[0].amp);
|
||||||
const double v = clampVelocity(velocity);
|
const double v = clampVelocity(velocity);
|
||||||
// At or before the first point / at or after the last, read the endpoint amp (the endpoints are
|
|
||||||
// at 0 and 127, so this only fires exactly at the ends for an in-range velocity).
|
|
||||||
if (v <= points_.front().velocity) return clampAmp(points_.front().amp);
|
if (v <= points_.front().velocity) return clampAmp(points_.front().amp);
|
||||||
if (v >= points_.back().velocity) return clampAmp(points_.back().amp);
|
if (v >= points_.back().velocity) return clampAmp(points_.back().amp);
|
||||||
// Find the segment [points_[i], points_[i+1]] containing v (X-ordered, so a linear scan).
|
|
||||||
for (std::size_t i = 0; i + 1 < points_.size(); ++i) {
|
for (std::size_t i = 0; i + 1 < points_.size(); ++i) {
|
||||||
const VelocityPoint& a = points_[i];
|
const VelocityPoint& a = points_[i];
|
||||||
const VelocityPoint& b = points_[i + 1];
|
const VelocityPoint& b = points_[i + 1];
|
||||||
if (v >= a.velocity && v <= b.velocity) {
|
if (v >= a.velocity && v <= b.velocity) {
|
||||||
const double span = b.velocity - a.velocity;
|
const double span = b.velocity - a.velocity;
|
||||||
// Coincident-X neighbours (a step): jump straight to the later point's amp — the segment
|
// Coincident-X neighbours (a step): zero-width segment, no interior to blend.
|
||||||
// has zero width so there is no interior to blend.
|
|
||||||
if (span <= 0.0) return clampAmp(b.amp);
|
if (span <= 0.0) return clampAmp(b.amp);
|
||||||
|
|
||||||
// --- Monotone cubic Hermite (Fritsch–Carlson) interpolation on segment [a,b] ---------
|
// Monotone cubic Hermite (Fritsch-Carlson): provably stays within [a.amp, b.amp]
|
||||||
// Curved (spline) response, not straight lines. The interpolant provably stays within
|
// between the two knots (no overshoot), reproducing a straight line for collinear input.
|
||||||
// [a.amp, b.amp] between the two knots (no bulge below 0 / above 1), and for collinear
|
const double d = (b.amp - a.amp) / span;
|
||||||
// control points its tangents reduce to the secant slope — so it reproduces the straight
|
|
||||||
// line to within floating-point rounding (~1e-15), preserving linear()'s null-response
|
|
||||||
// contract (y = velocity/127 to ~1e-15; the test tolerance of 1e-12 is appropriate).
|
|
||||||
const double d = (b.amp - a.amp) / span; // secant of THIS segment
|
|
||||||
|
|
||||||
// Tangent at a: 0 if a is the first knot (endpoint), else the FC-limited tangent using
|
|
||||||
// the previous segment's secant. Same for the tangent at b (0 at the last knot).
|
|
||||||
double mA = d;
|
double mA = d;
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
const VelocityPoint& prev = points_[i - 1];
|
const VelocityPoint& prev = points_[i - 1];
|
||||||
@@ -141,7 +120,7 @@ double VelocityCurve::eval(double velocity) const {
|
|||||||
const double dPrev = (a.amp - prev.amp) / spanPrev;
|
const double dPrev = (a.amp - prev.amp) / spanPrev;
|
||||||
mA = fritschCarlsonTangent(dPrev, d, spanPrev, span);
|
mA = fritschCarlsonTangent(dPrev, d, spanPrev, span);
|
||||||
} else {
|
} else {
|
||||||
mA = 0.0; // coincident-X predecessor (a step at a) -> flat tangent
|
mA = 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
double mB = d;
|
double mB = d;
|
||||||
@@ -152,13 +131,10 @@ double VelocityCurve::eval(double velocity) const {
|
|||||||
const double dNext = (next.amp - b.amp) / spanNext;
|
const double dNext = (next.amp - b.amp) / spanNext;
|
||||||
mB = fritschCarlsonTangent(d, dNext, span, spanNext);
|
mB = fritschCarlsonTangent(d, dNext, span, spanNext);
|
||||||
} else {
|
} else {
|
||||||
mB = 0.0; // coincident-X successor (a step at b) -> flat tangent
|
mB = 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cubic Hermite basis on the normalized position t across [a,b]. For collinear knots
|
|
||||||
// mA==mB==d, so h00*a + (h10*span)*d + h01*b + (h11*span)*d collapses to the straight
|
|
||||||
// line to within floating-point rounding (~1e-15).
|
|
||||||
const double t = (v - a.velocity) / span;
|
const double t = (v - a.velocity) / span;
|
||||||
const double t2 = t * t;
|
const double t2 = t * t;
|
||||||
const double t3 = t2 * t;
|
const double t3 = t2 * t;
|
||||||
@@ -175,8 +151,7 @@ double VelocityCurve::eval(double velocity) const {
|
|||||||
|
|
||||||
std::size_t VelocityCurve::addPoint(double velocity, double amp) {
|
std::size_t VelocityCurve::addPoint(double velocity, double amp) {
|
||||||
const VelocityPoint p{clampVelocity(velocity), clampAmp(amp)};
|
const VelocityPoint p{clampVelocity(velocity), clampAmp(amp)};
|
||||||
// Insert keeping X-order: first index whose velocity is STRICTLY greater than the new one, so a
|
// First index strictly greater, so a duplicate-X point lands immediately after the existing one.
|
||||||
// duplicate-X point lands immediately after the existing one (a later move can separate them).
|
|
||||||
std::size_t i = 0;
|
std::size_t i = 0;
|
||||||
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
|
while (i < points_.size() && points_[i].velocity <= p.velocity) ++i;
|
||||||
points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p);
|
points_.insert(points_.begin() + static_cast<std::ptrdiff_t>(i), p);
|
||||||
@@ -191,11 +166,10 @@ VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, doubl
|
|||||||
double newAmp = clampAmp(amp);
|
double newAmp = clampAmp(amp);
|
||||||
double newVel;
|
double newVel;
|
||||||
if (isFirst) {
|
if (isFirst) {
|
||||||
newVel = kVelMin; // endpoint pinned in X at 0 — only amp moves
|
newVel = kVelMin;
|
||||||
} else if (isLast) {
|
} else if (isLast) {
|
||||||
newVel = kVelMax; // endpoint pinned in X at 127 — only amp moves
|
newVel = kVelMax;
|
||||||
} else {
|
} else {
|
||||||
// Interior point: clamp X strictly within its immediate neighbours so it can't cross them.
|
|
||||||
const double lo = points_[index - 1].velocity;
|
const double lo = points_[index - 1].velocity;
|
||||||
const double hi = points_[index + 1].velocity;
|
const double hi = points_[index + 1].velocity;
|
||||||
newVel = std::clamp(clampVelocity(velocity), lo, hi);
|
newVel = std::clamp(clampVelocity(velocity), lo, hi);
|
||||||
@@ -216,9 +190,7 @@ VelocityCurve::CurvePixel VelocityCurve::pixelFromPoint(const Box& box, const Ve
|
|||||||
}
|
}
|
||||||
|
|
||||||
VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) {
|
VelocityPoint VelocityCurve::pointFromPixel(const Box& box, int x, int y) {
|
||||||
// The exact inverse of velToX/ampToY (within the one-pixel rounding quantum). Degenerate
|
// Exact inverse of velToX/ampToY (within one pixel); degenerate dims collapse the same way.
|
||||||
// dimensions collapse the same way the forward map does: velToX pins to box.left (velocity 0),
|
|
||||||
// ampToY pins to box.top (amp 1).
|
|
||||||
VelocityPoint p;
|
VelocityPoint p;
|
||||||
const int w = std::max(0, box.width);
|
const int w = std::max(0, box.width);
|
||||||
const int h = std::max(0, box.height);
|
const int h = std::max(0, box.height);
|
||||||
|
|||||||
@@ -1,43 +1,13 @@
|
|||||||
// velocity_curve.h — PURE velocity->amp transfer curve (S-VIEW-9, r10). NO VST3, NO REAPER, NO
|
// velocity_curve.h — velocity->amp transfer curve. eval(velocity) is called once per note-on
|
||||||
// SWELL/LICE, NO vendor/ includes at the boundary. The mirror of envelope_edit / card_drag: the
|
// in Voice::start(), never per frame. Editor hit-test/inverse-map take an explicit pixel Box
|
||||||
// eval + the clamp/order/inverse-map arithmetic live here, unit-tested outside the DAW; the future
|
// rather than a Rect: this module sits below sampler_core in the link graph and must not gain
|
||||||
// editor shell (reasampler_editor.cpp, S-VIEW-10) draws the box + node handles and feeds each move's
|
// a transitive dependency on editor-layout types.
|
||||||
// pixel delta back through here, committing the result to the zone through the same off-audio-thread
|
|
||||||
// path a slider edit uses.
|
|
||||||
//
|
|
||||||
// WHAT IT IS. A monotonic-in-x transfer function mapping MIDI velocity (X: 0..127) to an amp scalar
|
|
||||||
// (Y: 0..1), authored as an ordered list of control points. eval(velocity) is called ONCE per
|
|
||||||
// note-on in Voice::start() (never per frame) to set the voice's velocityGain_, replacing the fixed
|
|
||||||
// linear velocity/127 map. The curve is a per-PerformanceZone performance characteristic (D-B) — a
|
|
||||||
// sibling of the AHDSR envelope, pitch engine, and keyTrack scalar — so it varies per sound, stored
|
|
||||||
// on PerformanceZone and resolved onto the KeyZone at keymap build (mirror of keyTrack).
|
|
||||||
//
|
|
||||||
// DEFAULT — flat y=1 (fork R10-F1 Option A, Daniel 2026-07-27). VelocityCurve::flat() is the seeded
|
|
||||||
// default: EVERY velocity plays at unity amp. This is a DELIBERATE, Daniel-approved behavior change
|
|
||||||
// vs. the shipped linear velocity/127 map — soft hits are now full level until a curve is drawn.
|
|
||||||
// NOT bit-identical to the pre-r10 engine, by design; do not "preserve" the linear response.
|
|
||||||
//
|
|
||||||
// THE INVARIANT (mirror of envelope_edit's S-VIEW-F2). A drag/edit can NEVER produce a curve eval
|
|
||||||
// couldn't handle:
|
|
||||||
// * X-ORDERED — a point clamps between its predecessor's and successor's velocity, so control
|
|
||||||
// points never cross in X. This is what makes eval a well-defined FUNCTION (one amp per
|
|
||||||
// velocity): each X falls in exactly one [p_i, p_{i+1}] segment.
|
|
||||||
// * BOX-CLAMPED — velocity clamps to [0,127], amp clamps to [0,1] (the drawn box).
|
|
||||||
// Both endpoints (velocity 0 and 127) are always present so eval is total over [0,127]; delete
|
|
||||||
// refuses to remove them, and the constructors seed them.
|
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
// DELIBERATELY dependency-free at the boundary (no editor_geometry / Rect). This module sits BELOW
|
|
||||||
// sampler_core in the link graph (KeyZone carries a VelocityCurve; Voice::start calls eval), and the
|
|
||||||
// engine must not gain a transitive dependency on the editor's layout types. The editor hit-test /
|
|
||||||
// inverse-map therefore takes an explicit pixel box (boxLeft/boxTop/boxWidth/boxHeight) rather than a
|
|
||||||
// Rect — the future editor shell (S-VIEW-10) passes its box coords directly. Mirror of envelope_edit's
|
|
||||||
// role, but one layer lower, so the coupling stays out of the engine core.
|
|
||||||
|
|
||||||
namespace reasampler::instrument::engine {
|
namespace reasampler::instrument::engine {
|
||||||
|
|
||||||
// The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into.
|
// The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into.
|
||||||
@@ -46,81 +16,56 @@ inline constexpr double kVelMax = 127.0;
|
|||||||
inline constexpr double kAmpMin = 0.0;
|
inline constexpr double kAmpMin = 0.0;
|
||||||
inline constexpr double kAmpMax = 1.0;
|
inline constexpr double kAmpMax = 1.0;
|
||||||
|
|
||||||
// One control point: a (velocity, amp) knot the curve passes through. Both fields are box-clamped
|
// A raw-constructed point is NOT auto-clamped (the mutators own that invariant) — build curves
|
||||||
// by the mutators; a raw-constructed point is NOT auto-clamped (the mutators own the invariant), so
|
// through the named constructors / addPoint rather than pushing raw points.
|
||||||
// build curves through the named constructors / addPoint rather than pushing raw points.
|
|
||||||
struct VelocityPoint {
|
struct VelocityPoint {
|
||||||
double velocity = 0.0; // X, [0,127]
|
double velocity = 0.0; // X, [0,127]
|
||||||
double amp = 0.0; // Y, [0,1]
|
double amp = 0.0; // Y, [0,1]
|
||||||
};
|
};
|
||||||
|
|
||||||
// The pick radius (px) around a node's drawn point for the editor hit-test. Mirrors
|
// Pick radius (px) around a node's drawn point for the editor hit-test.
|
||||||
// envelope_edit::kNodeGrabRadius / waveform_view::kMarkerGrabWidth.
|
|
||||||
inline constexpr int kCurveNodeGrabRadius = 6;
|
inline constexpr int kCurveNodeGrabRadius = 6;
|
||||||
|
|
||||||
// A velocity->amp transfer curve: an X-ORDERED list of control points spanning [0,127], evaluated by
|
// An X-ordered list of control points spanning [0,127], evaluated by a monotone cubic Hermite
|
||||||
// a MONOTONE cubic Hermite spline (Fritsch–Carlson slope limiting) through the knots — a genuine
|
// spline (Fritsch-Carlson slope limiting) — a genuine curve, not a polyline, that provably never
|
||||||
// curved response (Daniel 2026-07-27: "straight lines sound like shit"), not a polyline. Each
|
// overshoots a segment's amp range. For collinear knots the tangents reduce to the secant slope,
|
||||||
// velocity still maps to exactly one amp: the interpolant is single-valued and provably stays within
|
// so the spline reproduces linear()'s straight line to within ~1e-15. The two endpoints (velocity
|
||||||
// each segment's amp range, so the curve never overshoots below 0 or above 1. For COLLINEAR knots the
|
// 0 and 127) are load-bearing: they keep eval total over the domain and are never deletable.
|
||||||
// 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.
|
|
||||||
class VelocityCurve {
|
class VelocityCurve {
|
||||||
public:
|
public:
|
||||||
// R10-F1 default (Option A): flat y=1 — endpoints (0,1) and (127,1); every velocity -> unity.
|
// flat() (endpoints (0,1)/(127,1), every velocity -> unity) is the default — see
|
||||||
|
// velocity_curve in the directory CLAUDE.md for why this isn't bit-identical to the
|
||||||
|
// pre-existing linear() response.
|
||||||
static VelocityCurve flat();
|
static VelocityCurve flat();
|
||||||
// The classic linear ramp y = velocity/127 — endpoints (0,0) and (127,1). Retained for tests
|
|
||||||
// and as the Option-B seed; NOT the default (see R10-F1).
|
|
||||||
static VelocityCurve linear();
|
static VelocityCurve linear();
|
||||||
|
|
||||||
// Rebuild a curve from a deserialized point list, REPAIRING the invariant defensively (the
|
// Rebuilds from a deserialized point list, repairing the invariant defensively: box-clamps
|
||||||
// deserialization seam, sample_map's zones-payload v7). Each point is box-clamped; the list is
|
// each point, stable-sorts by velocity, forces both endpoints present (synthesized if
|
||||||
// stable-sorted by velocity (X-ordered); endpoints at velocity 0 and 127 are forced present
|
// missing), falls back to flat() if fewer than 2 usable points remain. A corrupt/truncated
|
||||||
// (an absent endpoint is synthesized at the nearest interior amp, or unity for an empty list).
|
// blob yields a well-formed curve, never an invariant-violating one.
|
||||||
// A list with fewer than 2 usable points falls back to flat(). Never trusts the wire blindly —
|
|
||||||
// a corrupt/truncated blob yields a well-formed curve, never an invariant-violating one.
|
|
||||||
static VelocityCurve fromPoints(std::vector<VelocityPoint> pts);
|
static VelocityCurve fromPoints(std::vector<VelocityPoint> pts);
|
||||||
|
|
||||||
// The control points, X-ordered, first at velocity 0 and last at velocity 127 (invariant).
|
|
||||||
const std::vector<VelocityPoint>& points() const { return points_; }
|
const std::vector<VelocityPoint>& points() const { return points_; }
|
||||||
std::size_t size() const { return points_.size(); }
|
std::size_t size() const { return points_.size(); }
|
||||||
|
|
||||||
// Evaluate the curve at `velocity` -> amp in [0,1]. Velocity is box-clamped to [0,127] first,
|
// Degenerate cases (shouldn't occur post-construction): empty curve returns kAmpMax; a
|
||||||
// so an out-of-range note (shouldn't occur) reads the nearest endpoint. Between two adjacent
|
// one-point curve returns that point's amp.
|
||||||
// points the amp follows a MONOTONE cubic Hermite spline (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.
|
|
||||||
double eval(double velocity) const;
|
double eval(double velocity) const;
|
||||||
|
|
||||||
// --- Editing (for the S-VIEW-10 editor UI) --------------------------------------------------
|
// Inserted at a velocity duplicating an existing point lands immediately after it, so a
|
||||||
// Insert a new control point, box-clamped, keeping the list X-ordered by velocity. Returns the
|
// subsequent move can separate them. Returns the inserted index.
|
||||||
// index of the inserted point. A new point at a velocity that duplicates an existing one is
|
|
||||||
// inserted immediately AFTER it (so a subsequent move can separate them); the endpoints are not
|
|
||||||
// special-cased on insert (a point at exactly 0 or 127 inserts adjacent to that endpoint).
|
|
||||||
std::size_t addPoint(double velocity, double amp);
|
std::size_t addPoint(double velocity, double amp);
|
||||||
|
|
||||||
// Move point `index` to (velocity, amp), box-clamped AND X-clamped between its immediate
|
// Box-clamped and X-clamped between immediate neighbours (monotonic-X grammar). The two
|
||||||
// neighbours so it cannot cross them (monotonic-X grammar). The two ENDPOINTS are pinned in X
|
// endpoints are pinned in X (only their amp moves); out-of-range index is a no-op.
|
||||||
// (index 0 stays at velocity 0, the last stays at 127) — only their AMP moves; their velocity
|
|
||||||
// argument is ignored. An out-of-range index is a no-op. Returns the (possibly clamped)
|
|
||||||
// resulting point.
|
|
||||||
VelocityPoint movePoint(std::size_t index, double velocity, double amp);
|
VelocityPoint movePoint(std::size_t index, double velocity, double amp);
|
||||||
|
|
||||||
// Delete point `index`. The two endpoints (index 0 and the last) are NOT deletable — a request
|
// Endpoints (index 0 and last) are not deletable; that or an out-of-range index is a no-op
|
||||||
// to remove either, or an out-of-range index, is a no-op returning false. Returns true iff a
|
// returning false.
|
||||||
// point was removed.
|
|
||||||
bool deletePoint(std::size_t index);
|
bool deletePoint(std::size_t index);
|
||||||
|
|
||||||
// --- Editor hit-test + inverse map (mirror of envelope_edit) --------------------------------
|
// The drawn box, in pixels: X = velocity across the width, Y = amp UP the height (amp 1 at
|
||||||
// The drawn box, in pixels: origin (boxLeft, boxTop), `boxWidth` px wide, `boxHeight` px tall.
|
// top). Passed explicitly rather than a Rect — see header preamble.
|
||||||
// X = velocity across the width (0 at boxLeft, 127 at boxLeft+boxWidth); Y = amp UP the height
|
|
||||||
// (amp 1 at boxTop, amp 0 at boxTop+boxHeight-1). Passed explicitly (not a Rect) so this module
|
|
||||||
// stays free of editor-layout types — see the header preamble.
|
|
||||||
struct Box {
|
struct Box {
|
||||||
int left = 0;
|
int left = 0;
|
||||||
int top = 0;
|
int top = 0;
|
||||||
@@ -128,43 +73,32 @@ public:
|
|||||||
int height = 0;
|
int height = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Which control point a grab at (x,y) lands on, given the drawn `box`. Returns the index of the
|
// Index of the first point within the pick radius on both axes, or -1 for a miss. First-match
|
||||||
// first point within the pick radius in BOTH axes, or -1 for a miss. First-match in point order
|
// in point order for determinism.
|
||||||
// for determinism (mirror of nodeAtPoint).
|
|
||||||
int pointAtPixel(const Box& box, int x, int y) const;
|
int pointAtPixel(const Box& box, int x, int y) const;
|
||||||
|
|
||||||
// A node's drawn pixel position (S-VIEW-10). The ONE point->pixel mapping — the same mapping
|
// The one point->pixel mapping, exposed so drawing and hit-testing can never drift apart.
|
||||||
// pointAtPixel hit-tests against — exposed so the editor shell draws the trace + node handles
|
|
||||||
// at exactly the coordinates the hit-test expects (draw and grab can never drift).
|
|
||||||
struct CurvePixel {
|
struct CurvePixel {
|
||||||
int x = 0;
|
int x = 0;
|
||||||
int y = 0;
|
int y = 0;
|
||||||
};
|
};
|
||||||
static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p);
|
static CurvePixel pixelFromPoint(const Box& box, const VelocityPoint& p);
|
||||||
|
|
||||||
// The absolute pixel -> (velocity, amp) inverse (S-VIEW-10): where an empty-space click lands
|
// Exact inverse of pixelFromPoint (within the one-pixel quantum) — where an empty-space click
|
||||||
// as a NEW control point, box-clamped. The exact inverse of pixelFromPoint's mapping (within
|
// lands as a new point. Degenerate box: zero-width reads velocity 0; height <= 1 reads amp 1.
|
||||||
// the one-pixel quantum), so an added point appears under the cursor. Degenerate box: a
|
|
||||||
// zero-width box reads velocity 0; a height <= 1 box reads amp 1 (the top row), mirroring
|
|
||||||
// pixelFromPoint's degenerate collapse.
|
|
||||||
static VelocityPoint pointFromPixel(const Box& box, int x, int y);
|
static VelocityPoint pointFromPixel(const Box& box, int x, int y);
|
||||||
|
|
||||||
// Resolve a drag of point `index` by a pixel delta since grab, given the curve AS OF GRAB TIME
|
// `grabCurve` is the curve as of mouse-down (shell snapshots it so the delta is absolute).
|
||||||
// (`grabCurve` — the shell snapshots it on mouse-down so the delta is absolute) and the box.
|
// Maps the pixel delta to velocity/amp over the box, then applies movePoint's clamp. Zero
|
||||||
// Maps the pixel delta to a (velocity, amp) delta over the box, then applies movePoint's clamp
|
// width/height box or out-of-range index returns grabCurve unchanged.
|
||||||
// (box + neighbour X + endpoint X-pin). A zero-width/height box or out-of-range index returns
|
|
||||||
// `grabCurve` unchanged. Pure — mirror of resolveNodeDrag.
|
|
||||||
static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
|
static VelocityCurve resolvePointDrag(const VelocityCurve& grabCurve, std::size_t index,
|
||||||
const Box& box, int dxPixels, int dyPixels);
|
const Box& box, int dxPixels, int dyPixels);
|
||||||
|
|
||||||
// Equality (for tests + round-trip assertions): same point count + each point equal within a
|
|
||||||
// tight epsilon.
|
|
||||||
bool equals(const VelocityCurve& other, double eps = 1e-9) const;
|
bool equals(const VelocityCurve& other, double eps = 1e-9) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Points are always X-ordered with an endpoint at 0 and 127. Constructed only through the named
|
// Always X-ordered with an endpoint at 0 and 127; constructors + deserialize establish the
|
||||||
// constructors + deserialize (see sample_map), which establish that invariant; the mutators
|
// invariant, mutators preserve it.
|
||||||
// preserve it.
|
|
||||||
std::vector<VelocityPoint> points_;
|
std::vector<VelocityPoint> points_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// voice.cpp — the PER-NOTE half of Voice: note-on setup (including the Preserve ring
|
||||||
|
// prime), legato retune, gate-off, and the off-thread shifter presize. The per-sample
|
||||||
|
// render half is inline in voice.h by RT constraint — see that file's header.
|
||||||
|
|
||||||
|
#include "core/instrument/engine/voice.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Voice::start(int note, int velocity, const SampleData& sample, bool declickTakeover) {
|
||||||
|
// 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_;
|
||||||
|
declickRefR_ = (lastOutR_ > 1.0) ? 1.0 : (lastOutR_ < -1.0) ? -1.0 : lastOutR_;
|
||||||
|
declickPending_ = true;
|
||||||
|
} else {
|
||||||
|
declickPending_ = false;
|
||||||
|
}
|
||||||
|
// Any in-flight ramp is superseded: pending re-derives from the reference, which already
|
||||||
|
// includes the running declick's contribution via lastOut (it tracks post-declick output).
|
||||||
|
declickActive_ = false;
|
||||||
|
declickWeight_ = 0.0;
|
||||||
|
|
||||||
|
active_ = true;
|
||||||
|
releasing_ = false;
|
||||||
|
amplitudeDone_ = false;
|
||||||
|
note_ = note;
|
||||||
|
// Velocity->amp mapped once at note-on; the per-frame render just multiplies the cached
|
||||||
|
// velocityGain_.
|
||||||
|
velocityGain_ = sample.velocityCurve.eval(static_cast<double>(velocity));
|
||||||
|
// Feeds both engines through baseRatio_ (Varispeed read-rate bias and Preserve shift
|
||||||
|
// amount both derive from it below).
|
||||||
|
baseRatio_ = keyTrackedRatio(note, sample.rootNote, sample.keyTrack);
|
||||||
|
sample_ = &sample;
|
||||||
|
|
||||||
|
const PlayParams& p = sample.play;
|
||||||
|
playMode_ = p.playMode;
|
||||||
|
pitchEngine_ = p.pitchEngine;
|
||||||
|
|
||||||
|
// 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 (all five fields read from play.adsr, resolved to
|
||||||
|
// frames from stored seconds at load time); Trigger = the time-boxed fade-in/out over the
|
||||||
|
// % play length.
|
||||||
|
if (playMode_ == PlayMode::Gate) {
|
||||||
|
env_.configure(p.adsr);
|
||||||
|
env_.noteOn();
|
||||||
|
playEnd_ = 0; // unused in Gate
|
||||||
|
} else {
|
||||||
|
// Trigger: play [start, playEnd) where
|
||||||
|
// playEnd = start + round(lengthFraction*(frames-start)).
|
||||||
|
double frac = p.trigger.lengthFraction;
|
||||||
|
if (frac <= 0.0) frac = 0.0; // %=0 -> zero play length (finishes immediately)
|
||||||
|
if (frac > 1.0) frac = 1.0;
|
||||||
|
const std::int64_t span = frameCount - start; // >= 1 (start clamped < frameCount)
|
||||||
|
std::int64_t playLen = static_cast<std::int64_t>(
|
||||||
|
static_cast<double>(span) * frac + 0.5); // round
|
||||||
|
if (playLen < 0) playLen = 0;
|
||||||
|
if (playLen > span) playLen = span;
|
||||||
|
playEnd_ = start + playLen;
|
||||||
|
trigEnv_.configure(playLen, p.trigger.fadeInFrames, p.trigger.fadeOutFrames,
|
||||||
|
kDefaultFadeCurve);
|
||||||
|
}
|
||||||
|
|
||||||
|
pitchEnv_.configure(p.pitchEnv);
|
||||||
|
pitchEnv_.noteOn();
|
||||||
|
|
||||||
|
// Filter: reset() clears integrator state for the new note (prepare() preserves it —
|
||||||
|
// voice_filter.h / filter/CLAUDE.md). Velocity maps through the curve once here, off the
|
||||||
|
// per-frame path, exactly as the amp's velocityGain_ does.
|
||||||
|
filterOn_ = p.filter.enabled;
|
||||||
|
if (filterOn_) {
|
||||||
|
filterCutoffNorm_ = static_cast<double>(p.filter.settings.cutoffNorm);
|
||||||
|
filterModAmount_ = p.filter.modAmount;
|
||||||
|
filterKeyTrack_ = p.filter.keyTrack;
|
||||||
|
filterVelOffset_ =
|
||||||
|
p.filter.velAmount * p.filter.velocityCurve.eval(static_cast<double>(velocity));
|
||||||
|
filterRate_ = static_cast<double>(sample.sampleRate);
|
||||||
|
filterEnv_.configure(p.filter.env);
|
||||||
|
filterEnv_.noteOn();
|
||||||
|
filter_.reset();
|
||||||
|
updateFilterCutoffBase(note);
|
||||||
|
// The note's ONE full solve — Q, morph and drive are constants for its lifetime, so
|
||||||
|
// every later re-solve is the cheap cutoff-only path. A modulated voice supersedes this
|
||||||
|
// cutoff in tickFilterCutoff on its first frame, before any sample reaches the kernel.
|
||||||
|
instrument::engine::filter::FilterSettings s = p.filter.settings;
|
||||||
|
s.cutoffNorm = filterBaseCutoff_;
|
||||||
|
filter_.prepare(s, filterRate_);
|
||||||
|
filterSolvedCutoff_ = filterBaseCutoff_;
|
||||||
|
filterSolved_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
// 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;
|
||||||
|
const std::int64_t primeCount =
|
||||||
|
loopWrap ? w : std::min<std::int64_t>(w, primeBound - start);
|
||||||
|
// Both channels walk identical SOURCE positions (the walk depends only on loop
|
||||||
|
// geometry, not on channel PCM values) — compute `p` once for channel 0, reuse for 1.
|
||||||
|
std::int64_t p = start;
|
||||||
|
for (int ch = 0; ch < (stereoSample ? 2 : 1); ++ch) {
|
||||||
|
const std::vector<AudioSample>& pcmCh = ch == 0 ? sample.frames : sample.framesR;
|
||||||
|
std::int64_t q = start;
|
||||||
|
for (std::int64_t i = 0; i < primeCount; ++i) {
|
||||||
|
if (loopWrap) {
|
||||||
|
while (q >= loop.end) q -= loopLen;
|
||||||
|
}
|
||||||
|
// q < frameCount holds by construction on the non-loop path (primeCount is
|
||||||
|
// bounded); the guard stays as a belt for the loop-wrap walk.
|
||||||
|
primeBuf_[static_cast<std::size_t>(i)] =
|
||||||
|
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
|
||||||
|
++q;
|
||||||
|
}
|
||||||
|
(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).
|
||||||
|
feedPos_ = p;
|
||||||
|
if (!loopWrap && primeCount < w) {
|
||||||
|
// Sub-window playable span: the source is already exhausted at prime time.
|
||||||
|
shiftL_.freezeTail();
|
||||||
|
if (stereoSample) shiftR_.freezeTail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
|
||||||
|
}
|
||||||
|
|
||||||
|
void Voice::retune(int note) {
|
||||||
|
// Mono legato takeover: move the pitch, touch NOTHING else — the amplitude envelope keeps
|
||||||
|
// running (no re-attack), the read head keeps its position, the shifter keeps its ring
|
||||||
|
// (Preserve picks the new baseRatio_ up via next frame's setShiftRatio; Varispeed via the
|
||||||
|
// per-frame ratio_ recompute). Velocity gain deliberately stays the first note's — a
|
||||||
|
// legato phrase is one gesture, one strike (classic mono-synth behavior).
|
||||||
|
if (!active_ || sample_ == nullptr) return;
|
||||||
|
note_ = note;
|
||||||
|
baseRatio_ = keyTrackedRatio(note, sample_->rootNote, sample_->keyTrack);
|
||||||
|
// Filter key-tracking follows the pitch: it is a function of the note, so a slide moves it
|
||||||
|
// too. The velocity offset deliberately stays the first note's, matching velocityGain_.
|
||||||
|
if (filterOn_) updateFilterCutoffBase(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Voice::release() {
|
||||||
|
if (!active_) return;
|
||||||
|
if (playMode_ == PlayMode::Trigger) return; // Trigger ignores note-off, plays through
|
||||||
|
releasing_ = true;
|
||||||
|
env_.noteOff();
|
||||||
|
filterEnv_.noteOff();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
#pragma once
|
||||||
|
// voice.h — one sounding voice: a repitched, enveloped read over the loaded capture.
|
||||||
|
//
|
||||||
|
// The PER-SAMPLE render half (advanceFrame and everything it calls) is defined INLINE here
|
||||||
|
// on purpose: VoiceEngine::render's inner loop lives in another TU, and with no LTO
|
||||||
|
// configured an out-of-line render would put a call — and the envelope ticks behind it —
|
||||||
|
// across a TU boundary on the hottest path in the program. The per-NOTE half (start /
|
||||||
|
// retune / release / hardStop / presize) is cold enough to live in voice.cpp.
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/audio/peaks.h"
|
||||||
|
#include "core/instrument/engine/envelopes.h"
|
||||||
|
#include "core/instrument/engine/filter/filter_params.h"
|
||||||
|
#include "core/instrument/engine/filter/voice_filter.h"
|
||||||
|
#include "core/instrument/engine/pitch_shift.h"
|
||||||
|
#include "core/instrument/engine/play_params.h"
|
||||||
|
#include "core/instrument/engine/velocity_curve.h"
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
using audio::AudioSample;
|
||||||
|
using instrument::engine::PitchShifter;
|
||||||
|
using instrument::engine::VelocityCurve;
|
||||||
|
using instrument::engine::VelocityPoint;
|
||||||
|
|
||||||
|
// 2^((note - rootNote) / 12). note == rootNote -> 1.0. Pure equal temperament; no
|
||||||
|
// reference-frequency needed.
|
||||||
|
inline double pitchRatio(int note, int rootNote) {
|
||||||
|
return std::pow(2.0, static_cast<double>(note - rootNote) / 12.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 for an integer-valued double, 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.
|
||||||
|
inline double keyTrackedRatio(int note, int rootNote, double keyTrack) {
|
||||||
|
const double semis = static_cast<double>(note - rootNote) * keyTrack;
|
||||||
|
return std::pow(2.0, semis / 12.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One octave expressed in the cutoff control's normalized domain, read out of the filter
|
||||||
|
// module's OWN inverse rather than re-derived from its endpoints — the log law belongs to
|
||||||
|
// filter_params, and a second copy here could drift from it. Evaluated at note-on only.
|
||||||
|
inline double filterNormPerOctave() {
|
||||||
|
namespace flt = instrument::engine::filter;
|
||||||
|
return static_cast<double>(flt::filterNormFromCutoffHz(2.0f * flt::kFilterCutoffMinHz) -
|
||||||
|
flt::filterNormFromCutoffHz(flt::kFilterCutoffMinHz));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Takeover declick: a restart of a sounding voice (mono retrigger takeover/fallback 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)
|
||||||
|
|
||||||
|
// A single voice: one active note playing the loaded capture, repitched and enveloped.
|
||||||
|
// Reads the sample by fractional frame position with linear interpolation, advancing by the
|
||||||
|
// pitch ratio; loops the sustain region for held notes past the loop end.
|
||||||
|
class Voice {
|
||||||
|
public:
|
||||||
|
// Plays `sample` (a stable reference the caller must keep alive — the engine's loaded
|
||||||
|
// instrument owns it), repitched from its root by `sample.keyTrack`. Play-mode /
|
||||||
|
// AHDSR / pitch-engine params are read from sample.play (frames, resolved from stored
|
||||||
|
// seconds at load). 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(). Byte-identical to
|
||||||
|
// the bare engine when sample.play is default. `velocityCurve` maps note-on velocity to
|
||||||
|
// amp gain, evaluated once here (off the per-frame path). `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, bool declickTakeover = false);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
void retune(int note);
|
||||||
|
|
||||||
|
// 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 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() { active_ = false; }
|
||||||
|
|
||||||
|
// 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 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_; }
|
||||||
|
int note() const { return note_; }
|
||||||
|
// 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 pitch engine this voice is running (for the engine's Preserve-voice tally). Only
|
||||||
|
// meaningful while active().
|
||||||
|
PitchEngine pitchEngine() const { return pitchEngine_; }
|
||||||
|
|
||||||
|
// 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. Already velocity- and envelope-scaled — the engine sums voices directly.
|
||||||
|
// Mono path (channel 0 only).
|
||||||
|
AudioSample renderFrame() {
|
||||||
|
AudioSample discard = 0.0f;
|
||||||
|
return advanceFrame(/*stereo=*/false, discard);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
r = 0.0f;
|
||||||
|
l = advanceFrame(/*stereo=*/true, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
// 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 {
|
||||||
|
if (sample_ == nullptr || playMode_ != PlayMode::Gate) return false;
|
||||||
|
const SampleLoop& loop = sample_->loop;
|
||||||
|
return loop.hasLoop && loop.end > loop.start && loop.start >= 0 &&
|
||||||
|
loop.end <= static_cast<std::int64_t>(sample_->frames.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
double amp;
|
||||||
|
if (playMode_ == PlayMode::Gate) {
|
||||||
|
amp = env_.tick();
|
||||||
|
if (env_.finished()) amplitudeDone_ = true;
|
||||||
|
} else {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
return amp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advances the filter envelope and re-solves the corner from the modulated cutoff. The
|
||||||
|
// solve is UNQUANTIZED: the corner tracks the envelope continuously, so a sweep glides
|
||||||
|
// rather than staircasing. State preservation across the solve is voice_filter's own
|
||||||
|
// contract (voice_filter.h / filter/CLAUDE.md). Do not reintroduce a step quantizer on the
|
||||||
|
// control value to save the solve — setCutoffNorm exists to make the solve cheap instead.
|
||||||
|
//
|
||||||
|
// Two exact skips, neither of which rounds the control: filterModAmount_ is fixed for the
|
||||||
|
// note's lifetime, so a zero depth can only ever re-derive the cutoff already solved; and a
|
||||||
|
// held envelope (sustain, or finished) reproduces the previous position bit-for-bit. Both
|
||||||
|
// compare the value itself, so they can never suppress a move the ear would hear.
|
||||||
|
// filterSolved_ == false (forced by start()/retune() via updateFilterCutoffBase) falls
|
||||||
|
// through both so a moved base always re-solves.
|
||||||
|
void tickFilterCutoff() {
|
||||||
|
if (filterModAmount_ == 0.0 && filterSolved_) return;
|
||||||
|
double cut = static_cast<double>(filterBaseCutoff_) +
|
||||||
|
filterModAmount_ * filterEnv_.tick();
|
||||||
|
if (cut < 0.0) cut = 0.0;
|
||||||
|
if (cut > 1.0) cut = 1.0;
|
||||||
|
const float cutNorm = static_cast<float>(cut);
|
||||||
|
if (filterSolved_ && cutNorm == filterSolvedCutoff_) return;
|
||||||
|
filterSolvedCutoff_ = cutNorm;
|
||||||
|
filterSolved_ = true;
|
||||||
|
filter_.setCutoffNorm(cutNorm, filterRate_);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cutoff position before the envelope: the stored knob position plus this note's
|
||||||
|
// velocity offset and key-tracking. Recomputed at note-on and at a legato retune (both
|
||||||
|
// move the note), never per frame.
|
||||||
|
void updateFilterCutoffBase(int note) {
|
||||||
|
double base = filterCutoffNorm_ + filterVelOffset_;
|
||||||
|
if (filterKeyTrack_ != 0.0 && sample_ != nullptr) {
|
||||||
|
base += filterKeyTrack_ *
|
||||||
|
(static_cast<double>(note - sample_->rootNote) / 12.0) *
|
||||||
|
filterNormPerOctave();
|
||||||
|
}
|
||||||
|
if (base < 0.0) base = 0.0;
|
||||||
|
if (base > 1.0) base = 1.0;
|
||||||
|
filterBaseCutoff_ = static_cast<float>(base);
|
||||||
|
filterSolved_ = false; // forces the next frame to solve
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
// 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. (An earlier revision stored the frozen
|
||||||
|
// difference (ref − x₀), which could exceed full scale if outₙ rose while that
|
||||||
|
// residue was still large.)
|
||||||
|
declickPending_ = false;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, advances the head, and latches idle on exhaustion. `stereo` selects
|
||||||
|
// whether the second channel is read (into `outR`). Returns the channel-0 value.
|
||||||
|
//
|
||||||
|
// INLINE BY CONSTRAINT — see the file header.
|
||||||
|
AudioSample advanceFrame(bool stereo, AudioSample& outR) {
|
||||||
|
if (!active_ || sample_ == nullptr) {
|
||||||
|
if (stereo) outR = 0.0f;
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<AudioSample>& pcm = sample_->frames;
|
||||||
|
const std::int64_t frameCount = static_cast<std::int64_t>(pcm.size());
|
||||||
|
// Read the second channel only for a genuinely stereo sample; a mono sample plays
|
||||||
|
// dual-mono (channel 0 duplicated), so `pcmR` aliases channel 0 in that case.
|
||||||
|
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). 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) {
|
||||||
|
const double loopLen = static_cast<double>(loop.end - loop.start);
|
||||||
|
while (readPos_ >= static_cast<double>(loop.end)) {
|
||||||
|
readPos_ -= loopLen; // wrap by exactly one loop length, preserving phase.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, 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();
|
||||||
|
if (declickActive_) {
|
||||||
|
// Bounded blend at silence: outCurrent == 0, so the blend is
|
||||||
|
// w*(ref − 0) == w*ref. The weight decays by kDeclickDecay each frame,
|
||||||
|
// floor-checked on the weight itself.
|
||||||
|
const double l = declickWeight_ * declickRefL_;
|
||||||
|
const double r = declickWeight_ * declickRefR_; // same weight both channels
|
||||||
|
declickWeight_ *= kDeclickDecay;
|
||||||
|
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||||
|
declickActive_ = false;
|
||||||
|
active_ = false;
|
||||||
|
}
|
||||||
|
lastOutL_ = l;
|
||||||
|
lastOutR_ = stereo ? r : l;
|
||||||
|
if (stereo) outR = static_cast<AudioSample>(r);
|
||||||
|
return static_cast<AudioSample>(l);
|
||||||
|
}
|
||||||
|
active_ = false;
|
||||||
|
if (stereo) outR = 0.0f;
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Both pitch branches leave the UNENVELOPED post-pitch signal here; the filter acts on
|
||||||
|
// it and the amp gain is applied afterwards, so the pipeline is pitch -> filter -> amp
|
||||||
|
// and the amp envelope shapes the filtered result (drive included).
|
||||||
|
double outL, outRlocal = 0.0;
|
||||||
|
if (pitchEngine_ == PitchEngine::Preserve && shiftL_.configured()) {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
// 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;
|
||||||
|
const bool exhausted = feedPos_ >= feedBound;
|
||||||
|
if (exhausted) shiftL_.freezeTail(); // idempotent; input ignored while frozen
|
||||||
|
const bool feedOk = (!exhausted && feedPos_ >= 0 && feedPos_ < frameCount);
|
||||||
|
const AudioSample feedL = feedOk ? pcm[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||||
|
const double shift = baseRatio_ * envFactor;
|
||||||
|
shiftL_.setShiftRatio(shift);
|
||||||
|
const double shiftedL = static_cast<double>(shiftL_.process(feedL));
|
||||||
|
outL = shiftedL;
|
||||||
|
if (stereo) {
|
||||||
|
if (haveR && shiftR_.configured()) {
|
||||||
|
// Genuine stereo (linked lag): channel 1's shifter FOLLOWS channel 0's
|
||||||
|
// splice decisions via processLinked — one correlation search, one lag, one
|
||||||
|
// splice schedule for both channels (standard stereo SOLA). An independent
|
||||||
|
// per-channel search re-drew an inter-channel offset of up to +/-maxLag at
|
||||||
|
// every splice: stereo image wander at the splice cadence + mono-sum
|
||||||
|
// combing. Each shifter is still processed EXACTLY ONCE per output frame
|
||||||
|
// (never twice — that would advance its heads twice and corrupt the state).
|
||||||
|
// Gated on haveR so a MONO sample never touches shiftR_ — start() only
|
||||||
|
// primes it for genuinely stereo samples, and a stale un-primed ring must
|
||||||
|
// not leak a previous note.
|
||||||
|
if (exhausted) shiftR_.freezeTail();
|
||||||
|
const AudioSample feedR =
|
||||||
|
feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
|
||||||
|
shiftR_.setShiftRatio(shift);
|
||||||
|
outRlocal =
|
||||||
|
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice()));
|
||||||
|
} else {
|
||||||
|
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the
|
||||||
|
// shifted value from the mono feed; mirror it to R. Do NOT call
|
||||||
|
// shiftL_.process again this frame.
|
||||||
|
outRlocal = shiftedL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
++feedPos_;
|
||||||
|
// Preserve advances the read head at the SOURCE rate (duration preserved).
|
||||||
|
ratio_ = 1.0;
|
||||||
|
} else {
|
||||||
|
// VARISPEED: pitch and duration coupled. The read rate carries the repitch; the
|
||||||
|
// pitch envelope multiplies the ratio for the read-rate bias (unchanged idiom when
|
||||||
|
// the envelope is off -> pitchEnvSemis == 0 -> factor 1.0 -> byte-identical).
|
||||||
|
//
|
||||||
|
// Linear interpolation between the two bracketing SOURCE frames at the read head.
|
||||||
|
// For the loop case, the second point wraps to loopStart so the seam is continuous.
|
||||||
|
const std::int64_t i0 = static_cast<std::int64_t>(readPos_);
|
||||||
|
const double frac = readPos_ - static_cast<double>(i0);
|
||||||
|
std::int64_t i1 = i0 + 1;
|
||||||
|
if (loopUsable && i1 >= loop.end) {
|
||||||
|
i1 = loop.start; // seamless wrap for the interpolation partner.
|
||||||
|
}
|
||||||
|
const bool i0ok = (i0 >= 0 && i0 < frameCount);
|
||||||
|
const bool i1ok = (i1 >= 0 && i1 < frameCount);
|
||||||
|
const double srcL = (i0ok ? static_cast<double>(pcm[i0]) : 0.0) +
|
||||||
|
((i1ok ? static_cast<double>(pcm[i1]) : 0.0) -
|
||||||
|
(i0ok ? static_cast<double>(pcm[i0]) : 0.0)) * frac;
|
||||||
|
outL = srcL;
|
||||||
|
if (stereo) {
|
||||||
|
const double srcR = (i0ok ? static_cast<double>(pcmR[i0]) : 0.0) +
|
||||||
|
((i1ok ? static_cast<double>(pcmR[i1]) : 0.0) -
|
||||||
|
(i0ok ? static_cast<double>(pcmR[i0]) : 0.0)) * frac;
|
||||||
|
outRlocal = srcR;
|
||||||
|
}
|
||||||
|
ratio_ = baseRatio_ * envFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skipped whole when disengaged (the default), so an un-filtered render stays
|
||||||
|
// bit-identical to the pre-filter engine.
|
||||||
|
if (filterOn_) {
|
||||||
|
tickFilterCutoff();
|
||||||
|
outL = static_cast<double>(filter_.process(0, static_cast<float>(outL)));
|
||||||
|
// Dual-mono feeds channel 1 the value channel 0 already carried, so mirroring the
|
||||||
|
// filtered result is exactly what a second identical filter would produce — one
|
||||||
|
// less kernel pass per frame for the same samples.
|
||||||
|
if (stereo) {
|
||||||
|
outRlocal = haveR
|
||||||
|
? static_cast<double>(filter_.process(1, static_cast<float>(outRlocal)))
|
||||||
|
: outL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outL *= gain;
|
||||||
|
if (stereo) outRlocal *= gain;
|
||||||
|
|
||||||
|
// Takeover declick (bounded-blend revision): on the FIRST frame after a takeover/steal
|
||||||
|
// restart, seed the blend weight at 1.0 so this frame's output is
|
||||||
|
// outₙ*(1−w) + ref*w = out*(1−1) + ref*1 = ref (exact boundary identity).
|
||||||
|
// Each subsequent frame the blend add is `w*(ref − outCurrent)` and then w decays by
|
||||||
|
// kDeclickDecay. The output is therefore bounded by max(|ref|, |outCurrent|) in every
|
||||||
|
// frame — mid-ramp overshoot from a rising outCurrent is structurally impossible.
|
||||||
|
// [An earlier revision added the frozen difference (ref − x₀) ungated; if outₙ rose
|
||||||
|
// while the residue was still large the sum could exceed ±1 by up to ~+3.8 dB on an
|
||||||
|
// extreme retrig.] Inactive (the common case) costs one branch; the blend itself costs
|
||||||
|
// one extra subtract.
|
||||||
|
if (declickPending_) seedDeclick();
|
||||||
|
if (declickActive_) {
|
||||||
|
const double addL = declickWeight_ * (declickRefL_ - outL);
|
||||||
|
const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL));
|
||||||
|
outL += addL;
|
||||||
|
if (stereo) outRlocal += addR;
|
||||||
|
declickWeight_ *= kDeclickDecay; // one shared weight — both channels decay together
|
||||||
|
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
|
||||||
|
declickActive_ = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stereo) outR = static_cast<AudioSample>(outRlocal);
|
||||||
|
|
||||||
|
// Track the value this voice actually contributed THIS frame (post-gain, incl. any
|
||||||
|
// running declick) — a future takeover restart seeds its declick from exactly this. In
|
||||||
|
// a mono render the R track mirrors L (dual-mono semantics, matching the stereo mirror
|
||||||
|
// of a mono sample), so a later stereo takeover still has a sane R seed.
|
||||||
|
lastOutL_ = outL;
|
||||||
|
lastOutR_ = stereo ? outRlocal : outL;
|
||||||
|
|
||||||
|
readPos_ += ratio_;
|
||||||
|
|
||||||
|
// A finished amplitude envelope frees the voice — unless a takeover declick still
|
||||||
|
// rings: the envelope contributes 0 from here on, so the remaining frames are the bare
|
||||||
|
// ramp fading out (bounded: the ramp floors within ~4 ms). Baseline unchanged.
|
||||||
|
if (amplitudeDone_ && !declickActive_) {
|
||||||
|
active_ = false;
|
||||||
|
}
|
||||||
|
return static_cast<AudioSample>(outL);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool active_ = false;
|
||||||
|
bool releasing_ = false;
|
||||||
|
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 readPos_ = 0.0; // fractional frame index into the sample
|
||||||
|
const SampleData* sample_ = nullptr;
|
||||||
|
|
||||||
|
// 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_;
|
||||||
|
std::int64_t startFrame_ = 0; // clamped initial read frame; Trigger fade offset origin
|
||||||
|
std::int64_t playEnd_ = 0; // Trigger: source-frame end; Gate: unused
|
||||||
|
bool amplitudeDone_ = false; // set when the active amplitude envelope finished
|
||||||
|
|
||||||
|
// The voice's OWN filter and filter envelope — per-voice, never shared, so two notes at
|
||||||
|
// different envelope phases are filtered independently. filterCutoffNorm_ keeps the
|
||||||
|
// unmodulated knob position the base is rebuilt from. Q, morph and drive are note-constants
|
||||||
|
// solved once by start()'s prepare(), which is why every later re-solve is cutoff-only.
|
||||||
|
// filterRate_ <= 0 makes prepare() bypass rather than invent a rate.
|
||||||
|
instrument::engine::filter::VoiceFilter filter_;
|
||||||
|
AdsrEnvelope filterEnv_;
|
||||||
|
bool filterOn_ = false;
|
||||||
|
double filterRate_ = 0.0;
|
||||||
|
double filterCutoffNorm_ = 1.0;
|
||||||
|
double filterModAmount_ = 0.0;
|
||||||
|
double filterVelOffset_ = 0.0; // velAmount * velocityCurve.eval(velocity), fixed per note
|
||||||
|
double filterKeyTrack_ = 0.0;
|
||||||
|
float filterBaseCutoff_ = 1.0f; // cutoff before the envelope, clamped
|
||||||
|
float filterSolvedCutoff_ = 1.0f; // the position the live coefficients were solved from
|
||||||
|
bool filterSolved_ = false; // false forces the next frame to solve
|
||||||
|
|
||||||
|
// pitchEngine_ selects Varispeed (ratio bias) vs Preserve (source-rate read + shifter).
|
||||||
|
// shiftL_/shiftR_ transpose the Preserve output per channel. pitchEnv_ rides either engine.
|
||||||
|
//
|
||||||
|
// 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_;
|
||||||
|
PitchShifter shiftR_;
|
||||||
|
std::int64_t feedPos_ = 0;
|
||||||
|
std::vector<AudioSample> primeBuf_;
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// 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)
|
||||||
|
double declickRefR_ = 0.0;
|
||||||
|
double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
|
||||||
|
double lastOutL_ = 0.0;
|
||||||
|
double lastOutR_ = 0.0;
|
||||||
|
|
||||||
|
std::uint64_t startOrder_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
// voice_engine.cpp — note routing, allocation/stealing, the mono held stack, panic, and the
|
||||||
|
// block render loops. See voice_engine.h for the contract.
|
||||||
|
//
|
||||||
|
// The render loops below call Voice::renderFrame / renderFrameStereo, which are inline in
|
||||||
|
// voice.h precisely so this TU boundary costs nothing on the per-sample path.
|
||||||
|
|
||||||
|
#include "core/instrument/engine/voice_engine.h"
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
VoiceEngine::VoiceEngine(std::size_t maxVoices, const SampleData& sample,
|
||||||
|
std::size_t preserveVoiceCap,
|
||||||
|
std::int64_t preserveWindowFrames,
|
||||||
|
VoiceMode voiceMode, MonoTrigger monoTrigger,
|
||||||
|
bool takeoverDeclick)
|
||||||
|
// MONO always uses voices_[0] only (last-note priority, single voice); size to 1 so
|
||||||
|
// the "only voices_[0] is ever driven" invariant is structurally enforced — no latent
|
||||||
|
// RT-discipline risk if a future mono path touched voices_[1..]. maxVoices == 0 clamps
|
||||||
|
// to 1 (documented degenerate: at least one voice so a note-on is always serviceable).
|
||||||
|
: voices_(voiceMode == VoiceMode::Mono ? 1
|
||||||
|
: (maxVoices == 0 ? 1 : maxVoices)),
|
||||||
|
sample_(sample),
|
||||||
|
preserveVoiceCap_(preserveVoiceCap),
|
||||||
|
voiceMode_(voiceMode), monoTrigger_(monoTrigger),
|
||||||
|
takeoverDeclick_(takeoverDeclick) {
|
||||||
|
// Pre-size every voice's Preserve shifters HERE (construction is off the audio thread), so
|
||||||
|
// note-on never allocates. A 0 window leaves them pass-through (no ring). This is the one
|
||||||
|
// allocation point for the shifter rings across the engine's lifetime.
|
||||||
|
if (preserveWindowFrames > 1) {
|
||||||
|
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||||
|
voices_[i].presizePreserveShifters(preserveWindowFrames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t VoiceEngine::activePreserveVoices() const {
|
||||||
|
// Count only voices that are SOUNDING A NOTE (playable span still running), not voices
|
||||||
|
// that have finished their note but are still ringing out a declick tail. A ramp-only
|
||||||
|
// past-end voice must not consume a cap slot — that would cause a new Preserve note-on to
|
||||||
|
// be dropped during the narrow ~4 ms window the ramp lives.
|
||||||
|
std::size_t n = 0;
|
||||||
|
for (const Voice& v : voices_) {
|
||||||
|
if (v.soundingNote() && v.pitchEngine() == PitchEngine::Preserve) ++n;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t VoiceEngine::allocateVoice() {
|
||||||
|
// 1. A free (idle) voice, lowest index for determinism.
|
||||||
|
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||||
|
if (!voices_[i].active()) return i;
|
||||||
|
}
|
||||||
|
// 2. All busy -> steal. Prefer the oldest voice already in release (a dying tail),
|
||||||
|
// else the oldest voice overall. "Oldest" = smallest startOrder.
|
||||||
|
std::size_t bestReleasing = kNoVoice;
|
||||||
|
std::uint64_t bestReleasingOrder = 0;
|
||||||
|
std::size_t bestOverall = kNoVoice;
|
||||||
|
std::uint64_t bestOverallOrder = 0;
|
||||||
|
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||||
|
const std::uint64_t order = voices_[i].startOrder();
|
||||||
|
if (voices_[i].releasing()) {
|
||||||
|
if (bestReleasing == kNoVoice || order < bestReleasingOrder) {
|
||||||
|
bestReleasing = i;
|
||||||
|
bestReleasingOrder = order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestOverall == kNoVoice || order < bestOverallOrder) {
|
||||||
|
bestOverall = i;
|
||||||
|
bestOverallOrder = order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestReleasing != kNoVoice ? bestReleasing : bestOverall;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoiceEngine::removeHeld(int note) {
|
||||||
|
for (std::size_t i = 0; i < heldCount_; ++i) {
|
||||||
|
if (heldStack_[i].note == static_cast<std::uint8_t>(note)) {
|
||||||
|
// Shift the notes above it down one slot (press order preserved).
|
||||||
|
for (std::size_t j = i + 1; j < heldCount_; ++j) heldStack_[j - 1] = heldStack_[j];
|
||||||
|
--heldCount_;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t VoiceEngine::monoNoteOn(int note, int velocity) {
|
||||||
|
// Reject out-of-range notes BEFORE touching the held stack: HeldNote stores the note as a
|
||||||
|
// uint8, so an unguarded value (e.g. 256, or a negative) would alias mod 256 onto a real
|
||||||
|
// held note and corrupt the stack. Mirrored in monoNoteOff.
|
||||||
|
if (note < 0 || note > 127) return kNoVoice;
|
||||||
|
// Nothing decoded: a defined no-play, and the note must not join the stack (it cannot
|
||||||
|
// sound, so it must not later take the voice back on a fallback).
|
||||||
|
if (!sample_.playable()) return kNoVoice;
|
||||||
|
|
||||||
|
// The note joins (or moves to) the top of the held stack. Velocity is clamped into the
|
||||||
|
// byte for storage only; the voice start below receives the caller's value untouched.
|
||||||
|
removeHeld(note);
|
||||||
|
if (heldCount_ < heldStack_.size()) {
|
||||||
|
const int vclamped = velocity < 0 ? 0 : (velocity > 127 ? 127 : velocity);
|
||||||
|
heldStack_[heldCount_++] = HeldNote{static_cast<std::uint8_t>(note),
|
||||||
|
static_cast<std::uint8_t>(vclamped)};
|
||||||
|
}
|
||||||
|
|
||||||
|
Voice& v = voices_[0];
|
||||||
|
// LEGATO takeover, keyed on the HELD-STACK DEPTH: after the push above, heldCount_ >= 2
|
||||||
|
// means another note was already physically held — the exact "takeover within a phrase"
|
||||||
|
// predicate. (The previous guard, `active && !releasing`, broke for TRIGGER: release() is
|
||||||
|
// a no-op there, so releasing_ never latches and a one-shot still ringing after the last
|
||||||
|
// key-up was silently RETUNED in place instead of re-attacked. NOTE: a one-held-note
|
||||||
|
// same-note re-press (heldCount_ becomes 1 after the removeHeld/re-push above — so
|
||||||
|
// heldCount_ < 2) re-attacks rather than retuning, the correct fresh-phrase behavior.)
|
||||||
|
//
|
||||||
|
// soundingNote() (not just active()): a voice whose note has run to its play-end but is
|
||||||
|
// still ringing a declick tail must NOT be retuned — that would move the pitch of a dying
|
||||||
|
// ramp rather than restarting the new note, producing a silent note on the common
|
||||||
|
// "hammer same key while a past-end ring-out is active" path. The tail should keep fading;
|
||||||
|
// the new note-on restarts the voice normally (falls through to start() below).
|
||||||
|
if (v.soundingNote() && heldCount_ >= 2 && monoTrigger_ == MonoTrigger::Legato) {
|
||||||
|
v.retune(note);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// RETRIGGER takeover / first note of a phrase: (re)start the voice. The declick opt-in
|
||||||
|
// rides every mono restart; start() self-gates it on the voice being ACTIVE, so a
|
||||||
|
// first-note fresh start never ramps — only a hard cut of a sounding tone.
|
||||||
|
v.start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||||
|
v.setStartOrder(nextStartOrder_++);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoiceEngine::monoNoteOff(int note) {
|
||||||
|
// Same range guard as monoNoteOn: removeHeld compares against the uint8-cast note, so an
|
||||||
|
// unguarded out-of-range off (e.g. 256 -> 0 mod 256) would evict a legitimately held note.
|
||||||
|
if (note < 0 || note > 127) return;
|
||||||
|
removeHeld(note);
|
||||||
|
Voice& v = voices_[0];
|
||||||
|
// Releasing a note that is not the sounding one (a lower held note or an already-released
|
||||||
|
// note) changes nothing audible.
|
||||||
|
if (!v.active() || v.releasing() || v.note() != note) return;
|
||||||
|
|
||||||
|
if (heldCount_ == 0) {
|
||||||
|
v.release(); // last finger up: gate off (Trigger ignores this and plays through).
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// FALLBACK: the most-recent still-held note takes the voice back (last-note priority).
|
||||||
|
const HeldNote fb = heldStack_[heldCount_ - 1];
|
||||||
|
if (monoTrigger_ == MonoTrigger::Legato) {
|
||||||
|
v.retune(fb.note); // glide back, no re-attack
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Retrigger fallback: re-strike the fallen-back-to note at its own original velocity.
|
||||||
|
// Peer restart site of monoNoteOn's takeover — same declick opt-in (the fallback also
|
||||||
|
// hard-cuts the sounding tone).
|
||||||
|
v.start(fb.note, fb.velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||||
|
v.setStartOrder(nextStartOrder_++);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t VoiceEngine::noteOn(int note, int velocity) {
|
||||||
|
if (voiceMode_ == VoiceMode::Mono) return monoNoteOn(note, velocity);
|
||||||
|
if (!sample_.playable()) return kNoVoice; // nothing decoded: defined no-play.
|
||||||
|
|
||||||
|
// Preserve voice cap: a Preserve voice is materially heavier than Varispeed (a per-voice
|
||||||
|
// OLA shifter). When a cap is set and it is already reached, DROP a new Preserve note-on
|
||||||
|
// rather than glitch (a defined no-play — no shifter is allocated). Varispeed notes are
|
||||||
|
// unaffected. A voice already sounding is never cut by this cap; only NEW Preserve onsets
|
||||||
|
// past the cap are refused.
|
||||||
|
if (preserveVoiceCap_ > 0 && sample_.play.pitchEngine == PitchEngine::Preserve &&
|
||||||
|
activePreserveVoices() >= preserveVoiceCap_) {
|
||||||
|
return kNoVoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The voice's Preserve shifters were pre-sized at engine construction (off-thread), so
|
||||||
|
// start() only reset()s + warm()s them — no allocation on this audio-thread path.
|
||||||
|
// The takeover declick rides the STEAL restart too: start() self-gates on the voice being
|
||||||
|
// active, so a free-voice start never ramps — only an at-cap steal, which is the same hard
|
||||||
|
// cut of a sounding tone as the mono retrig takeover.
|
||||||
|
const std::size_t v = allocateVoice();
|
||||||
|
voices_[v].start(note, velocity, sample_, /*declickTakeover=*/takeoverDeclick_);
|
||||||
|
voices_[v].setStartOrder(nextStartOrder_++);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoiceEngine::noteOff(int note) {
|
||||||
|
if (voiceMode_ == VoiceMode::Mono) { monoNoteOff(note); return; }
|
||||||
|
// Release the NEWEST active, non-releasing voice on this note (largest startOrder),
|
||||||
|
// so a re-triggered note releases its newest instance first and older tails ring.
|
||||||
|
std::size_t target = kNoVoice;
|
||||||
|
std::uint64_t bestOrder = 0;
|
||||||
|
for (std::size_t i = 0; i < voices_.size(); ++i) {
|
||||||
|
if (voices_[i].active() && !voices_[i].releasing() &&
|
||||||
|
voices_[i].note() == note) {
|
||||||
|
const std::uint64_t order = voices_[i].startOrder();
|
||||||
|
if (target == kNoVoice || order > bestOrder) {
|
||||||
|
target = i;
|
||||||
|
bestOrder = order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target != kNoVoice) voices_[target].release();
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoiceEngine::allNotesOff() {
|
||||||
|
// CC 123. Clear the mono held stack so no fallback can resurrect a phantom note (the
|
||||||
|
// stuck-note scenario: a lost note-off leaves an entry that monoNoteOff's fallback
|
||||||
|
// restarts and sustains forever with no key held), then gate off every active voice.
|
||||||
|
// Gate voices enter their release tail; Trigger one-shots ignore release by design and
|
||||||
|
// play through their bounded play length. RT-safe: no allocation, bounded by the pool size.
|
||||||
|
heldCount_ = 0;
|
||||||
|
for (Voice& v : voices_) {
|
||||||
|
if (v.active()) v.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoiceEngine::allSoundsOff() {
|
||||||
|
// CC 120. Hard-stop EVERY voice immediately (no release ramp — silences Trigger one-shots
|
||||||
|
// that allNotesOff() cannot stop) and clear the mono held stack. RT-safe: no allocation,
|
||||||
|
// bounded by the pool size.
|
||||||
|
heldCount_ = 0;
|
||||||
|
for (Voice& v : voices_) {
|
||||||
|
v.hardStop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
||||||
|
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
|
||||||
|
// The VST3 process callback hands us the host's output channel buffer here, so the
|
||||||
|
// audio thread never touches the heap.
|
||||||
|
if (out == nullptr || frameCount == 0) return;
|
||||||
|
for (Voice& voice : voices_) {
|
||||||
|
if (!voice.active()) continue;
|
||||||
|
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||||
|
if (!voice.active()) break;
|
||||||
|
out[f] += voice.renderFrame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoiceEngine::render(AudioSample* left, AudioSample* right, std::size_t frameCount) {
|
||||||
|
// Real-time safe stereo mix: no allocation, no resize. Sum each active voice's per-channel
|
||||||
|
// contribution into the caller's two buffers. Mirrors the mono loop exactly (same voice
|
||||||
|
// iteration, same mid-block idle short-circuit) so stereo and mono share one stealing/idle
|
||||||
|
// discipline; only the per-frame call differs (renderFrameStereo vs renderFrame).
|
||||||
|
if (left == nullptr || right == nullptr || frameCount == 0) return;
|
||||||
|
for (Voice& voice : voices_) {
|
||||||
|
if (!voice.active()) continue;
|
||||||
|
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||||
|
if (!voice.active()) break;
|
||||||
|
AudioSample l = 0.0f, r = 0.0f;
|
||||||
|
voice.renderFrameStereo(l, r);
|
||||||
|
left[f] += l;
|
||||||
|
right[f] += r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VoiceEngine::render(std::vector<AudioSample>& out, std::size_t frameCount) {
|
||||||
|
// Off-thread / test path: grow the buffer (this allocates — never call under
|
||||||
|
// process), zero-fill the appended span, then delegate to the RT mix loop so both
|
||||||
|
// overloads share exactly one summation path.
|
||||||
|
const std::size_t base = out.size();
|
||||||
|
out.resize(base + frameCount, 0.0f);
|
||||||
|
render(out.data() + base, frameCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t VoiceEngine::activeVoiceCount() const {
|
||||||
|
std::size_t n = 0;
|
||||||
|
for (const Voice& v : voices_) {
|
||||||
|
if (v.active()) ++n;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
#pragma once
|
||||||
|
// voice_engine.h — the COLD half of the sampler engine: note routing, voice allocation and
|
||||||
|
// stealing, the mono held-note stack, the two-tier panic, and the block render loops. The
|
||||||
|
// per-voice per-sample work it drives is inline in voice.h, so render's inner loop keeps its
|
||||||
|
// present inline shape across this seam.
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/audio/peaks.h"
|
||||||
|
#include "core/instrument/engine/play_params.h"
|
||||||
|
#include "core/instrument/engine/voice.h"
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
using audio::AudioSample;
|
||||||
|
|
||||||
|
// The polyphonic voice engine: a fixed pool of voices over ONE loaded capture, 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),
|
||||||
|
// 2. else the oldest voice overall (longest-held note gives way to the new one).
|
||||||
|
// "Oldest" = smallest startOrder (assigned monotonically at note-on) — the standard
|
||||||
|
// hardware-sampler policy.
|
||||||
|
class VoiceEngine {
|
||||||
|
public:
|
||||||
|
// Builds an engine with `maxVoices` voices playing `sample` (must outlive the engine —
|
||||||
|
// held by reference, never copies PCM). Every playback parameter rides on the sample; the
|
||||||
|
// engine holds no parameters of its own beyond the voice-system config below.
|
||||||
|
// `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 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`: 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 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`: when true, every restart of a sounding voice (mono retrigger
|
||||||
|
// takeover/fallback, 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 SampleData& sample,
|
||||||
|
std::size_t preserveVoiceCap = 0, std::int64_t preserveWindowFrames = 0,
|
||||||
|
VoiceMode voiceMode = VoiceMode::Poly,
|
||||||
|
MonoTrigger monoTrigger = MonoTrigger::Retrigger,
|
||||||
|
bool takeoverDeclick = false);
|
||||||
|
|
||||||
|
// MIDI note-on. Allocates a free voice, or steals one per the policy above. Returns the
|
||||||
|
// index of the voice used, or kNoVoice when nothing is playable (no decoded PCM, an
|
||||||
|
// out-of-range note, or a Preserve note-on past the cap) — a defined no-play, not an error.
|
||||||
|
std::size_t noteOn(int note, int velocity);
|
||||||
|
|
||||||
|
// MIDI note-off. Releases the most-recently-started active, non-releasing voice
|
||||||
|
// playing `note` (so a re-triggered same note releases the newest first, leaving
|
||||||
|
// the older tail to ring — matches hardware behavior). No-op if none match.
|
||||||
|
void noteOff(int note);
|
||||||
|
|
||||||
|
// 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 (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();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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). 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).
|
||||||
|
std::size_t activeVoiceCount() const;
|
||||||
|
|
||||||
|
std::size_t maxVoices() const { return voices_.size(); }
|
||||||
|
|
||||||
|
static constexpr std::size_t kNoVoice = static_cast<std::size_t>(-1);
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Picks a voice to (re)use for a new note-on: a free voice if any, else a stolen
|
||||||
|
// one per the documented policy. Always returns a valid index (maxVoices >= 1).
|
||||||
|
std::size_t allocateVoice();
|
||||||
|
|
||||||
|
// 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: last-note priority over a held-note stack. The stack holds every
|
||||||
|
// currently-held, playable note in press order (top = most recent = the sounding note).
|
||||||
|
// 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; };
|
||||||
|
|
||||||
|
// Push to the stack and take the voice over (legato retune, else a fresh start). Returns
|
||||||
|
// 0 (the mono voice) or kNoVoice for an unplayable/out-of-range note (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);
|
||||||
|
// 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 SampleData& sample_;
|
||||||
|
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; // 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
// zone_params.h — the per-zone play-parameter VALUE STRUCTS + per-instance mode enums the
|
|
||||||
// sampler engine, the sample_map resolution layer, the ComponentState codec, and the editor
|
|
||||||
// all share (Q-W2v header split, T4-14/T4-17). Split out of sampler_core.h so a UI or codec
|
|
||||||
// TU that reads a param struct no longer recompiles when a Voice/VoiceEngine member changes.
|
|
||||||
// PURE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes — standard library + peaks only.
|
|
||||||
// The per-frame EVALUATOR classes (AdsrEnvelope / TriggerEnvelope / PitchEnvelope) and the
|
|
||||||
// engine (Keymap / Voice / VoiceEngine) stay in sampler_core.h.
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include "core/audio/peaks.h" // AudioSample (float)
|
|
||||||
|
|
||||||
namespace reasampler {
|
|
||||||
|
|
||||||
// Q-W1 interim: the flat `reasampler` namespace is the engine family's home until its own
|
|
||||||
// re-namespace lands; the deps live in their sub-namespace homes.
|
|
||||||
using audio::AudioSample;
|
|
||||||
|
|
||||||
// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7
|
|
||||||
// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders
|
|
||||||
// per-channel. A PERFORMANCE choice the instrument owns (component state), never written
|
|
||||||
// to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain
|
|
||||||
// value so the shell (bus negotiation, state) and the engine share one spelling; the core
|
|
||||||
// itself never branches on it — the mode only picks which render overload the shell drives.
|
|
||||||
enum class ChannelMode { Mono, Stereo };
|
|
||||||
|
|
||||||
// The instrument's per-instance VOICE MODE (Phase S voice redesign). POLY is today's
|
|
||||||
// polyphonic engine (fixed pool + bounded stealing); MONO is a single voice with LAST-NOTE
|
|
||||||
// priority over a held-note stack (classic mono synth: a new note takes the voice over; the
|
|
||||||
// release of the top note falls back to the most-recent still-held note). A PERFORMANCE
|
|
||||||
// choice the instrument owns (component state), never a bank fact. Default Poly preserves
|
|
||||||
// current behavior.
|
|
||||||
enum class VoiceMode { Poly, Mono };
|
|
||||||
|
|
||||||
// How a MONO takeover treats the envelopes (Phase S — Daniel: explicitly toggleable).
|
|
||||||
// RETRIGGER restarts the amplitude (and pitch) envelope on every new mono note. LEGATO keeps
|
|
||||||
// the envelope running when a note is taken over while another is held — pitch moves without
|
|
||||||
// a re-attack (and the fallback on top-note release glides back the same way). Legato applies
|
|
||||||
// only to a SAME-SAMPLE takeover: crossing into a zone playing a different sample restarts
|
|
||||||
// the voice (one read head cannot glide between two PCM streams; a re-attack on a sample
|
|
||||||
// change is the deterministic, documented fallback). Meaningless in Poly. Default Retrigger.
|
|
||||||
enum class MonoTrigger { Retrigger, Legato };
|
|
||||||
|
|
||||||
// The user-parameterized polyphony bound (Phase S): a per-instance persisted voice count.
|
|
||||||
// One spelling shared by the engine, the component-state (de)serializer, and the editor's
|
|
||||||
// control so the range can never drift apart. Default 16 == the pre-Phase-S fixed pool.
|
|
||||||
inline constexpr int kMinVoiceCount = 1;
|
|
||||||
inline constexpr int kMaxVoiceCount = 32;
|
|
||||||
inline constexpr int kDefaultVoiceCount = 16;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because
|
|
||||||
// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching
|
|
||||||
// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower
|
|
||||||
// with the rest of the engine machinery; only the value structs need to precede SampleData.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack
|
|
||||||
// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below.
|
|
||||||
struct AdsrParams {
|
|
||||||
std::int64_t attackFrames = 0;
|
|
||||||
std::int64_t holdFrames = 0; // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR
|
|
||||||
std::int64_t decayFrames = 0;
|
|
||||||
double sustainLevel = 1.0; // 0..1
|
|
||||||
std::int64_t releaseFrames = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's
|
|
||||||
// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop,
|
|
||||||
// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone
|
|
||||||
// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before.
|
|
||||||
enum class PlayMode { Gate, Trigger };
|
|
||||||
|
|
||||||
// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span
|
|
||||||
// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)),
|
|
||||||
// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over
|
|
||||||
// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play
|
|
||||||
// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger.
|
|
||||||
struct TriggerParams {
|
|
||||||
double lengthFraction = 1.0; // (0,1] of the post-start span to play
|
|
||||||
std::int64_t fadeInFrames = 0; // 0->1 ramp at the head
|
|
||||||
std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd
|
|
||||||
};
|
|
||||||
|
|
||||||
// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default
|
|
||||||
// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool)
|
|
||||||
// so a third curve can join without a signature change.
|
|
||||||
enum class FadeCurve { EqualPower, Linear };
|
|
||||||
|
|
||||||
// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted.
|
|
||||||
inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower;
|
|
||||||
|
|
||||||
// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration
|
|
||||||
// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances
|
|
||||||
// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length).
|
|
||||||
enum class PitchEngine { Varispeed, Preserve };
|
|
||||||
|
|
||||||
// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching"
|
|
||||||
// directive). ONE constant to flip if Varispeed should be the default instead. This is the
|
|
||||||
// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's
|
|
||||||
// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core
|
|
||||||
// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16
|
|
||||||
// engine" holds for the core's own regression tests (an octave up still halves duration in the
|
|
||||||
// bare engine); the Preserve product default is layered on above at (de)serialization.
|
|
||||||
inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve;
|
|
||||||
|
|
||||||
// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds
|
|
||||||
// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger =
|
|
||||||
// smoother on big transpositions. Onset latency is ZERO: start() primes the ring with the first
|
|
||||||
// window of real source, so output frame 0 IS source frame 0 regardless of window size (GA2 fix).
|
|
||||||
// One knob, resolved at voice allocation.
|
|
||||||
inline constexpr double kPreserveWindowMs = 50.0;
|
|
||||||
|
|
||||||
// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always
|
|
||||||
// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to
|
|
||||||
// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack
|
|
||||||
// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-).
|
|
||||||
struct PitchEnvParams {
|
|
||||||
bool enabled = false;
|
|
||||||
std::int64_t attackFrames = 0;
|
|
||||||
std::int64_t decayFrames = 0;
|
|
||||||
double peakSemitones = 0.0; // signed depth at the peak
|
|
||||||
};
|
|
||||||
|
|
||||||
// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData
|
|
||||||
// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16
|
|
||||||
// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope
|
|
||||||
// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the
|
|
||||||
// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one
|
|
||||||
// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine.
|
|
||||||
struct ZonePlayParams {
|
|
||||||
PlayMode playMode = PlayMode::Gate;
|
|
||||||
AdsrParams adsr; // Gate: the AHDSR envelope
|
|
||||||
TriggerParams trigger; // Trigger: %-length + fades
|
|
||||||
PitchEngine pitchEngine = PitchEngine::Varispeed;
|
|
||||||
PitchEnvParams pitchEnv; // AD pitch modulation, off by default
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that
|
|
||||||
// govern playback. The shell decodes the on-disk WAV and fills this; the core
|
|
||||||
// never touches a file.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// A loop over [start, end) frames, half-open. A zero-length loop (start == end)
|
|
||||||
// is the "no sustain loop" marker — a held note past the sample end goes silent
|
|
||||||
// rather than looping a zero span. absent-loop is modeled by leaving hasLoop false.
|
|
||||||
struct SampleLoop {
|
|
||||||
bool hasLoop = false;
|
|
||||||
std::int64_t start = 0; // first looped frame (inclusive)
|
|
||||||
std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end
|
|
||||||
};
|
|
||||||
|
|
||||||
// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is
|
|
||||||
// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample).
|
|
||||||
// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise
|
|
||||||
// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both
|
|
||||||
// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical
|
|
||||||
// across channels; only the sampled value differs. `rootNote` is the MIDI note the file was
|
|
||||||
// recorded at (S2 intrinsic) — the pitch that plays back at unity ratio.
|
|
||||||
struct SampleData {
|
|
||||||
std::vector<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::int64_t startFrame = 0;
|
|
||||||
|
|
||||||
// S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch
|
|
||||||
// envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is
|
|
||||||
// Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData.
|
|
||||||
ZonePlayParams play;
|
|
||||||
|
|
||||||
// 2 iff a matching-length second channel exists; else 1. A framesR of a different
|
|
||||||
// length than frames is treated as absent (mono) — a malformed pair never half-plays.
|
|
||||||
int channelCount() const {
|
|
||||||
return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace reasampler
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
reasampler_pure_library(bridge_marshal SOURCES bridge_marshal.cpp)
|
||||||
|
reasampler_test(bridge_marshal LINK bridge_marshal)
|
||||||
|
|
||||||
|
reasampler_pure_library(trigger_seam SOURCES trigger_seam.cpp)
|
||||||
|
# Links only trigger_seam — not even editor_geometry — the plainest data-boundary proof
|
||||||
|
# available.
|
||||||
|
reasampler_test(trigger_seam LINK trigger_seam)
|
||||||
|
|
||||||
|
reasampler_pure_library(bank_sync
|
||||||
|
SOURCES bank_sync.cpp
|
||||||
|
LINK PUBLIC assignment_request PRIVATE wire)
|
||||||
|
# Links only bank_sync (+ its assignment_request dep): linking more would break the
|
||||||
|
# plain-data-boundary proof.
|
||||||
|
reasampler_test(bank_sync LINK bank_sync)
|
||||||
|
|
||||||
|
# The state codec is shared with the extension's preset-blob path, so it must link WITHOUT
|
||||||
|
# the voice engine: velocity_curve (the curve field) and master_gain (the wire gain cap) only.
|
||||||
|
# play_params.h also pulls in filter/'s headers (FilterSettings, MorphLaw) for the v9 filter
|
||||||
|
# tail -- plain value types, so no filter symbol is linked and this stays true.
|
||||||
|
reasampler_pure_library(component_state_io
|
||||||
|
SOURCES component_state_io.cpp
|
||||||
|
LINK PUBLIC velocity_curve master_gain)
|
||||||
|
# Links only component_state_io, deliberately no sampler_core/pitch_shift: the structural
|
||||||
|
# proof the codec is engine-free, which is what keeps engine object code out of the extension.
|
||||||
|
reasampler_test(component_state_io LINK component_state_io)
|
||||||
|
|
||||||
|
# The mapping's product is plain SampleData, so the voice engine is not a dependency.
|
||||||
|
reasampler_pure_library(sample_map
|
||||||
|
SOURCES sample_map.cpp
|
||||||
|
LINK PUBLIC bank_book wav_codec velocity_curve peaks)
|
||||||
|
# Links only sample_map + component_state_io: the same plain-data-boundary proof, spanning
|
||||||
|
# both halves of the mapping/codec split where the frozen-format assertions live.
|
||||||
|
reasampler_test(sample_map LINK sample_map component_state_io)
|
||||||
@@ -10,20 +10,14 @@
|
|||||||
namespace reasampler::instrument::map {
|
namespace reasampler::instrument::map {
|
||||||
|
|
||||||
std::int64_t parseBankGeneration(const std::string& raw) {
|
std::int64_t parseBankGeneration(const std::string& raw) {
|
||||||
// Whole-string, non-negative decimal parse WITHOUT exceptions or locale
|
// Whole-string, non-negative decimal parse, no exceptions/locale surprises (core/wire's
|
||||||
// surprises — the shared core/wire accumulate (Q-W1, T2-01b). A leading
|
// guarded accumulate). Leading sign, non-digit, empty, or int64 overflow -> absent (0).
|
||||||
// '+' / '-', any non-digit, an empty string, or overflow past int64 max all
|
|
||||||
// reject to the absent default (0); the guarded accumulate means a
|
|
||||||
// pathologically long digit run can never wrap into a bogus small value.
|
|
||||||
std::int64_t value = 0;
|
std::int64_t value = 0;
|
||||||
if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent;
|
if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent;
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string formatBankGeneration(std::int64_t generation) {
|
std::string formatBankGeneration(std::int64_t generation) {
|
||||||
// Non-negative decimal; a negative (should never be produced by the writer) formats as
|
|
||||||
// its std::to_string form and would parse back to 0, so the writer's monotonic counter
|
|
||||||
// stays in the >= 0 domain by construction.
|
|
||||||
return std::to_string(generation);
|
return std::to_string(generation);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,23 +31,20 @@ AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& re
|
|||||||
AssignConsumeDecision d;
|
AssignConsumeDecision d;
|
||||||
d.consumedGeneration = lastConsumed; // default: nothing changes
|
d.consumedGeneration = lastConsumed; // default: nothing changes
|
||||||
|
|
||||||
// Rule 1: no request, or not newer than what we already consumed -> nothing new.
|
// Rule 1: no request, or not newer than what we already consumed.
|
||||||
if (!request) return d;
|
if (!request) return d;
|
||||||
if (request->generation <= lastConsumed) return d;
|
if (request->generation <= lastConsumed) return d;
|
||||||
|
|
||||||
// Rule 2: a new request, but this instance is not the target -> do not act, do NOT
|
// Rule 2: new but not our target -> don't advance the marker, stay eligible.
|
||||||
// advance the marker (stay eligible if focus later lands here). No thundering herd.
|
|
||||||
if (!isFocusedTarget) return d;
|
if (!isFocusedTarget) return d;
|
||||||
|
|
||||||
// The request is new AND we are the target: it will be consumed-as-seen either way, so
|
// New and our target: consumed-as-seen either way.
|
||||||
// advance the marker to its generation so it is never re-evaluated.
|
|
||||||
d.consumedGeneration = request->generation;
|
d.consumedGeneration = request->generation;
|
||||||
|
|
||||||
// Rule 3: unresolvable (bankId, sampleId) -> DROP silently (reader requirement): marker
|
// Rule 3: unresolvable -> drop silently, marker already advanced above.
|
||||||
// advanced above, but no selection change.
|
|
||||||
if (!resolves) return d;
|
if (!resolves) return d;
|
||||||
|
|
||||||
// Rule 4: new, target, resolvable -> apply the selection.
|
// Rule 4: new, target, resolvable -> apply.
|
||||||
d.apply = true;
|
d.apply = true;
|
||||||
d.bankId = request->bankId;
|
d.bankId = request->bankId;
|
||||||
d.sampleId = request->sampleId;
|
d.sampleId = request->sampleId;
|
||||||
|
|||||||
@@ -1,21 +1,11 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// bank_sync — PURE decision logic for the S9 bank-generation change-detection and the
|
// bank_sync — decision logic for bank-generation change-detection and the instrument-side
|
||||||
// S8 instrument-side assignment-request consume. NO VST3, NO REAPER, NO SWELL, NO
|
// assignment-request consume. The instrument polls two "reasampler" ext-state keys off the
|
||||||
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the mirror of
|
// audio thread: the bank-generation counter (has the bank changed?) and the assignment
|
||||||
// sample_map / bridge_marshal splitting the fiddly, testable arithmetic out of a
|
// request (should I switch to a just-ingested sample?). The shell reads the raw strings and
|
||||||
// host-facing shell.
|
// owns cadence (a UI-thread timer, never `process`) + side effects (reloadInstrument,
|
||||||
//
|
// setSelectedSampleId); this module owns only the yes/no decisions, so they're provable
|
||||||
// WHY IT EXISTS (S9/S8 reader seams). The instrument polls two "reasampler" ext-state
|
// without a host. assignment_request.h owns the wire format; this owns the consume decision.
|
||||||
// keys off the audio thread: the S9 bank-generation counter (has the bank changed?) and
|
|
||||||
// the S8 assignment request (should I switch to a just-ingested sample?). The RAW string
|
|
||||||
// read crosses the bridge in the shell; every DECISION after — parse the generation
|
|
||||||
// stamp, decide whether it differs from what we last saw, decide whether a decoded
|
|
||||||
// assignment request is NEW-and-resolvable-and-worth-applying — is pure and lives here.
|
|
||||||
//
|
|
||||||
// The processor shell owns the cadence (a UI-thread timer, NEVER process) and the side
|
|
||||||
// effects (reloadInstrument, setSelectedSampleId); this module owns only the yes/no maths so
|
|
||||||
// the reader's rules are provable without a host. assignment_request.h owns the WIRE format
|
|
||||||
// (encode/decode); this module owns the CONSUME decision layered over a decoded request.
|
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
@@ -27,41 +17,26 @@ namespace reasampler::instrument::map {
|
|||||||
|
|
||||||
using wire::AssignmentRequest;
|
using wire::AssignmentRequest;
|
||||||
|
|
||||||
// The S9 bank-generation "generation 0 = never stamped" default. A project saved before
|
// Default for a project with no bank_generation key yet (pre-existing project); the first
|
||||||
// S9 shipped carries no bank_generation key; the bridge read yields an absent/empty value
|
// real bump (>= 1) then reads as a change against this.
|
||||||
// which parses to this, and the first real bump (>= 1) then reads as a change. Matches the
|
|
||||||
// writer's monotonic-from-1 counter (the extension bumps to 1 on the first mutation).
|
|
||||||
inline constexpr std::int64_t kBankGenerationAbsent = 0;
|
inline constexpr std::int64_t kBankGenerationAbsent = 0;
|
||||||
|
|
||||||
// Parse the raw bank-generation ext-state value the bridge read. The writer stamps a
|
// Absent / empty / malformed / negative / overflowing all yield kBankGenerationAbsent (0),
|
||||||
// non-negative decimal integer (formatBankGeneration). Absent / empty / malformed / negative
|
// never a crash or spurious reload. Whole-string parse: trailing garbage rejects the value,
|
||||||
// / overflowing all yield kBankGenerationAbsent (0) — the reader treats any unreadable stamp
|
// so a torn/partial write is ignored until the next clean poll.
|
||||||
// as "generation 0", so a pre-S9 or corrupt value is a clean default, never a crash and never
|
|
||||||
// a spurious reload storm (0 vs a previously-seen 0 is no change). Whole-string parse: trailing
|
|
||||||
// garbage after the digits rejects the value (returns 0), so a torn/partial write is ignored
|
|
||||||
// until the next clean poll (the read tolerates staleness by design — it reloads on the NEXT
|
|
||||||
// poll once the value is clean).
|
|
||||||
std::int64_t parseBankGeneration(const std::string& raw);
|
std::int64_t parseBankGeneration(const std::string& raw);
|
||||||
|
|
||||||
// Format a bank-generation counter for the ext-state stamp. The inverse of
|
// Inverse of parseBankGeneration: plain decimal, no sign, no padding — byte-stable across
|
||||||
// parseBankGeneration for a non-negative value: a plain decimal, no sign, no padding, so
|
// writes of the same value.
|
||||||
// the stamp is byte-stable across writes of the same value.
|
|
||||||
std::string formatBankGeneration(std::int64_t generation);
|
std::string formatBankGeneration(std::int64_t generation);
|
||||||
|
|
||||||
// Has the bank generation changed since the reader last saw `seen`? True when `current`
|
// True when `current` differs from `seen` (not just increases — a project switch/reload can
|
||||||
// differs from `seen` — the reader then triggers a reload. Any difference counts (not just
|
// legitimately lower the value, and the reader should still re-read the bank).
|
||||||
// an increase): the writer is monotonic, but a project switch or reload can legitimately
|
|
||||||
// lower the value, and the reader should re-read the bank in that case too. `seen` starts at
|
|
||||||
// kBankGenerationAbsent so the first non-zero generation reads as a change (the pre-S9 /
|
|
||||||
// first-bump refresh the spec requires).
|
|
||||||
bool bankGenerationChanged(std::int64_t seen, std::int64_t current);
|
bool bankGenerationChanged(std::int64_t seen, std::int64_t current);
|
||||||
|
|
||||||
// The verdict of the S8 assignment-request consume decision (below). A pure value the
|
// Verdict of the assignment-request consume decision below. apply and consumedGeneration
|
||||||
// processor shell acts on: apply the selection (or not) and advance the consumed marker
|
// advancing are NOT the same event — a request naming an unresolvable sample is dropped
|
||||||
// (or not). Distinct booleans because the two are NOT the same event — a request may be
|
// (consumed-as-seen) without applying, so the shell doesn't re-evaluate it every poll.
|
||||||
// consumed-as-seen (marker advances) without being applied (it named an unresolvable
|
|
||||||
// sample and was DROPPED per the reader requirement), so the shell must not re-evaluate it
|
|
||||||
// every poll.
|
|
||||||
struct AssignConsumeDecision {
|
struct AssignConsumeDecision {
|
||||||
bool apply = false; // set this instance's selection to (bankId, sampleId) + reload
|
bool apply = false; // set this instance's selection to (bankId, sampleId) + reload
|
||||||
std::string bankId; // the request's bank (valid only when apply)
|
std::string bankId; // the request's bank (valid only when apply)
|
||||||
@@ -69,37 +44,15 @@ struct AssignConsumeDecision {
|
|||||||
std::int64_t consumedGeneration = 0; // the marker to persist (== lastConsumed when nothing new)
|
std::int64_t consumedGeneration = 0; // the marker to persist (== lastConsumed when nothing new)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Decide whether to CONSUME a decoded assignment request (S8 instrument-side reader).
|
// `lastConsumed` persists across reopen so a request already applied and manually changed
|
||||||
|
// away from is not reapplied. `resolves` is whether (bankId, sampleId) exists in the live
|
||||||
|
// bank right now. `isFocusedTarget` gates thundering-herd (only the focused-editor instance
|
||||||
|
// applies; others neither apply nor advance their marker, staying eligible if focus moves).
|
||||||
//
|
//
|
||||||
// `request` — the decoded assignment request (nullopt when the assign_request key
|
// Rules, in order: (1) no request or generation <= lastConsumed -> no-op. (2) new but not
|
||||||
// is absent / malformed — nothing pending).
|
// the target -> no-op, marker unchanged (stays eligible later). (3) new, target, but doesn't
|
||||||
// `lastConsumed` — the generation this instance last consumed (persisted in component
|
// resolve -> drop silently, marker still advances (consumed-as-seen, never re-evaluated).
|
||||||
// state so a re-open does not re-apply a request the user already got,
|
// (4) new, target, resolves -> apply + advance marker.
|
||||||
// then manually changed away from). Defaults to 0 for a fresh instance.
|
|
||||||
// `resolves` — whether the request's (bankId, sampleId) resolves to an existing bank
|
|
||||||
// sample RIGHT NOW (the shell computed this against the live bank blob).
|
|
||||||
// `isFocusedTarget` — whether THIS instance is the assignment target under the shell's
|
|
||||||
// thundering-herd policy (e.g. only the focused-editor instance applies).
|
|
||||||
// The shell passes true when this instance should act; false suppresses
|
|
||||||
// consumption entirely so a non-target instance neither applies nor
|
|
||||||
// advances its marker (it stays eligible if it later becomes the target).
|
|
||||||
//
|
|
||||||
// RULES (all pure, order matters):
|
|
||||||
// 1. No request, or an OLDER/equal generation (<= lastConsumed): nothing new — do not
|
|
||||||
// apply, marker unchanged. (Covers the re-open case: the persisted marker == the
|
|
||||||
// request's generation, so it is not re-applied.)
|
|
||||||
// 2. A NEW request (generation > lastConsumed) but NOT this instance's target: do not
|
|
||||||
// apply and do NOT advance the marker — a non-target instance must stay able to consume
|
|
||||||
// the request if focus later lands on it. (No thundering herd: only the target acts.)
|
|
||||||
// 3. A NEW request, this instance IS the target, but the (bankId, sampleId) does NOT
|
|
||||||
// resolve: DROP it silently (assignment_request.h reader requirement) — do not apply,
|
|
||||||
// but DO advance the marker to the request's generation so a stale/unresolvable request
|
|
||||||
// is consumed-as-seen and never re-evaluated (no error state, no selection change).
|
|
||||||
// 4. A NEW request, target, and resolvable: APPLY (selection <- (bankId, sampleId)) and
|
|
||||||
// advance the marker to the request's generation.
|
|
||||||
//
|
|
||||||
// The shell then: if apply, setSelectedSampleId + reloadInstrument; always persist
|
|
||||||
// consumedGeneration into component state when it advanced.
|
|
||||||
AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request,
|
AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& request,
|
||||||
std::int64_t lastConsumed, bool resolves,
|
std::int64_t lastConsumed, bool resolves,
|
||||||
bool isFocusedTarget);
|
bool isFocusedTarget);
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ namespace reasampler::instrument::map {
|
|||||||
|
|
||||||
std::optional<std::string> decodeGetProjExtState(int apiReturn,
|
std::optional<std::string> decodeGetProjExtState(int apiReturn,
|
||||||
const std::string& buffer) {
|
const std::string& buffer) {
|
||||||
// REAPER returns the length of the stored value; 0 means the key is absent. Guard
|
// 0 return means absent; guard the buffer too so a reused dirty buffer can't
|
||||||
// both the return AND the buffer: a caller that reused a dirty buffer must not
|
// surface stale bytes as a value.
|
||||||
// surface stale bytes as a value when the API reported nothing.
|
|
||||||
if (apiReturn <= 0 || buffer.empty()) return std::nullopt;
|
if (apiReturn <= 0 || buffer.empty()) return std::nullopt;
|
||||||
return buffer;
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,8 @@
|
|||||||
// bridge_marshal.h — PURE marshalling helper for the REAPER VST-host bridge read.
|
// bridge_marshal — pure GetProjExtState result decode for the REAPER VST-host bridge read.
|
||||||
// NO VST3, NO REAPER types at the boundary.
|
|
||||||
//
|
|
||||||
// The bridge shell (reaper_bridge.cpp) resolves REAPER API functions by name over the
|
|
||||||
// host callback and invokes them; the one fiddly-and-easy-to-get-wrong part around
|
|
||||||
// GetProjExtState — interpreting its int return against the buffer it filled — is pure
|
|
||||||
// and unit-tested here. Mirror of capture_paths / wav_codec splitting the arithmetic out
|
|
||||||
// of a REAPER-facing shell.
|
|
||||||
//
|
|
||||||
// The S1 spike ALSO carried a string-scan JSON reader (extractJsonStringField) as a
|
|
||||||
// stand-in until the instrument could parse the bank properly. S4 retired it: the
|
|
||||||
// instrument now parses the "reasampler" bank blob through the SHARED bank_book /
|
|
||||||
// bank_model JSON path (sample_map.cpp), so there is no second JSON parser. This module
|
|
||||||
// is back to its one honest job — the API-return decode.
|
|
||||||
//
|
//
|
||||||
// Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
|
// Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
|
||||||
// int GetProjExtState (ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz);
|
// int GetProjExtState(ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz)
|
||||||
// -- returns the length written (0 when the key is absent).
|
// returns the length written (0 when the key is absent).
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
@@ -26,18 +13,10 @@
|
|||||||
|
|
||||||
namespace reasampler::instrument::map {
|
namespace reasampler::instrument::map {
|
||||||
|
|
||||||
// Interpret a GetProjExtState result: the int return value (bytes the API reports for
|
// Value only when the API reported non-empty AND the buffer is non-empty — REAPER
|
||||||
// the key) and the buffer it filled. Returns the value only when the API reported a
|
// writes 0 and leaves the buffer untouched for an absent key, so stale buffer
|
||||||
// non-empty result AND the buffer is non-empty — REAPER writes 0 and leaves the buffer
|
// contents must never read as a hit.
|
||||||
// untouched for an absent key, and we must not treat stale buffer contents as a hit.
|
|
||||||
//
|
|
||||||
// `apiReturn` is GetProjExtState's return; `buffer` is the NUL-terminated string it
|
|
||||||
// wrote (already truncated to the C string by the caller).
|
|
||||||
std::optional<std::string> decodeGetProjExtState(int apiReturn,
|
std::optional<std::string> decodeGetProjExtState(int apiReturn,
|
||||||
const std::string& buffer);
|
const std::string& buffer);
|
||||||
|
|
||||||
// The GetProjExtState GROW-LOOP retry policy (T2-04) lived here through Q-W5; it
|
|
||||||
// was rehomed to core/wire/ext_state_read.h in Q-W6 (its consumers are 2:1
|
|
||||||
// extension-side, so it belongs on the neutral wire seam, not the instrument map).
|
|
||||||
|
|
||||||
} // namespace reasampler::instrument::map
|
} // namespace reasampler::instrument::map
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// component_state_io — the ComponentState envelope + zones-payload binary codec. See
|
// component_state_io — the ComponentState envelope + params-payload binary codec. See
|
||||||
// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7)
|
// component_state_io.h for the format ladders (envelope v1..v11, params payload v1..v9).
|
||||||
// and the why-a-separate-module note (Q-W2v, T4-13 ≡ T2-07). PURE: standard library +
|
// Every wire format is FROZEN — byte-identical across revisions.
|
||||||
// the pure sample_map value types + core/wire's LE byte codec (T4-20) + velocity_curve
|
|
||||||
// + master_gain. Every wire format is FROZEN — byte-identical to the pre-split writer.
|
|
||||||
|
|
||||||
#include "core/instrument/map/component_state_io.h"
|
#include "core/instrument/map/component_state_io.h"
|
||||||
|
|
||||||
@@ -13,7 +11,7 @@
|
|||||||
#include <utility> // std::move
|
#include <utility> // std::move
|
||||||
|
|
||||||
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap
|
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap
|
||||||
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec, T4-20)
|
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec)
|
||||||
|
|
||||||
namespace reasampler::instrument::map {
|
namespace reasampler::instrument::map {
|
||||||
|
|
||||||
@@ -28,41 +26,53 @@ namespace {
|
|||||||
// Signed 64-bit values ride the wire as their two's-complement unsigned image.
|
// Signed 64-bit values ride the wire as their two's-complement unsigned image.
|
||||||
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
|
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
|
||||||
|
|
||||||
// Append the zones payload — the shared body of the performance blob and the component blob,
|
// What a payload read yields. `adoptedSampleId` is non-empty ONLY for a retired zone-list
|
||||||
// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion
|
// payload that carried at least one zone: the first zone's capture, which supersedes the
|
||||||
// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail
|
// envelope's selection id (see the adoption rule in the header).
|
||||||
// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes
|
struct PayloadRead {
|
||||||
// the zone count so any reader can detect the record shape independently of the envelope version
|
InstrumentParams params;
|
||||||
// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip
|
std::string adoptedSampleId;
|
||||||
// through EITHER envelope with no envelope bump.
|
};
|
||||||
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
|
|
||||||
putLE(out, kZonesFormatMarker);
|
|
||||||
putLE(out, kZonesPayloadVersion);
|
|
||||||
putLE(out, static_cast<std::uint32_t>(map.zones.size()));
|
|
||||||
for (const PerformanceZone& z : map.zones) {
|
|
||||||
putLE(out, static_cast<std::uint32_t>(z.sampleId.size()));
|
|
||||||
out.insert(out.end(), z.sampleId.begin(), z.sampleId.end());
|
|
||||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.lowNote)));
|
|
||||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.highNote)));
|
|
||||||
out.push_back(z.rootOverride ? 1 : 0);
|
|
||||||
if (z.rootOverride) {
|
|
||||||
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.
|
|
||||||
out.push_back(z.loopOverride ? 1 : 0);
|
|
||||||
if (z.loopOverride) {
|
|
||||||
out.push_back(z.loopOverride->hasLoop ? 1 : 0);
|
|
||||||
putLE(out, asU64(z.loopOverride->start));
|
|
||||||
putLE(out, asU64(z.loopOverride->end));
|
|
||||||
}
|
|
||||||
out.push_back(z.startPoint ? 1 : 0);
|
|
||||||
if (z.startPoint) putLE(out, asU64(*z.startPoint));
|
|
||||||
|
|
||||||
// S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine).
|
// Emit the OVERRIDE trio shared by the v2..v7 per-zone record and the v8 single record, so
|
||||||
// Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames /
|
// the two shapes cannot drift byte-for-byte.
|
||||||
// fraction. Order matches the header's v5 record spec.
|
void putOverrides(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
||||||
const ZonePlaySeconds& pp = z.play;
|
out.push_back(p.rootOverride ? 1 : 0);
|
||||||
|
if (p.rootOverride) {
|
||||||
|
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(*p.rootOverride)));
|
||||||
|
}
|
||||||
|
out.push_back(p.loopOverride ? 1 : 0);
|
||||||
|
if (p.loopOverride) {
|
||||||
|
out.push_back(p.loopOverride->hasLoop ? 1 : 0);
|
||||||
|
putLE(out, asU64(p.loopOverride->start));
|
||||||
|
putLE(out, asU64(p.loopOverride->end));
|
||||||
|
}
|
||||||
|
out.push_back(p.startPoint ? 1 : 0);
|
||||||
|
if (p.startPoint) putLE(out, asU64(*p.startPoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A velocity curve: 4-byte LE control-point count, then per point velocity + amp as doubles.
|
||||||
|
// The amp curve (v7) and the filter's own curve (v9) share this shape.
|
||||||
|
void putCurve(std::vector<std::uint8_t>& out, const VelocityCurve& curve) {
|
||||||
|
const std::vector<VelocityPoint>& pts = curve.points();
|
||||||
|
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||||
|
for (const VelocityPoint& pt : pts) {
|
||||||
|
putLE(out, doubleToBits(pt.velocity));
|
||||||
|
putLE(out, doubleToBits(pt.amp));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the params payload: marker + version + the single parameter record. Always emits
|
||||||
|
// the CURRENT payload version; the marker precedes the record so any reader detects the
|
||||||
|
// shape independent of the envelope version (see component_state_io.h).
|
||||||
|
void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p) {
|
||||||
|
putLE(out, kParamsFormatMarker);
|
||||||
|
putLE(out, kParamsPayloadVersion);
|
||||||
|
putOverrides(out, p);
|
||||||
|
|
||||||
|
// Play params: wall-clock times are SECONDS (doubles); trigger %-length + fades stay
|
||||||
|
// source frames/fraction. Field order matches the header's v5 tail spec verbatim.
|
||||||
|
const PlaySeconds& pp = p.play;
|
||||||
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
|
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
|
||||||
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
|
||||||
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
|
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
|
||||||
@@ -78,50 +88,123 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
|
|||||||
putLE(out, doubleToBits(pp.adsr.decaySeconds));
|
putLE(out, doubleToBits(pp.adsr.decaySeconds));
|
||||||
putLE(out, doubleToBits(pp.adsr.sustainLevel));
|
putLE(out, doubleToBits(pp.adsr.sustainLevel));
|
||||||
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
|
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
|
||||||
// PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar (1.0 = 100% ET).
|
// Key-tracking scalar (1.0 = 100% ET).
|
||||||
putLE(out, doubleToBits(z.keyTrack));
|
putLE(out, doubleToBits(p.keyTrack));
|
||||||
// PAYLOAD v7 (S-VIEW-9): the per-zone velocity->amp transfer curve, appended last. 4-byte LE
|
// The velocity->amp transfer curve: 4-byte LE control-point count, then per point
|
||||||
// control-point count, then per point velocity + amp as IEEE-754 doubles (endpoints included).
|
// velocity + amp as doubles (endpoints included, so N >= 2).
|
||||||
const std::vector<VelocityPoint>& pts = z.velocityCurve.points();
|
putCurve(out, p.velocityCurve);
|
||||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
// v9: the per-voice filter tail. The module's floats widen to doubles on the wire so the
|
||||||
for (const VelocityPoint& p : pts) {
|
// whole payload stays one numeric shape.
|
||||||
putLE(out, doubleToBits(p.velocity));
|
const FilterSeconds& f = pp.filter;
|
||||||
putLE(out, doubleToBits(p.amp));
|
out.push_back(f.enabled ? 1 : 0);
|
||||||
|
putLE(out, doubleToBits(static_cast<double>(f.settings.cutoffNorm)));
|
||||||
|
putLE(out, doubleToBits(static_cast<double>(f.settings.resonanceNorm)));
|
||||||
|
putLE(out, doubleToBits(static_cast<double>(f.settings.morphNorm)));
|
||||||
|
putLE(out, doubleToBits(static_cast<double>(f.settings.driveNorm)));
|
||||||
|
out.push_back(f.settings.morphLaw == engine::filter::MorphLaw::HighNotchLow ? 1 : 0);
|
||||||
|
putLE(out, doubleToBits(f.modAmount));
|
||||||
|
putLE(out, doubleToBits(f.velAmount));
|
||||||
|
putLE(out, doubleToBits(f.keyTrack));
|
||||||
|
putLE(out, doubleToBits(f.env.attackSeconds));
|
||||||
|
putLE(out, doubleToBits(f.env.holdSeconds));
|
||||||
|
putLE(out, doubleToBits(f.env.decaySeconds));
|
||||||
|
putLE(out, doubleToBits(f.env.sustainLevel));
|
||||||
|
putLE(out, doubleToBits(f.env.releaseSeconds));
|
||||||
|
putCurve(out, f.velocityCurve);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read the play tail (v5 shape onward) into `p`. Shared by the legacy zone reader and the
|
||||||
|
// v8 single-record reader so the two can never disagree about field order.
|
||||||
|
void readSecondsPlayTail(ByteReader& r, InstrumentParams& p) {
|
||||||
|
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||||
|
p.play.adsr.holdSeconds = bitsToDouble(r.u64());
|
||||||
|
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||||
|
p.play.trigger.fadeInFrames = r.i64();
|
||||||
|
p.play.trigger.fadeOutFrames = r.i64();
|
||||||
|
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||||
|
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||||
|
p.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
|
||||||
|
p.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
|
||||||
|
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||||
|
p.play.adsr.attackSeconds = bitsToDouble(r.u64());
|
||||||
|
p.play.adsr.decaySeconds = bitsToDouble(r.u64());
|
||||||
|
p.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
||||||
|
p.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a velocity curve tail into `curve`. fromPoints repairs the X-order/endpoint invariant
|
||||||
|
// defensively; a truncated read leaves `curve` at whatever default it came in with.
|
||||||
|
void readCurveTail(ByteReader& r, VelocityCurve& curve) {
|
||||||
|
const std::uint32_t ptCount = r.u32();
|
||||||
|
std::vector<VelocityPoint> pts;
|
||||||
|
// 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 i = 0; i < ptCount && r.ok; ++i) {
|
||||||
|
const double vel = bitsToDouble(r.u64());
|
||||||
|
const double amp = bitsToDouble(r.u64());
|
||||||
|
pts.push_back(VelocityPoint{vel, amp});
|
||||||
|
}
|
||||||
|
if (r.ok) {
|
||||||
|
curve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read a zones payload from `r` into `map`. Shared by the performance parse and the component
|
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
|
||||||
// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the
|
// which is what makes a v8 blob play bit-identically under the new codec.
|
||||||
// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail —
|
void readFilterTail(ByteReader& r, InstrumentParams& p) {
|
||||||
// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read
|
FilterSeconds& f = p.play.filter;
|
||||||
// keeps the zones that parsed cleanly and drops the rest.
|
f.enabled = (r.u8() != 0);
|
||||||
// `projectRate` is the live host/project sample rate used to convert LEGACY v3 wall-clock frame
|
f.settings.cutoffNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||||
// counts (holdFrames, pitchEnv A/D) to the seconds domain at the read boundary: seconds = frames /
|
f.settings.resonanceNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||||
// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed.
|
f.settings.morphNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||||
void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
f.settings.driveNorm = static_cast<float>(bitsToDouble(r.u64()));
|
||||||
bool extended = false; // v2+: the S11 loop/start tail is present
|
f.settings.morphLaw = (r.u8() != 0) ? engine::filter::MorphLaw::HighNotchLow
|
||||||
std::uint32_t pv = 0; // payload version (0 = v1, no marker)
|
: engine::filter::MorphLaw::HighBandLow;
|
||||||
if (r.peekU32() == kZonesFormatMarker) {
|
// Same non-finite-falls-back-to-neutral guard as the v8 master gain above: these three
|
||||||
r.u32(); // consume the marker
|
// reach Voice::tickFilterCutoff's clamp compares and a static_cast<int>, both UB on NaN.
|
||||||
pv = r.u32(); // payload version
|
double modAmount = bitsToDouble(r.u64());
|
||||||
extended = (pv >= 2); // v2+ carries the loop/start tail
|
double velAmount = bitsToDouble(r.u64());
|
||||||
|
double keyTrack = bitsToDouble(r.u64());
|
||||||
|
f.modAmount = std::isfinite(modAmount) ? modAmount : 0.0;
|
||||||
|
f.velAmount = std::isfinite(velAmount) ? velAmount : 0.0;
|
||||||
|
f.keyTrack = std::isfinite(keyTrack) ? keyTrack : 0.0;
|
||||||
|
f.env.attackSeconds = bitsToDouble(r.u64());
|
||||||
|
f.env.holdSeconds = bitsToDouble(r.u64());
|
||||||
|
f.env.decaySeconds = bitsToDouble(r.u64());
|
||||||
|
f.env.sustainLevel = bitsToDouble(r.u64());
|
||||||
|
f.env.releaseSeconds = bitsToDouble(r.u64());
|
||||||
|
readCurveTail(r, f.velocityCurve);
|
||||||
}
|
}
|
||||||
const bool legacyV3Play = (pv == 3); // legacy S15/S16 play tail, wall-clock in 44.1k frames
|
|
||||||
|
// Read a RETIRED zone-list payload (v1..v7) and adopt zone ONE. Every zone is still parsed
|
||||||
|
// so the truncation ladder behaves exactly as it did — a record that fails mid-way stops the
|
||||||
|
// walk — but only the first zone's capture and parameters survive; the rest drop, touching
|
||||||
|
// no file and no bank entry.
|
||||||
|
// `pv` is the already-consumed payload version (0 = v1, no marker). `projectRate` converts
|
||||||
|
// the LEGACY v3 wall-clock frame counts to seconds (seconds = frames / projectRate); v5+
|
||||||
|
// blobs carry seconds directly and need no rate.
|
||||||
|
PayloadRead readLegacyZonePayload(ByteReader& r, std::uint32_t pv, double projectRate) {
|
||||||
|
PayloadRead out;
|
||||||
|
const bool extended = (pv >= 2); // v2+: the loop/start tail is present
|
||||||
|
const bool legacyV3Play = (pv == 3); // legacy play tail, wall-clock in nominal frames
|
||||||
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
|
const bool secondsPlay = (pv >= 5); // v5+: full play params, wall-clock in seconds
|
||||||
const bool keyTrackTail = (pv >= 6); // v6+ (S-VIEW-6): per-zone keyTrack scalar
|
const bool keyTrackTail = (pv >= 6); // v6+: keyTrack scalar
|
||||||
const bool curveTail = (pv >= 7); // v7+ (S-VIEW-9): per-zone velocity->amp curve, appended last
|
const bool curveTail = (pv >= 7); // v7+: velocity->amp curve, appended last
|
||||||
const std::uint32_t count = r.u32();
|
const std::uint32_t count = r.u32();
|
||||||
|
bool adopted = false;
|
||||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||||
// z.play defaults to the PRODUCT defaults (Gate + Preserve + tier-0 AHDSR seconds). A
|
// A v1/v2 payload (no play tail) lifts to the product defaults (Gate + Preserve +
|
||||||
// v1/v2 payload (no play tail) therefore lifts every zone to those defaults (S16-F1).
|
// tier-0 AHDSR seconds) — InstrumentParams' own construction defaults.
|
||||||
PerformanceZone z;
|
InstrumentParams p;
|
||||||
|
std::string sampleId;
|
||||||
const std::uint32_t idLen = r.u32();
|
const std::uint32_t idLen = r.u32();
|
||||||
z.sampleId = r.str(idLen);
|
sampleId = r.str(idLen);
|
||||||
z.lowNote = r.i32();
|
r.i32(); // lowNote — the retired key range; read to keep the record walk aligned
|
||||||
z.highNote = r.i32();
|
r.i32(); // highNote
|
||||||
const std::uint8_t hasOverride = r.u8();
|
const std::uint8_t hasOverride = r.u8();
|
||||||
if (hasOverride) z.rootOverride = r.i32();
|
if (hasOverride) p.rootOverride = r.i32();
|
||||||
if (extended) {
|
if (extended) {
|
||||||
const std::uint8_t hasLoop = r.u8();
|
const std::uint8_t hasLoop = r.u8();
|
||||||
if (hasLoop) {
|
if (hasLoop) {
|
||||||
@@ -129,142 +212,114 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
|
|||||||
lp.hasLoop = (r.u8() != 0);
|
lp.hasLoop = (r.u8() != 0);
|
||||||
lp.start = r.i64();
|
lp.start = r.i64();
|
||||||
lp.end = r.i64();
|
lp.end = r.i64();
|
||||||
z.loopOverride = lp;
|
p.loopOverride = lp;
|
||||||
}
|
}
|
||||||
const std::uint8_t hasStart = r.u8();
|
const std::uint8_t hasStart = r.u8();
|
||||||
if (hasStart) z.startPoint = r.i64();
|
if (hasStart) p.startPoint = r.i64();
|
||||||
}
|
}
|
||||||
if (legacyV3Play) {
|
if (legacyV3Play) {
|
||||||
// LEGACY v3 play tail (Daniel's beta projects). Wall-clock fields (hold, pitchEnv A/D)
|
// LEGACY v3 play tail. Wall-clock fields (hold, pitchEnv A/D) were written as
|
||||||
// were written as frames -> divide by the project sample rate (threaded in as `projectRate`)
|
// frames -> divide by `projectRate` to reach seconds. Trigger %-length + fades
|
||||||
// to reach the seconds domain. Trigger %-length + fades are source-timeline, read as-is.
|
// are source-timeline, read as-is. A/D/S/R are ABSENT in v3 -> keep the defaults.
|
||||||
// A/D/S/R are ABSENT in v3 -> leave the seconds defaults on z.play.adsr.
|
assert(projectRate > 0.0 && "readLegacyZonePayload: projectRate must be > 0 for v3 lift");
|
||||||
assert(projectRate > 0.0 && "readZonesPayload: projectRate must be > 0 for v3 lift");
|
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // avoids div-by-zero; assert fires first
|
||||||
const double liftRate = projectRate > 0.0 ? projectRate : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
p.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
||||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
p.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||||
z.play.adsr.holdSeconds = static_cast<double>(r.i64()) / liftRate;
|
p.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||||
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
p.play.trigger.fadeInFrames = r.i64();
|
||||||
z.play.trigger.fadeInFrames = r.i64();
|
p.play.trigger.fadeOutFrames = r.i64();
|
||||||
z.play.trigger.fadeOutFrames = r.i64();
|
p.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||||
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
p.play.pitchEnv.enabled = (r.u8() != 0);
|
||||||
z.play.pitchEnv.enabled = (r.u8() != 0);
|
p.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||||
z.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
p.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||||
z.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
p.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
|
||||||
} else if (secondsPlay) {
|
} else if (secondsPlay) {
|
||||||
// Current v5 play tail: wall-clock times in SECONDS (doubles); trigger fades in source
|
readSecondsPlayTail(r, p);
|
||||||
// frames; read in the emit order.
|
|
||||||
z.play.playMode = (r.u8() != 0) ? PlayMode::Trigger : PlayMode::Gate;
|
|
||||||
z.play.adsr.holdSeconds = bitsToDouble(r.u64());
|
|
||||||
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
|
||||||
z.play.trigger.fadeInFrames = r.i64();
|
|
||||||
z.play.trigger.fadeOutFrames = r.i64();
|
|
||||||
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
|
||||||
z.play.pitchEnv.enabled = (r.u8() != 0);
|
|
||||||
z.play.pitchEnv.attackSeconds = bitsToDouble(r.u64());
|
|
||||||
z.play.pitchEnv.decaySeconds = bitsToDouble(r.u64());
|
|
||||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
|
||||||
z.play.adsr.attackSeconds = bitsToDouble(r.u64());
|
|
||||||
z.play.adsr.decaySeconds = bitsToDouble(r.u64());
|
|
||||||
z.play.adsr.sustainLevel = bitsToDouble(r.u64());
|
|
||||||
z.play.adsr.releaseSeconds = bitsToDouble(r.u64());
|
|
||||||
}
|
}
|
||||||
// PAYLOAD v6 (S-VIEW-6): the key-tracking scalar, appended after the v5 play tail. A pre-v6
|
// A pre-v6 payload leaves keyTrack = 1.0 (100% ET), so an already-saved instance
|
||||||
// payload (no field) leaves the PerformanceZone default (keyTrack = 1.0 = 100% ET), so an
|
// repitches BIT-IDENTICALLY. A pre-v7 payload leaves VelocityCurve::flat().
|
||||||
// already-saved instance repitches BIT-IDENTICALLY to the pre-S-VIEW-6 engine.
|
if (keyTrackTail) p.keyTrack = bitsToDouble(r.u64());
|
||||||
if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64());
|
if (curveTail) readCurveTail(r, p.velocityCurve);
|
||||||
// PAYLOAD v7 (S-VIEW-9): the velocity->amp transfer curve, appended after the v6 keyTrack. A
|
// Payload version 4 (a branch-only frames tail, never shipped) and any unknown pv
|
||||||
// pre-v7 payload (no field) leaves the PerformanceZone default (VelocityCurve::flat() — R10-F1
|
// leave the seconds product defaults on p.play.
|
||||||
// Option A, flat y=1), the deliberate NON-back-compat behavior change for already-saved zones.
|
if (!r.ok) break; // truncated mid-record -> keep what parsed cleanly, drop the rest
|
||||||
// fromPoints repairs the X-order/endpoint invariant defensively; a truncated read (r.ok flips
|
if (!adopted) {
|
||||||
// false mid-curve) leaves the flat default and the mid-zone break below drops the rest.
|
out.params = std::move(p);
|
||||||
if (curveTail) {
|
out.adoptedSampleId = std::move(sampleId);
|
||||||
const std::uint32_t ptCount = r.u32();
|
adopted = true;
|
||||||
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.
|
|
||||||
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) {
|
|
||||||
const double vel = bitsToDouble(r.u64());
|
|
||||||
const double amp = bitsToDouble(r.u64());
|
|
||||||
pts.push_back(VelocityPoint{vel, amp});
|
|
||||||
}
|
}
|
||||||
if (r.ok) z.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
|
|
||||||
}
|
}
|
||||||
// Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
|
return out;
|
||||||
// seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
|
|
||||||
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
|
||||||
map.zones.push_back(std::move(z));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
|
||||||
|
// appended tails), or a retired v1..v7 zone list (adopting zone one). An absent marker means
|
||||||
|
// v1 (a plain small zone count).
|
||||||
|
PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
|
||||||
|
std::uint32_t pv = 0; // 0 = v1, no marker
|
||||||
|
if (r.peekU32() == kParamsFormatMarker) {
|
||||||
|
r.u32(); // consume the marker
|
||||||
|
pv = r.u32(); // payload version
|
||||||
|
}
|
||||||
|
if (pv < kParamsSingleRecordVersion) return readLegacyZonePayload(r, pv, projectRate);
|
||||||
|
|
||||||
|
PayloadRead out;
|
||||||
|
InstrumentParams& p = out.params;
|
||||||
|
const std::uint8_t hasRoot = r.u8();
|
||||||
|
if (hasRoot) p.rootOverride = r.i32();
|
||||||
|
const std::uint8_t hasLoop = r.u8();
|
||||||
|
if (hasLoop) {
|
||||||
|
SampleLoop lp;
|
||||||
|
lp.hasLoop = (r.u8() != 0);
|
||||||
|
lp.start = r.i64();
|
||||||
|
lp.end = r.i64();
|
||||||
|
p.loopOverride = lp;
|
||||||
|
}
|
||||||
|
const std::uint8_t hasStart = r.u8();
|
||||||
|
if (hasStart) p.startPoint = r.i64();
|
||||||
|
readSecondsPlayTail(r, p);
|
||||||
|
p.keyTrack = bitsToDouble(r.u64());
|
||||||
|
readCurveTail(r, p.velocityCurve);
|
||||||
|
if (pv >= kParamsFilterVersion) readFilterTail(r, p);
|
||||||
|
// A truncated record leaves whatever parsed plus construction defaults for the rest —
|
||||||
|
// the same degrade-don't-throw contract the zone ladder always had.
|
||||||
|
if (!r.ok) return PayloadRead{};
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a payload read to the state: the adoption rule (a retired payload's first zone
|
||||||
|
// supersedes the envelope's selection id) lives here, once.
|
||||||
|
void applyPayload(ComponentState& out, PayloadRead read) {
|
||||||
|
out.params = std::move(read.params);
|
||||||
|
if (!read.adoptedSampleId.empty()) out.selectionId = std::move(read.adoptedSampleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
|
// --- Combined component state --------------------------------------
|
||||||
std::vector<std::uint8_t> out;
|
|
||||||
putLE(out, kPerformanceStateVersion);
|
|
||||||
putZonesPayload(out, map);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
PerformanceMap map;
|
|
||||||
ByteReader r(bytes);
|
|
||||||
const std::uint32_t version = r.u32();
|
|
||||||
if (!r.ok) return map; // no version tag -> empty
|
|
||||||
|
|
||||||
// BACK-COMPAT: a v1 blob is the S4 single-selection format (version 1 + id bytes,
|
|
||||||
// no length prefix). Lift it to one full-keyboard zone playing that id.
|
|
||||||
if (version == kSelectionStateVersion) {
|
|
||||||
const std::string id = deserializeSelection(bytes);
|
|
||||||
if (!id.empty()) {
|
|
||||||
PerformanceZone z;
|
|
||||||
z.sampleId = id;
|
|
||||||
z.lowNote = 0;
|
|
||||||
z.highNote = 127;
|
|
||||||
map.zones.push_back(std::move(z));
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
if (version != kPerformanceStateVersion) return map; // unknown -> empty
|
|
||||||
|
|
||||||
readZonesPayload(r, map, projectRate);
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Combined component state (v3, S10) --------------------------------------
|
|
||||||
|
|
||||||
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||||
std::vector<std::uint8_t> out;
|
std::vector<std::uint8_t> out;
|
||||||
putLE(out, kComponentStateVersion);
|
putLE(out, kComponentStateVersion);
|
||||||
// v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body.
|
// v4 addition: channel mode (0 mono/1 stereo) precedes the v3 body.
|
||||||
out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0);
|
out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0);
|
||||||
// v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE
|
// v5 addition: last-consumed assignment generation, 8-byte LE two's-complement, follows
|
||||||
// two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that
|
// the mode byte so a v4 reader stopping there is a strict prefix (see the v4 lift below).
|
||||||
// stops at the mode byte is a strict prefix (see the v4 lift below).
|
|
||||||
putLE(out, asU64(state.lastConsumedAssignGeneration));
|
putLE(out, asU64(state.lastConsumedAssignGeneration));
|
||||||
// v6 envelope addition (S-VIEW-4): the preview-trigger velocity, 1 byte (MIDI 1..127). Follows
|
// v6 addition: preview-trigger velocity, 1 byte (MIDI 1..127) — a v5 blob is a strict
|
||||||
// the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift).
|
// prefix up to this byte (see the v5 lift).
|
||||||
out.push_back(state.previewVelocity);
|
out.push_back(state.previewVelocity);
|
||||||
// v7 envelope addition (Phase S voice system): voice count (1..32), voice mode (0 = Poly,
|
// v7 addition: voice count (1..32), voice mode (0 Poly/1 Mono), mono trigger
|
||||||
// 1 = Mono), mono trigger (0 = Retrigger, 1 = Legato) — one byte each, following the
|
// (0 Retrigger/1 Legato) — one byte each, a v6 blob is a strict prefix up to here.
|
||||||
// velocity byte so a v6 blob is a strict prefix up to here (see the v6 lift).
|
|
||||||
const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount
|
const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount
|
||||||
: state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount
|
: state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount
|
||||||
: state.voiceCount;
|
: state.voiceCount;
|
||||||
out.push_back(static_cast<std::uint8_t>(vc));
|
out.push_back(static_cast<std::uint8_t>(vc));
|
||||||
out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0);
|
out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0);
|
||||||
out.push_back(state.monoTrigger == MonoTrigger::Legato ? 1 : 0);
|
out.push_back(state.monoTrigger == MonoTrigger::Legato ? 1 : 0);
|
||||||
// v8 envelope addition (FB1 master gain): the post-mixer LINEAR gain as an IEEE-754 double
|
// v8 addition: post-mixer LINEAR gain as a double (bit-cast to u64 LE) — a v7 blob is a
|
||||||
// (bit-cast to u64 LE), following the voice bytes so a v7 blob is a strict prefix up to
|
// strict prefix up to here. The WRITER never emits out-of-range: non-finite/negative
|
||||||
// here (see the v7 lift). The WRITER never emits an out-of-range value: non-finite or
|
// falls back to unity; above the +24 dB cap clamps to the cap.
|
||||||
// negative falls back to unity; above the +24 dB cap clamps to the cap.
|
|
||||||
{
|
{
|
||||||
double g = state.masterGainLinear;
|
double g = state.masterGainLinear;
|
||||||
const double maxLin = masterGainMaxLinear();
|
const double maxLin = masterGainMaxLinear();
|
||||||
@@ -272,16 +327,14 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
|||||||
if (g > maxLin) g = maxLin;
|
if (g > maxLin) g = maxLin;
|
||||||
putLE(out, doubleToBits(g));
|
putLE(out, doubleToBits(g));
|
||||||
}
|
}
|
||||||
// v9 envelope addition (GA channel-mode auto-default): the channel-mode-EXPLICIT flag,
|
// v9 addition: channel-mode-EXPLICIT flag, 1 byte — a v8 blob is a strict prefix up to
|
||||||
// 1 byte, following the gain double so a v8 blob is a strict prefix up to here (see the
|
// here. 0 = implicit (shell may auto-default from the loaded capture's channel count);
|
||||||
// v8 lift). 0 = implicit (the shell may auto-default the mode from the loaded capture's
|
// 1 = user deliberately toggled the mode (never fought).
|
||||||
// channel count); 1 = the user deliberately toggled the mode (never fought).
|
|
||||||
out.push_back(state.channelModeExplicit ? 1 : 0);
|
out.push_back(state.channelModeExplicit ? 1 : 0);
|
||||||
// v10 envelope addition (pS self-contained playback): the instance-owned sample-refs
|
// v10 addition: the instance-owned sample-refs table — a v9 blob is a strict prefix up
|
||||||
// table, following the explicit flag so a v9 blob is a strict prefix up to here (see
|
// to here. Wire shape per kSelectionRefsV10Version: entry count, then per entry id
|
||||||
// the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per
|
// + path (length-prefixed), rootNote, loop (hasLoop + start/end, always written),
|
||||||
// entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always
|
// channelCount, displayName (length-prefixed; display-only).
|
||||||
// written), channelCount, displayName (length-prefixed; display-only).
|
|
||||||
putLE(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
|
putLE(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
|
||||||
for (const SampleRefEntry& e : state.sampleRefs) {
|
for (const SampleRefEntry& e : state.sampleRefs) {
|
||||||
putLE(out, static_cast<std::uint32_t>(e.sampleId.size()));
|
putLE(out, static_cast<std::uint32_t>(e.sampleId.size()));
|
||||||
@@ -297,75 +350,65 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
|||||||
putLE(out, static_cast<std::uint32_t>(e.displayName.size()));
|
putLE(out, static_cast<std::uint32_t>(e.displayName.size()));
|
||||||
out.insert(out.end(), e.displayName.begin(), e.displayName.end());
|
out.insert(out.end(), e.displayName.begin(), e.displayName.end());
|
||||||
}
|
}
|
||||||
// v11 envelope addition (pS-usage instance identity): the minted per-instance guid,
|
// v11 envelope addition (usage instance identity): the minted per-instance guid,
|
||||||
// length-prefixed, following the refs table so a v10 blob is a strict prefix up to
|
// length-prefixed, following the refs table so a v10 blob is a strict prefix up to
|
||||||
// here (see the v10 lift). Empty = never published — legal, round-trips as empty.
|
// here (see the v10 lift). Empty = never published — legal, round-trips as empty.
|
||||||
putLE(out, static_cast<std::uint32_t>(state.instanceGuid.size()));
|
putLE(out, static_cast<std::uint32_t>(state.instanceGuid.size()));
|
||||||
out.insert(out.end(), state.instanceGuid.begin(), state.instanceGuid.end());
|
out.insert(out.end(), state.instanceGuid.begin(), state.instanceGuid.end());
|
||||||
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
|
// Length-prefixed selection id (it precedes the params payload, so it MUST be framed —
|
||||||
// unlike the v1 selection blob where the id ran to end-of-stream).
|
// unlike the v1 selection blob where the id ran to end-of-stream).
|
||||||
putLE(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
putLE(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
||||||
out.insert(out.end(), state.selectionId.begin(), state.selectionId.end());
|
out.insert(out.end(), state.selectionId.begin(), state.selectionId.end());
|
||||||
putZonesPayload(out, state.map);
|
putParamsPayload(out, state.params);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
||||||
double projectRate) {
|
double projectRate) {
|
||||||
// projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present.
|
// projectRate is only consumed for a LEGACY v3 payload; unused for v5+.
|
||||||
// For v5 and later blobs it is unused. See readZonesPayload for the guard.
|
|
||||||
ComponentState out;
|
ComponentState out;
|
||||||
ByteReader r(bytes);
|
ByteReader r(bytes);
|
||||||
const std::uint32_t version = r.u32();
|
const std::uint32_t version = r.u32();
|
||||||
if (!r.ok) return out; // no version tag -> empty (the S10 silent empty state)
|
if (!r.ok) return out; // no version tag -> empty (the silent empty state)
|
||||||
|
|
||||||
// BACK-COMPAT: an older blob predates the v3 {selection, zones} split.
|
// BACK-COMPAT: an older blob predates the v3 {selection, params} split.
|
||||||
// * v1 (S4 single-selection: version 1 + id-to-end): restore {id, one full-keyboard
|
// * v1 (original single-selection: version 1 + id-to-end): restore the id as the
|
||||||
// zone} so the old pick survives as BOTH the selection and a one-zone map.
|
// loaded capture with default parameters.
|
||||||
// * v2 (S5 zones-only): restore {"", zones} — that instance had zones but no separate
|
// * v2 (zones-only): the adopted first zone supplies BOTH the capture and the params.
|
||||||
// single-capture selection.
|
|
||||||
if (version == kSelectionStateVersion) {
|
if (version == kSelectionStateVersion) {
|
||||||
out.selectionId = deserializeSelection(bytes);
|
out.selectionId = deserializeSelection(bytes);
|
||||||
if (!out.selectionId.empty()) {
|
|
||||||
PerformanceZone z;
|
|
||||||
z.sampleId = out.selectionId;
|
|
||||||
z.lowNote = 0;
|
|
||||||
z.highNote = 127;
|
|
||||||
out.map.zones.push_back(std::move(z));
|
|
||||||
}
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
if (version == kPerformanceStateVersion) {
|
if (version == kPerformanceStateVersion) {
|
||||||
readZonesPayload(r, out.map, projectRate); // v2 body starts right after the version tag
|
applyPayload(out, readParamsPayload(r, projectRate)); // body starts after the tag
|
||||||
return out; // channelMode stays Mono (pre-S7)
|
return out; // channelMode stays Mono
|
||||||
}
|
}
|
||||||
// BACK-COMPAT: a v3 blob (pre-S7 {selection, zones}, no channel mode) restores as MONO —
|
// BACK-COMPAT: a v3 blob ({selection, params}, no channel mode) restores as MONO — the id
|
||||||
// the id length + id + zones body starts right after the version tag (no mode byte).
|
// length + id + payload starts right after the version tag (no mode byte).
|
||||||
if (version == kSelectionZonesV3Version) {
|
if (version == kSelectionV3Version) {
|
||||||
const std::uint32_t idLen = r.u32();
|
const std::uint32_t idLen = r.u32();
|
||||||
out.selectionId = r.str(idLen);
|
out.selectionId = r.str(idLen);
|
||||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||||
readZonesPayload(r, out.map, projectRate);
|
applyPayload(out, readParamsPayload(r, projectRate));
|
||||||
return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9)
|
return out; // channelMode stays Mono, marker stays 0
|
||||||
}
|
}
|
||||||
// BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker):
|
// BACK-COMPAT: a v4 blob ({mode, selection, params}, no consumed marker): mode byte, then
|
||||||
// mode byte, then the id + zones body — no 8-byte marker. lastConsumedAssignGeneration
|
// the id + payload — no 8-byte marker. lastConsumedAssignGeneration defaults to 0, so
|
||||||
// defaults to 0, so a first assign still applies for a pre-marker instance.
|
// a first assign still applies for a pre-marker instance.
|
||||||
if (version == kSelectionZonesModeV4Version) {
|
if (version == kSelectionModeV4Version) {
|
||||||
const std::uint8_t modeByte = r.u8();
|
const std::uint8_t modeByte = r.u8();
|
||||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||||
const std::uint32_t idLen = r.u32();
|
const std::uint32_t idLen = r.u32();
|
||||||
out.selectionId = r.str(idLen);
|
out.selectionId = r.str(idLen);
|
||||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||||
readZonesPayload(r, out.map, projectRate);
|
applyPayload(out, readParamsPayload(r, projectRate));
|
||||||
return out; // marker stays 0 (pre-S8/S9 reader)
|
return out; // marker stays 0
|
||||||
}
|
}
|
||||||
// BACK-COMPAT: a v5 blob (pre-S-VIEW-4 {mode, marker, selection, zones}, no preview-velocity
|
// BACK-COMPAT: a v5 blob ({mode, marker, selection, params}, no preview-velocity byte).
|
||||||
// byte): mode byte, then the 8-byte marker, then the id + zones body — no velocity byte.
|
// previewVelocity defaults to kPreviewVelocityDefault (construction default), so an
|
||||||
// previewVelocity defaults to kPreviewVelocityDefault (set at construction), so an already-saved
|
// already-saved instance restores at the mid default.
|
||||||
// pre-S-VIEW-4 instance restores at the mid default.
|
if (version == kSelectionModeMarkerV5Version) {
|
||||||
if (version == kSelectionZonesModeMarkerV5Version) {
|
|
||||||
const std::uint8_t modeByte = r.u8();
|
const std::uint8_t modeByte = r.u8();
|
||||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||||
@@ -374,22 +417,21 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
|||||||
const std::uint32_t idLen = r.u32();
|
const std::uint32_t idLen = r.u32();
|
||||||
out.selectionId = r.str(idLen);
|
out.selectionId = r.str(idLen);
|
||||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||||
readZonesPayload(r, out.map, projectRate);
|
applyPayload(out, readParamsPayload(r, projectRate));
|
||||||
return out; // previewVelocity stays at the mid default (pre-S-VIEW-4)
|
return out; // previewVelocity stays at the mid default
|
||||||
}
|
}
|
||||||
if (version != kComponentStateVersion &&
|
if (version != kComponentStateVersion &&
|
||||||
version != kSelectionZonesRefsV10Version &&
|
version != kSelectionRefsV10Version &&
|
||||||
version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version &&
|
version != kSelectionModeMarkerVelVoiceGainExplicitV9Version &&
|
||||||
version != kSelectionZonesModeMarkerVelVoiceGainV8Version &&
|
version != kSelectionModeMarkerVelVoiceGainV8Version &&
|
||||||
version != kSelectionZonesModeMarkerVelVoiceV7Version &&
|
version != kSelectionModeMarkerVelVoiceV7Version &&
|
||||||
version != kSelectionZonesModeMarkerVelV6Version) {
|
version != kSelectionModeMarkerVelV6Version) {
|
||||||
return out; // unknown -> empty
|
return out; // unknown -> empty
|
||||||
}
|
}
|
||||||
|
|
||||||
// v6..v10 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker,
|
// v6..v11 shared prefix: channel-mode byte, 8-byte consumed-assignment marker, 1-byte
|
||||||
// then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated
|
// preview velocity. A non-{0,1} mode byte treats as mono (conservative default) rather
|
||||||
// as mono (conservative default) rather than rejected — a corrupt mode never silences the
|
// than rejected — a corrupt mode never silences the instance.
|
||||||
// instance.
|
|
||||||
const std::uint8_t modeByte = r.u8();
|
const std::uint8_t modeByte = r.u8();
|
||||||
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
if (!r.ok) return out; // truncated before the mode byte -> empty (mono default holds)
|
||||||
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
out.channelMode = (modeByte == 1) ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||||
@@ -402,9 +444,9 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
|||||||
out.previewVelocity = (previewVel >= 1 && previewVel <= 127)
|
out.previewVelocity = (previewVel >= 1 && previewVel <= 127)
|
||||||
? previewVel
|
? previewVel
|
||||||
: kPreviewVelocityDefault;
|
: kPreviewVelocityDefault;
|
||||||
// v7+ (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the
|
// v7+: the three voice-system bytes. A v6 blob skips them — the construction defaults
|
||||||
// construction defaults {16, Poly, Retrigger} hold, reproducing pre-Phase-S behavior.
|
// {16, Poly, Retrigger} hold, reproducing pre-voice-system behavior.
|
||||||
if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) {
|
if (version >= kSelectionModeMarkerVelVoiceV7Version) {
|
||||||
const std::uint8_t vc = r.u8();
|
const std::uint8_t vc = r.u8();
|
||||||
const std::uint8_t vm = r.u8();
|
const std::uint8_t vm = r.u8();
|
||||||
const std::uint8_t mt = r.u8();
|
const std::uint8_t mt = r.u8();
|
||||||
@@ -417,10 +459,10 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
|||||||
out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly;
|
out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly;
|
||||||
out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
|
out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
|
||||||
}
|
}
|
||||||
// v8+ (FB1): the master-gain LINEAR double. A v7 blob (pre-FB1) skips it — the construction
|
// v8+: the master-gain LINEAR double. A v7 blob skips it — the construction default
|
||||||
// default (unity) holds, reproducing pre-FB1 output exactly. A non-finite, negative, or
|
// (unity) holds. A non-finite, negative, or above-cap value falls back to unity rather
|
||||||
// above-cap value (a corrupt blob) falls back to unity rather than silencing/blasting.
|
// than silencing/blasting.
|
||||||
if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) {
|
if (version >= kSelectionModeMarkerVelVoiceGainV8Version) {
|
||||||
const double g = bitsToDouble(r.u64());
|
const double g = bitsToDouble(r.u64());
|
||||||
if (!r.ok) return out; // truncated inside the gain double — out already carries
|
if (!r.ok) return out; // truncated inside the gain double — out already carries
|
||||||
// mode/marker/velocity/voice fields from above; unity holds
|
// mode/marker/velocity/voice fields from above; unity holds
|
||||||
@@ -429,19 +471,19 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
|||||||
? g
|
? g
|
||||||
: 1.0;
|
: 1.0;
|
||||||
}
|
}
|
||||||
// v9 (GA): the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction
|
// v9: the channel-mode-EXPLICIT flag. A v8-or-older blob skips it — the construction
|
||||||
// default (false = implicit) holds, so an already-saved instance's mode is treated as the
|
// default (false = implicit) holds, so an already-saved instance's mode is treated as
|
||||||
// un-touched default and the shell may auto-default it from the loaded capture.
|
// the untouched default and the shell may auto-default it from the loaded capture.
|
||||||
if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) {
|
if (version >= kSelectionModeMarkerVelVoiceGainExplicitV9Version) {
|
||||||
const std::uint8_t explicitByte = r.u8();
|
const std::uint8_t explicitByte = r.u8();
|
||||||
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
|
if (!r.ok) return out; // truncated before the flag -> empty (implicit holds)
|
||||||
out.channelModeExplicit = (explicitByte == 1);
|
out.channelModeExplicit = (explicitByte == 1);
|
||||||
}
|
}
|
||||||
// v10 (pS self-contained playback): the sample-refs table. A v9-or-older blob skips it —
|
// v10: the sample-refs table. A v9-or-older blob skips it — the EMPTY-table default
|
||||||
// the EMPTY-table default holds, and the shell lifts the refs once via the bridge-resolve
|
// holds, and the shell lifts the refs once via the bridge-resolve path (then re-saves
|
||||||
// path (then re-saves self-contained). A truncated mid-entry read keeps the entries that
|
// self-contained). A truncated mid-entry read keeps the entries that parsed cleanly and
|
||||||
// parsed cleanly and drops the rest (the selection/zones behind it are unreadable anyway).
|
// drops the rest (the selection/params behind it are unreadable anyway).
|
||||||
if (version >= kSelectionZonesRefsV10Version) {
|
if (version >= kSelectionRefsV10Version) {
|
||||||
const std::uint32_t refCount = r.u32();
|
const std::uint32_t refCount = r.u32();
|
||||||
for (std::uint32_t i = 0; i < refCount && r.ok; ++i) {
|
for (std::uint32_t i = 0; i < refCount && r.ok; ++i) {
|
||||||
SampleRefEntry e;
|
SampleRefEntry e;
|
||||||
@@ -449,11 +491,10 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
|||||||
e.sampleId = r.str(refIdLen);
|
e.sampleId = r.str(refIdLen);
|
||||||
const std::uint32_t pathLen = r.u32();
|
const std::uint32_t pathLen = r.u32();
|
||||||
e.ref.relativePath = r.str(pathLen);
|
e.ref.relativePath = r.str(pathLen);
|
||||||
// Range fallbacks (the refs table is the ONLY copy on the play path, so a
|
// Range fallbacks: the refs table is the ONLY copy on the play path, so a
|
||||||
// corrupt field must degrade to the field's default, never poison playback —
|
// corrupt field must degrade to the field's default, never poison playback. An
|
||||||
// the previewVelocity/voiceCount posture): an out-of-MIDI-range root falls back
|
// out-of-MIDI-range root falls back to the middle-C default distill() uses; a
|
||||||
// to the middle-C default distill() uses; a negative channel count falls back
|
// negative channel count falls back to 0 = unknown (auto-default then skips it).
|
||||||
// to 0 = unknown (the GA auto-default then skips it).
|
|
||||||
const std::int32_t root = r.i32();
|
const std::int32_t root = r.i32();
|
||||||
e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60;
|
e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60;
|
||||||
e.ref.loop.hasLoop = (r.u8() != 0);
|
e.ref.loop.hasLoop = (r.u8() != 0);
|
||||||
@@ -468,9 +509,9 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
|||||||
}
|
}
|
||||||
if (!r.ok) return out;
|
if (!r.ok) return out;
|
||||||
}
|
}
|
||||||
// v11 (pS-usage): the minted instance guid. A v10-or-older blob skips it — the
|
// v11: the minted instance guid. A v10-or-older blob skips it — the EMPTY default
|
||||||
// EMPTY default holds and the processor mints a fresh identity on first publish.
|
// holds and the processor mints a fresh identity on first publish.
|
||||||
if (version >= kSelectionZonesRefsIdentityV11Version) {
|
if (version >= kSelectionRefsIdentityV11Version) {
|
||||||
const std::uint32_t guidLen = r.u32();
|
const std::uint32_t guidLen = r.u32();
|
||||||
out.instanceGuid = r.str(guidLen);
|
out.instanceGuid = r.str(guidLen);
|
||||||
if (!r.ok) { out.instanceGuid.clear(); return out; } // truncated -> empty
|
if (!r.ok) { out.instanceGuid.clear(); return out; } // truncated -> empty
|
||||||
@@ -478,7 +519,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
|||||||
const std::uint32_t idLen = r.u32();
|
const std::uint32_t idLen = r.u32();
|
||||||
out.selectionId = r.str(idLen);
|
out.selectionId = r.str(idLen);
|
||||||
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
if (!r.ok) { out.selectionId.clear(); return out; } // truncated id -> empty
|
||||||
readZonesPayload(r, out.map, projectRate);
|
applyPayload(out, readParamsPayload(r, projectRate));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,291 +1,242 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// component_state_io — the ComponentState ENVELOPE + zones-payload binary codec for the
|
// component_state_io — the ComponentState ENVELOPE + params-payload binary codec for the
|
||||||
// ReaSampler 9000 instrument (Q-W2v split out of sample_map, T4-13 ≡ T2-07). PURE: NO
|
// ReaSampler 9000 instrument. Split out of sample_map so both artifacts can share it: the
|
||||||
// VST3, NO REAPER, NO SWELL, NO vendor/ includes — the same boundary sample_map keeps.
|
// instrument's processor reads/writes it at setState/getState, and the extension's
|
||||||
|
// instrument-drop path serializes the identical bytes into a transient .vstpreset, so the
|
||||||
|
// payload and the instrument's reader can never drift — without the extension having to
|
||||||
|
// link the whole voice engine (voice/pitch_shift) just to serialize one preset blob. Its
|
||||||
|
// own links are velocity_curve + master_gain (wire value validation), never the engine.
|
||||||
//
|
//
|
||||||
// WHY A SEPARATE MODULE. The codec grows on EVERY ComponentState envelope bump (v6→v11
|
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
|
||||||
// in one quarter), and it is deliberately shared across BOTH artifacts: the instrument's
|
// payload v1..v8) must be preserved exactly.
|
||||||
// processor reads/writes it at setState/getState, and the EXTENSION's instrument-drop
|
|
||||||
// path (core/wire/instrument_drop) serializes the same bytes into a transient .vstpreset
|
|
||||||
// so the payload and the instrument's reader can never drift. Housing it inside
|
|
||||||
// sample_map made the extension link the whole voice engine (sampler_core + pitch_shift)
|
|
||||||
// to serialize one preset blob; split out, both artifacts link the codec and only the
|
|
||||||
// VST links the engine. The codec's own links are velocity_curve + master_gain (wire
|
|
||||||
// value validation) — never the engine.
|
|
||||||
//
|
|
||||||
// EVERY wire format below is FROZEN (byte-identical to the pre-split writer); the full
|
|
||||||
// version ladders (envelope v1..v11, zones payload v1..v7) are preserved exactly.
|
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "core/instrument/map/sample_map.h" // PerformanceMap / SampleRefs / SelectedSample (+ zone_params via sampler_core)
|
#include "core/instrument/map/sample_map.h" // InstrumentParams / SampleRefs / SelectedSample
|
||||||
|
|
||||||
namespace reasampler::instrument::map {
|
namespace reasampler::instrument::map {
|
||||||
|
|
||||||
// --- Performance-map instance state (VST3 setState/getState) -----------------
|
// --- The instance's parameter payload ----------------------------------------
|
||||||
//
|
//
|
||||||
// The performance map is the instrument's OWN state (D-B), serialized to the VST3
|
// The one parameter set is the instrument's OWN state, serialized to the VST3
|
||||||
// component-state IBStream — NOT written to the "reasampler" bank ext-state (the
|
// component-state IBStream — never written to the "reasampler" bank ext-state. Versioned
|
||||||
// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of
|
// binary, tolerant of truncation/wrong-version (bounded reads, never throws across the host).
|
||||||
// truncation/wrong-version by design (bounded reads, never throws across the host).
|
|
||||||
//
|
//
|
||||||
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
|
// PAYLOAD VERSIONING is self-describing and envelope-independent: the payload carries its
|
||||||
// ZONES PAYLOAD.
|
// OWN version, so its record can grow without bumping the envelope version. Payload
|
||||||
|
// extensions and envelope-field additions stay on independent axes that can never collide
|
||||||
|
// on one version number.
|
||||||
//
|
//
|
||||||
// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones
|
// v1..v7 are the RETIRED per-zone list formats. They are still READ — a saved instance lifts
|
||||||
// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides)
|
// by adopting its FIRST zone's capture and that zone's parameters; any remaining zones drop
|
||||||
// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState
|
// (dropping a zone touches no file and no bank entry). A single-zone instance therefore
|
||||||
// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the
|
// lifts losslessly; a genuinely multi-zone one keeps zone one only, the deliberately relaxed
|
||||||
// key composition property: the zone-record extension is versioned inside the map blob, not on
|
// case. Their record shapes, in order:
|
||||||
// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not
|
// * v1 (original, no marker): 4-byte LE zone count, then per zone: 4-byte LE id length +
|
||||||
// collide on a single version number.
|
// id bytes, 4-byte LE lowNote, 4-byte LE highNote, 1 byte hasRootOverride, 4-byte LE
|
||||||
// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone:
|
// rootOverride (iff hasRootOverride). A payload starting with a small u32 is v1.
|
||||||
// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote,
|
// * v2: marker + version (== 2), then the v1 body PLUS, per zone after rootOverride:
|
||||||
// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride).
|
// 1 byte hasLoopOverride; iff set, 1 byte loop.hasLoop + 8-byte LE loop.start + loop.end
|
||||||
// A payload starting with a small u32 (the zone count) is v1 — there is no marker.
|
// (int64); 1 byte hasStartPoint; iff set, 8-byte LE startPoint (int64).
|
||||||
// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone
|
// * v3 (LEGACY — exists in Daniel's beta projects): marker + version (== 3), v2 body PLUS
|
||||||
// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended
|
// a play-params tail: 1 byte playMode (0 Gate/1 Trigger); 8-byte LE adsr.holdFrames
|
||||||
// to each zone record after rootOverride:
|
// (int64, FRAMES at a nominal rate); 8-byte LE trigger.lengthFraction (double); 8-byte
|
||||||
// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start,
|
// LE trigger.fadeInFrames + fadeOutFrames (int64); 1 byte pitchEngine (0 Varispeed/1
|
||||||
// 8-byte LE loop.end (both two's-complement int64);
|
// Preserve); 1 byte pitchEnv.enabled; 8-byte LE pitchEnv.attackFrames + decayFrames
|
||||||
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64).
|
// (int64, nominal FRAMES); 8-byte LE peakSemitones (double). LEGACY-READ CONVERSION: the
|
||||||
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads
|
// v3 wall-clock frame counts convert to seconds by dividing by the PROJECT sample rate
|
||||||
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope.
|
// threaded into the v3 lift path at read time (a parameter, no baked constant).
|
||||||
// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload
|
// Source-timeline fields (trigger %-length + fades) stay frames. A/D/S/R absent in v3 ->
|
||||||
// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint
|
// tier-0 seconds defaults (0.003/0/1.0/0.060).
|
||||||
// tail (the S15/S16 per-zone play params — always present, NOT flag-gated):
|
// * v5: marker + version (== 5), v2 body PLUS the full play params with WALL-CLOCK TIMES
|
||||||
// 1 byte playMode (0 = Gate, 1 = Trigger);
|
// AS SECONDS (rate-free doubles): 1 byte playMode; 8-byte LE adsr.holdSeconds; 8-byte LE
|
||||||
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal;
|
// trigger.lengthFraction; 8-byte LE trigger.fadeInFrames + fadeOutFrames (int64,
|
||||||
// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE);
|
// unchanged — source-timeline facts); 1 byte pitchEngine; 1 byte pitchEnv.enabled;
|
||||||
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
|
// 8-byte LE pitchEnv.attackSeconds + decaySeconds + peakSemitones; 8-byte LE
|
||||||
// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve);
|
// adsr.attackSeconds + decaySeconds + sustainLevel + releaseSeconds. v4 (a branch-only
|
||||||
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom);
|
// frames tail) was never shipped and is intentionally not read.
|
||||||
// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double.
|
// * v6: v5 PLUS 8-byte LE keyTrack (double) per zone (1.0 = 100% ET).
|
||||||
// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve +
|
// * v7: v6 PLUS the velocity->amp transfer curve per zone: 4-byte LE control-point count
|
||||||
// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved
|
// N, then per point 8-byte LE velocity + 8-byte LE amp (doubles), N >= 2. A pre-v7
|
||||||
// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest.
|
// payload lifts to VelocityCurve::flat() — a DELIBERATE non-back-compat behavior change
|
||||||
// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS
|
// (soft hits play louder than under the old linear velocity/127 map).
|
||||||
// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds
|
|
||||||
// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed
|
|
||||||
// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames.
|
|
||||||
// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060).
|
|
||||||
// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5),
|
|
||||||
// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full
|
|
||||||
// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles):
|
|
||||||
// 1 byte playMode (0 = Gate, 1 = Trigger);
|
|
||||||
// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double);
|
|
||||||
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
|
|
||||||
// 1 byte pitchEngine; 1 byte pitchEnv.enabled;
|
|
||||||
// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double);
|
|
||||||
// 8-byte LE pitchEnv.peakSemitones (double);
|
|
||||||
// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double);
|
|
||||||
// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double).
|
|
||||||
// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4
|
|
||||||
// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader
|
|
||||||
// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds
|
|
||||||
// to frames at the LIVE sample rate; no rate is baked into storage or the program.
|
|
||||||
// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is
|
|
||||||
// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved
|
|
||||||
// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes
|
|
||||||
// to an EMPTY map.
|
|
||||||
//
|
//
|
||||||
// These two functions serialize the ZONES only. Since S10 the instrument's full component
|
// v8 is the first one-parameter-set record: marker + version (== 8), then a SINGLE record
|
||||||
// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState
|
// with no count, no key range and no sample id (the envelope's selection id is the capture):
|
||||||
// below, the v3 format the processor actually reads/writes. serializePerformance/
|
// 1 byte hasRootOverride + 4-byte LE rootOverride (iff set); 1 byte hasLoopOverride + [1 byte
|
||||||
// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift.
|
// loop.hasLoop + 8-byte LE loop.start + loop.end] (iff set); 1 byte hasStartPoint + 8-byte LE
|
||||||
|
// startPoint (iff set); the v5 play tail verbatim (SECONDS); 8-byte LE keyTrack; then the
|
||||||
|
// velocity curve (count + points) as in v7.
|
||||||
|
//
|
||||||
|
// v9 (CURRENT WRITE FORMAT) is v8 PLUS the per-voice filter tail, appended after the velocity
|
||||||
|
// curve: 1 byte enabled; 8-byte LE cutoffNorm, resonanceNorm, morphNorm, driveNorm (doubles,
|
||||||
|
// widened from the module's floats); 1 byte morphLaw (0 HighBandLow / 1 HighNotchLow); 8-byte
|
||||||
|
// LE modAmount, velAmount, keyTrack; 8-byte LE filter-env attack/hold/decay/sustain/release
|
||||||
|
// SECONDS; then the filter's OWN velocity curve (count + points, same shape as v7's). A v8
|
||||||
|
// blob is a strict prefix, so it lifts to the off/neutral filter default and plays
|
||||||
|
// bit-identically.
|
||||||
|
//
|
||||||
|
// A truncated/unknown/empty payload yields the DEFAULT parameter set.
|
||||||
|
|
||||||
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||||
|
|
||||||
// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9).
|
// The params-payload format version and its detection marker. The marker is a high sentinel
|
||||||
// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 —
|
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
|
||||||
// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock
|
// a reader detects record shape independent of the envelope version.
|
||||||
// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides
|
inline constexpr std::uint32_t kParamsPayloadVersion = 9; // v8 + the per-voice filter tail
|
||||||
// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker +
|
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
|
||||||
// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts)
|
|
||||||
// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The
|
|
||||||
// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice,
|
|
||||||
// always tiny) can never collide with.
|
|
||||||
// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the
|
|
||||||
// full v5 play-params tail:
|
|
||||||
// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET).
|
|
||||||
// A v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone
|
|
||||||
// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the
|
|
||||||
// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
|
|
||||||
// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
|
|
||||||
// transfer curve appended to each zone record after the v6 keyTrack field:
|
|
||||||
// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp
|
|
||||||
// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2.
|
|
||||||
// A v1–v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1
|
|
||||||
// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change:
|
|
||||||
// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A
|
|
||||||
// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed.
|
|
||||||
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve
|
|
||||||
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
|
||||||
|
|
||||||
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are
|
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
|
||||||
// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a
|
// reads through the legacy walk; everything at or above it shares the v8 record shape and
|
||||||
// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap build
|
// grows by appending. The reader branches on this, never on kParamsPayloadVersion, so a
|
||||||
// already receives, so the seconds domain is consistent across both paths. No constant is baked in.
|
// future bump does not silently push the previous format back into the zone reader.
|
||||||
|
inline constexpr std::uint32_t kParamsSingleRecordVersion = 8;
|
||||||
|
|
||||||
// The performance map serialized to bytes for IBStream (getState).
|
// v8 + the per-voice filter tail. Named so the filter branch in readParamsPayload is
|
||||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
|
// self-describing, mirroring the envelope's version constants.
|
||||||
|
inline constexpr std::uint32_t kParamsFilterVersion = 9;
|
||||||
|
|
||||||
// The performance map parsed back from IBStream bytes (setState). A v2 blob parses
|
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
|
||||||
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
|
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
|
||||||
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
|
// (frames / projectRate = seconds) — the same rate the build already receives, so the
|
||||||
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
|
// seconds domain is consistent across both paths. No constant is baked in.
|
||||||
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) -----------------------
|
||||||
//
|
//
|
||||||
// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that
|
// Format (envelope v11): 4-byte LE version tag (== 11); 1-byte channel-mode field (0
|
||||||
// BOTH persist: the default face is one picked capture (the selection id), and zones are a
|
// mono/1 stereo); 8-byte LE last-consumed-assignment generation; 1-byte preview-trigger
|
||||||
// demoted opt-in overlay (the performance map). The component state carries both so a saved
|
// velocity (MIDI 1..127); three voice-system bytes (1-byte voice count 1..32, 1-byte voice
|
||||||
// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an
|
// mode 0 Poly/1 Mono, 1-byte mono trigger 0 Retrigger/1 Legato); 8-byte LE master-gain
|
||||||
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
|
// LINEAR value (double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB);
|
||||||
// state), never auto-playing sample #1.
|
// 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
|
||||||
|
// kSelectionRefsV10Version 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 params payload (its own self-describing version). The envelope grows fields on an
|
||||||
|
// axis INDEPENDENT of the payload version — do NOT bump one for the other.
|
||||||
//
|
//
|
||||||
// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono,
|
// An out-of-range voice byte or a non-finite/out-of-range master-gain double (a corrupt
|
||||||
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a
|
// blob) falls back to the field's default rather than silencing the instance.
|
||||||
// 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):
|
|
||||||
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct.
|
|
||||||
// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish): pre-pS-usage.
|
|
||||||
// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift).
|
|
||||||
// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode).
|
|
||||||
// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain).
|
|
||||||
// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults).
|
|
||||||
// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity).
|
|
||||||
// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker).
|
|
||||||
// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode.
|
|
||||||
// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection.
|
|
||||||
// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift.
|
|
||||||
// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state).
|
|
||||||
//
|
//
|
||||||
// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is
|
// BACK-COMPAT on read (every older blob lifts to channelMode = MONO,
|
||||||
// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user
|
// lastConsumedAssignGeneration = 0, previewVelocity = kPreviewVelocityDefault, voice
|
||||||
// already got and then manually changed away from: on re-open the instance re-reads the pending
|
// defaults {16 voices, Poly, Retrigger}, unity master gain, channelModeExplicit = FALSE — a
|
||||||
// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see
|
// pre-v9 mode byte is treated as the untouched default so the auto-default may follow the
|
||||||
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign
|
// loaded capture — and an EMPTY sample-refs table, which the shell lifts once via the
|
||||||
// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the
|
// bridge-resolve path — and an EMPTY instance guid, which the shell re-mints on first
|
||||||
// bank — the extension owns the assign_request key; the instrument only tracks what it consumed.
|
// publish):
|
||||||
// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no
|
// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, params} direct.
|
||||||
// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default.
|
// * 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, params}: implicit mode.
|
||||||
|
// * v7 blob -> unity master gain.
|
||||||
|
// * v6 blob -> voice defaults.
|
||||||
|
// * v5 blob -> no velocity byte.
|
||||||
|
// * v4 blob -> no marker.
|
||||||
|
// * v3 blob -> no channel mode.
|
||||||
|
// * v2 blob -> zones-only, no separate selection: the adopted first zone supplies BOTH.
|
||||||
|
// * v1 blob -> {mono, 0, mid, id, default params}: single-selection lift.
|
||||||
|
// * empty/unknown -> {mono, 0, mid, "", default params}: EMPTY (the silent empty state).
|
||||||
|
//
|
||||||
|
// ADOPTION RULE (retired zone payloads only): when a v1..v7 payload carries at least one
|
||||||
|
// zone, its FIRST zone's sampleId REPLACES the envelope's selection id — that zone is what
|
||||||
|
// the old first-match resolve actually played, so adopting it is what keeps a single-capture
|
||||||
|
// instance sounding identical. A payload with no zones leaves the envelope's selection alone.
|
||||||
|
//
|
||||||
|
// WHY THE ASSIGNMENT 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 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;
|
inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
|
||||||
|
|
||||||
struct ComponentState {
|
struct ComponentState {
|
||||||
std::string selectionId; // the single-capture pick; "" = no pick
|
std::string selectionId; // the loaded capture; "" = no pick
|
||||||
PerformanceMap map; // the opt-in zones; empty = no zones
|
InstrumentParams params; // the ONE parameter set governing it
|
||||||
ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E)
|
ChannelMode channelMode = ChannelMode::Mono; // decode mode; default mono
|
||||||
// GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle).
|
// Whether channelMode was DELIBERATELY set by the user (the editor toggle). While
|
||||||
// While false (implicit), the shell auto-defaults the mode from the loaded capture's
|
// false (implicit), the shell auto-defaults the mode from the loaded capture's channel
|
||||||
// channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the
|
// count on reload (stereo capture -> Stereo, mono -> Mono); once true, the user's
|
||||||
// user's choice is never fought. Pre-v9 blobs lift to false (implicit).
|
// choice is never fought. Pre-v9 blobs lift to false (implicit).
|
||||||
bool channelModeExplicit = false;
|
bool channelModeExplicit = false;
|
||||||
std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed
|
std::int64_t lastConsumedAssignGeneration = 0; // last assign_request generation consumed
|
||||||
// S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling
|
// Preview-trigger velocity (MIDI 1..127): a per-instance utility setting, persisted so
|
||||||
// of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's
|
// the Sample-view preview button retains the user's chosen strike velocity across saves.
|
||||||
// chosen strike velocity across saves. Defaults to kPreviewVelocityDefault.
|
|
||||||
std::uint8_t previewVelocity = kPreviewVelocityDefault;
|
std::uint8_t previewVelocity = kPreviewVelocityDefault;
|
||||||
// Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
|
// Voice system: per-instance performance choices. Defaults {16, Poly, Retrigger}
|
||||||
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an
|
// reproduce pre-voice-system behavior exactly, so an older blob lifting to these plays
|
||||||
// older blob lifting to these plays byte-identically.
|
// byte-identically.
|
||||||
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
|
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
|
||||||
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
|
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
|
||||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
|
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
|
||||||
// FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity;
|
// Post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; up to
|
||||||
// up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output
|
// ~15.849 = +24 dB — master_gain owns the dB taper). Applied by process() AFTER the
|
||||||
// trim applied by process() AFTER the voice sum (engine + drain + preview) — never per
|
// voice sum — never per voice. Default unity reproduces pre-master-gain output
|
||||||
// voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically,
|
// byte-identically.
|
||||||
// so an older blob lifting to 1.0 plays exactly as it did.
|
|
||||||
double masterGainLinear = 1.0;
|
double masterGainLinear = 1.0;
|
||||||
// pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics
|
// Self-contained playback: the instance-OWNED sample refs — path + intrinsics for every
|
||||||
// for every bank sample this instance plays (see the SampleRefs block above). setState
|
// bank sample this instance plays (see the SampleRefs block in sample_map.h). setState
|
||||||
// decodes straight from these; NO bridge/extension read is required for playback. A
|
// 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
|
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
|
||||||
// path once (then re-saves self-contained).
|
// path once (then re-saves self-contained).
|
||||||
SampleRefs sampleRefs;
|
SampleRefs sampleRefs;
|
||||||
// pS-usage (v11): the minted per-instance identity the usage publisher keys its
|
// The minted per-instance identity the usage publisher keys its "rsusage_<guid>"
|
||||||
// "rsusage_<guid>" ext-state record under (see sample_usage.h — the prune-protection
|
// ext-state record under (see sample_usage.h — the prune-protection seam). Persisted so
|
||||||
// seam). Persisted so the key is stable across sessions (records do not proliferate
|
// the key is stable across sessions. Empty = never published (a fresh or pre-v11
|
||||||
// per reopen). Empty = never published (a fresh or pre-v11 instance); the processor
|
// instance); the processor mints one on first publish, and RE-mints when the publish
|
||||||
// mints one on first publish, and RE-mints when the publish plan detects this state
|
// plan detects this state was cloned onto another track (FX copy / track duplication).
|
||||||
// was cloned onto another track (FX copy / track duplication — planUsagePublish).
|
|
||||||
std::string instanceGuid;
|
std::string instanceGuid;
|
||||||
};
|
};
|
||||||
|
|
||||||
inline constexpr std::uint32_t kComponentStateVersion = 11;
|
inline constexpr std::uint32_t kComponentStateVersion = 11;
|
||||||
|
|
||||||
// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed
|
// v10 + the minted instance guid, length-prefixed after the refs table. Mirrors the
|
||||||
// after the refs table). Mirrors the v10/v9/… series so the version branches in
|
// v10/v9/… series so the version branches in deserializeComponentState stay self-describing.
|
||||||
// deserializeComponentState stay self-describing.
|
inline constexpr std::uint32_t kSelectionRefsIdentityV11Version = 11;
|
||||||
inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
|
|
||||||
|
|
||||||
// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table).
|
// v9 + the instance-owned sample-refs table. Wire shape of the refs block (inserted after
|
||||||
// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection
|
// the v9 explicit flag, before the selection id): 4-byte LE entry count, then per entry:
|
||||||
// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE
|
// 4-byte LE id length + id bytes, 4-byte LE path length + path bytes, 4-byte LE rootNote
|
||||||
// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop,
|
// (two's-complement), 1 byte loop.hasLoop, 8-byte LE loop.start + loop.end (int64, written
|
||||||
// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of
|
// regardless of hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName
|
||||||
// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length +
|
// length + bytes (display-only; the editor label's extension-absent fallback).
|
||||||
// displayName bytes (display-only; the editor label's extension-absent fallback).
|
inline constexpr std::uint32_t kSelectionRefsV10Version = 10;
|
||||||
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
|
|
||||||
|
|
||||||
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode
|
// Everything through the master gain, no channel-mode explicit flag. Retained so
|
||||||
// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode.
|
// deserializeComponentState can lift a v8 blob to implicit mode.
|
||||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8;
|
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceGainV8Version = 8;
|
||||||
|
|
||||||
// The GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the
|
// v8 + the channel-mode-EXPLICIT flag. Mirrors the v8/v7/v6/… series so the v9-branch check
|
||||||
// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing.
|
// in deserializeComponentState is self-describing.
|
||||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9;
|
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceGainExplicitV9Version = 9;
|
||||||
|
|
||||||
// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker +
|
// Selection + params + channel mode + consumed marker + preview velocity + voice system, no
|
||||||
// preview velocity + voice system, no master gain). Retained so deserializeComponentState can
|
// master gain. Retained so deserializeComponentState can lift a v7 blob to unity master gain.
|
||||||
// lift a v7 blob to unity master gain.
|
inline constexpr std::uint32_t kSelectionModeMarkerVelVoiceV7Version = 7;
|
||||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7;
|
|
||||||
|
|
||||||
// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker +
|
// Selection + params + channel mode + consumed marker + preview velocity, no voice-system
|
||||||
// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a
|
// fields. Retained so deserializeComponentState can lift a v6 blob to the voice defaults
|
||||||
// v6 blob to the voice defaults {16, Poly, Retrigger}.
|
// {16, Poly, Retrigger}.
|
||||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6;
|
inline constexpr std::uint32_t kSelectionModeMarkerVelV6Version = 6;
|
||||||
|
|
||||||
// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no
|
// Selection + params + channel mode + consumed marker, no preview velocity. Retained so
|
||||||
// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity.
|
// deserializeComponentState can lift a v5 blob to a mid velocity.
|
||||||
inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5;
|
inline constexpr std::uint32_t kSelectionModeMarkerV5Version = 5;
|
||||||
|
|
||||||
// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed
|
// Selection + params + channel mode, no consumed marker. Retained so
|
||||||
// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
|
// deserializeComponentState can lift a v4 blob to {mode, 0, sel, params}.
|
||||||
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
|
inline constexpr std::uint32_t kSelectionModeV4Version = 4;
|
||||||
|
|
||||||
// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named
|
// Selection + params, no channel mode. Retained so deserializeComponentState can lift a v3
|
||||||
// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}.
|
// blob to {mono, selection, params}.
|
||||||
inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
|
inline constexpr std::uint32_t kSelectionV3Version = 3;
|
||||||
|
|
||||||
// The full instance state serialized to bytes for IBStream (getState).
|
// The full instance state serialized to bytes for IBStream (getState).
|
||||||
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state);
|
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state);
|
||||||
@@ -300,18 +251,13 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
|
|||||||
|
|
||||||
// --- Instance state (VST3 setState/getState) --------------------------------
|
// --- Instance state (VST3 setState/getState) --------------------------------
|
||||||
//
|
//
|
||||||
// The instrument's OWN state is which bank sample it plays (D-B: the selection is a
|
// The original v1 instance state was which bank sample it plays — a single string id.
|
||||||
// performance choice, held by the instrument, never written back to the bank). It is a
|
|
||||||
// single string id. serialize/deserialize keep the on-the-wire form explicit and
|
|
||||||
// versioned so a future Tier can extend it without breaking already-saved instances.
|
|
||||||
//
|
//
|
||||||
// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No
|
// Format (v1): 4-byte LE version tag (== 1) followed by the id bytes — no length prefix
|
||||||
// length prefix is needed — the id runs to the end of the stream (the host tells us the
|
// needed, the id runs to end of stream. deserializeSelection tolerates a truncated/wrong-
|
||||||
// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob
|
// version/empty blob by returning "" (no selection is SILENCE + the "pick a capture" empty
|
||||||
// by returning "" (no selection — under the S10 policy reversal an empty selection is
|
// state, not the bank's first sample), never throwing across the host boundary. Retained
|
||||||
// SILENCE + the "pick a capture" empty state, not the bank's first sample), never
|
// for the v1 back-compat lift in deserializeComponentState.
|
||||||
// throwing across the host boundary. Retained for the v1→v3 back-compat lift in
|
|
||||||
// deserializeComponentState; the processor's live state is the v3 ComponentState above.
|
|
||||||
|
|
||||||
inline constexpr std::uint32_t kSelectionStateVersion = 1;
|
inline constexpr std::uint32_t kSelectionStateVersion = 1;
|
||||||
|
|
||||||
@@ -322,5 +268,4 @@ std::vector<std::uint8_t> serializeSelection(const std::string& sampleId);
|
|||||||
// too-short, or empty -> "" (graceful no-selection).
|
// too-short, or empty -> "" (graceful no-selection).
|
||||||
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
|
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
|
||||||
|
|
||||||
|
|
||||||
} // namespace reasampler::instrument::map
|
} // namespace reasampler::instrument::map
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
// note_entry.cpp — see note_entry.h. PURE text->MIDI-note parse for the S12 numeric entry.
|
|
||||||
|
|
||||||
#include "core/instrument/map/note_entry.h"
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <cctype>
|
|
||||||
|
|
||||||
namespace reasampler::instrument::map {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
char asciiUpper(char c) {
|
|
||||||
return static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string trim(const std::string& s) {
|
|
||||||
std::size_t a = 0;
|
|
||||||
std::size_t b = s.size();
|
|
||||||
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
|
|
||||||
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
|
|
||||||
return s.substr(a, b - a);
|
|
||||||
}
|
|
||||||
|
|
||||||
int clampNote(long long n) {
|
|
||||||
if (n < 0) return 0;
|
|
||||||
if (n > 127) return 127;
|
|
||||||
return static_cast<int>(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Semitone offset within an octave for a note letter (C..B), or -1 for a non-letter.
|
|
||||||
int letterSemitone(char up) {
|
|
||||||
switch (up) {
|
|
||||||
case 'C': return 0;
|
|
||||||
case 'D': return 2;
|
|
||||||
case 'E': return 4;
|
|
||||||
case 'F': return 5;
|
|
||||||
case 'G': return 7;
|
|
||||||
case 'A': return 9;
|
|
||||||
case 'B': return 11;
|
|
||||||
default: return -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
std::optional<int> parseNoteName(const std::string& s) {
|
|
||||||
if (s.empty()) return std::nullopt;
|
|
||||||
std::size_t i = 0;
|
|
||||||
const int base = letterSemitone(asciiUpper(s[i]));
|
|
||||||
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).
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
// The octave: an optional sign then digits, running to the end.
|
|
||||||
if (i >= s.size()) return std::nullopt; // a bare "C" has no octave -> reject (ambiguous)
|
|
||||||
bool neg = false;
|
|
||||||
if (s[i] == '+' || s[i] == '-') {
|
|
||||||
neg = (s[i] == '-');
|
|
||||||
++i;
|
|
||||||
}
|
|
||||||
if (i >= s.size()) return std::nullopt;
|
|
||||||
int octave = 0;
|
|
||||||
bool anyDigit = false;
|
|
||||||
for (; i < s.size(); ++i) {
|
|
||||||
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
|
|
||||||
octave = octave * 10 + (s[i] - '0');
|
|
||||||
anyDigit = true;
|
|
||||||
}
|
|
||||||
if (!anyDigit) return std::nullopt;
|
|
||||||
if (neg) octave = -octave;
|
|
||||||
// MIDI note = (octave + 1) * 12 + semitone (C-1 == 0, C4 == 60).
|
|
||||||
const long long note = static_cast<long long>(octave + 1) * 12 + semitone;
|
|
||||||
return clampNote(note);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::optional<int> parseInteger(const std::string& s) {
|
|
||||||
if (s.empty()) return std::nullopt;
|
|
||||||
std::size_t i = 0;
|
|
||||||
bool neg = false;
|
|
||||||
if (s[i] == '+' || s[i] == '-') {
|
|
||||||
neg = (s[i] == '-');
|
|
||||||
++i;
|
|
||||||
}
|
|
||||||
if (i >= s.size()) return std::nullopt;
|
|
||||||
long long v = 0;
|
|
||||||
for (; i < s.size(); ++i) {
|
|
||||||
if (!std::isdigit(static_cast<unsigned char>(s[i]))) return std::nullopt;
|
|
||||||
v = v * 10 + (s[i] - '0');
|
|
||||||
if (v > 1000000) v = 1000000; // saturate; clampNote takes it to 127 anyway
|
|
||||||
}
|
|
||||||
if (neg) v = -v;
|
|
||||||
return clampNote(v);
|
|
||||||
}
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
std::optional<int> parseNoteEntry(const std::string& text) {
|
|
||||||
const std::string s = trim(text);
|
|
||||||
if (s.empty()) return std::nullopt;
|
|
||||||
// Try a plain integer first (the common MIDI-number case); fall back to a note name.
|
|
||||||
if (std::isdigit(static_cast<unsigned char>(s[0])) || s[0] == '+' ||
|
|
||||||
(s[0] == '-' && s.size() > 1 && std::isdigit(static_cast<unsigned char>(s[1])))) {
|
|
||||||
if (auto n = parseInteger(s)) return n;
|
|
||||||
}
|
|
||||||
return parseNoteName(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace reasampler::instrument::map
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
// 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.
|
|
||||||
//
|
|
||||||
// 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).
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <optional>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
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.
|
|
||||||
std::optional<int> parseNoteEntry(const std::string& text);
|
|
||||||
|
|
||||||
} // namespace reasampler::instrument::map
|
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
// sample_map — pure implementation (the RESOLUTION half; the ComponentState codec
|
// sample_map — pure implementation (the resolution half; the ComponentState codec lives
|
||||||
// lives in component_state_io.cpp since Q-W2v). See sample_map.h. NO VST3 / REAPER /
|
// in component_state_io.cpp). See sample_map.h.
|
||||||
// SWELL / vendor includes; standard library + the pure bank_book / wav_codec / sampler_core.
|
|
||||||
|
|
||||||
#include "core/instrument/map/sample_map.h"
|
#include "core/instrument/map/sample_map.h"
|
||||||
|
|
||||||
#include <algorithm> // std::min
|
#include <algorithm> // std::remove_if
|
||||||
#include <cassert> // assert
|
#include <cassert> // assert
|
||||||
#include <utility> // std::move
|
#include <utility> // std::move
|
||||||
|
|
||||||
@@ -12,9 +11,8 @@ namespace reasampler::instrument::map {
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank
|
// The bank stores loop points as an optional LoopPoints (both-or-neither); the core wants
|
||||||
// stores loop points as an optional LoopPoints (both-or-neither); the core wants a
|
// a SampleLoop with an explicit hasLoop. Absent -> no loop.
|
||||||
// SampleLoop with an explicit hasLoop. Absent -> no loop.
|
|
||||||
SampleLoop loopFromSample(const Sample& s) {
|
SampleLoop loopFromSample(const Sample& s) {
|
||||||
SampleLoop out;
|
SampleLoop out;
|
||||||
if (s.loop) {
|
if (s.loop) {
|
||||||
@@ -25,64 +23,35 @@ SampleLoop loopFromSample(const Sample& s) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C
|
// rootNote defaults to middle C (60) when the bank left the intrinsic empty — an
|
||||||
// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on
|
// un-rooted sample plays unity at C4 rather than failing to play.
|
||||||
// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4).
|
|
||||||
SelectedSample distill(const Sample& s) {
|
SelectedSample distill(const Sample& s) {
|
||||||
SelectedSample out;
|
SelectedSample out;
|
||||||
out.relativePath = s.relativePath;
|
out.relativePath = s.relativePath;
|
||||||
out.rootNote = s.rootNote ? *s.rootNote : 60;
|
out.rootNote = s.rootNote ? *s.rootNote : 60;
|
||||||
out.loop = loopFromSample(s);
|
out.loop = loopFromSample(s);
|
||||||
out.channelCount = s.channelCount; // capture intrinsic; 0 = unknown (older entry)
|
out.channelCount = s.channelCount; // 0 = unknown (older entry)
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The ONE override-beats-intrinsic fold shared by the bank-side resolvePerformance and the
|
|
||||||
// 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.
|
|
||||||
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.
|
|
||||||
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).
|
|
||||||
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;
|
|
||||||
return rz;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
||||||
const std::string& sampleId) {
|
const std::string& sampleId) {
|
||||||
// POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short-
|
// An empty selection is SILENCE, not the first sample — by design.
|
||||||
// circuit before parsing — no stored id resolves to nothing to play by design.
|
|
||||||
if (sampleId.empty()) return std::nullopt;
|
if (sampleId.empty()) return std::nullopt;
|
||||||
if (banksJson.empty()) return std::nullopt;
|
if (banksJson.empty()) return std::nullopt;
|
||||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||||
if (!book) return std::nullopt; // malformed -> nothing to play (never throw)
|
if (!book) return std::nullopt; // malformed -> nothing to play (never throw)
|
||||||
|
|
||||||
// Search every bank (pool first, then named — banks() is ordinal order) for the
|
// Search every bank (ordinal order) for the stored id; a sample lives in exactly
|
||||||
// stored id. A sample lives in exactly one bank, so first hit wins.
|
// one bank, so first hit wins.
|
||||||
for (const Bank& b : book->banks()) {
|
for (const Bank& b : book->banks()) {
|
||||||
if (const Sample* s = b.index.query(sampleId)) {
|
if (const Sample* s = b.index.query(sampleId)) {
|
||||||
return distill(*s);
|
return distill(*s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A stale stored id (no longer resolves) is SILENCE, not a substituted first sample:
|
// A stale stored id is SILENCE too — the editor's empty state, not a substitution.
|
||||||
// the editor reflects the missing pick with its empty state rather than masking it.
|
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +61,7 @@ ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplici
|
|||||||
return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono;
|
return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Instance-owned sample references (pS self-contained playback) -------------
|
// --- Instance-owned sample references (self-contained playback) -------------
|
||||||
|
|
||||||
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId) {
|
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId) {
|
||||||
if (sampleId.empty()) return nullptr;
|
if (sampleId.empty()) return nullptr;
|
||||||
@@ -102,18 +71,9 @@ const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleI
|
|||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
|
std::vector<std::string> referencedSampleIds(const std::string& selectionId) {
|
||||||
const PerformanceMap& map) {
|
|
||||||
std::vector<std::string> ids;
|
std::vector<std::string> ids;
|
||||||
const auto addUnique = [&ids](const std::string& id) {
|
if (!selectionId.empty()) ids.push_back(selectionId);
|
||||||
if (id.empty()) return;
|
|
||||||
for (const std::string& have : ids) {
|
|
||||||
if (have == id) return;
|
|
||||||
}
|
|
||||||
ids.push_back(id);
|
|
||||||
};
|
|
||||||
addUnique(selectionId);
|
|
||||||
for (const PerformanceZone& z : map.zones) addUnique(z.sampleId);
|
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +93,7 @@ void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
|
|||||||
for (SampleRefEntry& e : refs) {
|
for (SampleRefEntry& e : refs) {
|
||||||
if (e.sampleId == id) {
|
if (e.sampleId == id) {
|
||||||
e.ref = distilled;
|
e.ref = distilled;
|
||||||
e.displayName = found->displayName; // rename sync rides the same refresh
|
e.displayName = found->displayName; // rename sync
|
||||||
updated = true;
|
updated = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -226,29 +186,25 @@ std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interlea
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||||
int sourceChannels, ChannelMode mode, int sampleRate) {
|
int sourceChannels, ChannelMode mode, int sampleRate) {
|
||||||
assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)");
|
assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)");
|
||||||
DecodedZonePcm out;
|
DecodedPcm out;
|
||||||
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
|
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
|
||||||
out.sampleRate = sampleRate;
|
out.sampleRate = sampleRate;
|
||||||
if (mode == ChannelMode::Mono) {
|
if (mode == ChannelMode::Mono) {
|
||||||
// MONO mode: the existing downmix policy (average all source channels), one channel out.
|
|
||||||
out.monoFrames = downmixToMono(interleaved, sourceChannels);
|
out.monoFrames = downmixToMono(interleaved, sourceChannels);
|
||||||
return out; // framesR stays empty
|
return out; // framesR stays empty
|
||||||
}
|
}
|
||||||
// STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0
|
// extractChannel clamps out-of-range, so a mono source yields L == R (dual-mono).
|
||||||
// duplicated when the source is mono (dual-mono, centered). extractChannel clamps the
|
|
||||||
// out-of-range channel request to the last channel, so a mono source yields L == R.
|
|
||||||
out.monoFrames = extractChannel(interleaved, sourceChannels, 0);
|
out.monoFrames = extractChannel(interleaved, sourceChannels, 0);
|
||||||
out.framesR = extractChannel(interleaved, sourceChannels, 1);
|
out.framesR = extractChannel(interleaved, sourceChannels, 1);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate) {
|
||||||
// seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R,
|
// seconds -> frames at the LIVE rate; source-timeline quantities (trigger %-length +
|
||||||
// pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry
|
// fades) carry through untouched, already frames/fractions.
|
||||||
// through untouched — they are already source frames / fractions. Non-time fields pass as-is.
|
|
||||||
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
|
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
|
||||||
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
|
||||||
const auto secToFrames = [sr](double sec) {
|
const auto secToFrames = [sr](double sec) {
|
||||||
@@ -256,7 +212,7 @@ ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
|||||||
if (f < 0.0) f = 0.0;
|
if (f < 0.0) f = 0.0;
|
||||||
return static_cast<std::int64_t>(f + 0.5);
|
return static_cast<std::int64_t>(f + 0.5);
|
||||||
};
|
};
|
||||||
ZonePlayParams out;
|
PlayParams out;
|
||||||
out.playMode = stored.playMode;
|
out.playMode = stored.playMode;
|
||||||
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
|
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
|
||||||
out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds);
|
out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds);
|
||||||
@@ -269,136 +225,77 @@ ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
|
|||||||
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
|
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
|
||||||
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
|
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
|
||||||
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
|
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
|
||||||
|
// Filter: the control positions are already rate-free and carry through untouched; only
|
||||||
|
// its envelope resolves to frames.
|
||||||
|
out.filter.enabled = stored.filter.enabled;
|
||||||
|
out.filter.settings = stored.filter.settings;
|
||||||
|
out.filter.modAmount = stored.filter.modAmount;
|
||||||
|
out.filter.velAmount = stored.filter.velAmount;
|
||||||
|
out.filter.keyTrack = stored.filter.keyTrack;
|
||||||
|
out.filter.velocityCurve = stored.filter.velocityCurve;
|
||||||
|
out.filter.env.attackFrames = secToFrames(stored.filter.env.attackSeconds);
|
||||||
|
out.filter.env.holdFrames = secToFrames(stored.filter.env.holdSeconds);
|
||||||
|
out.filter.env.decayFrames = secToFrames(stored.filter.env.decaySeconds);
|
||||||
|
out.filter.env.sustainLevel = stored.filter.env.sustainLevel;
|
||||||
|
out.filter.env.releaseFrames = secToFrames(stored.filter.env.releaseSeconds);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
|
// --- The one parameter set ----------------------------------------------------
|
||||||
int rootNote, const SampleLoop& loop,
|
|
||||||
std::vector<AudioSample> framesR, const ZonePlaySeconds& play) {
|
ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams& params) {
|
||||||
assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)");
|
ResolvedCapture rs;
|
||||||
|
rs.relativePath = ref.relativePath;
|
||||||
|
rs.rootNote = params.rootOverride ? *params.rootOverride : ref.rootNote;
|
||||||
|
// Key tracking + velocity curve are instrument state — carried straight through.
|
||||||
|
rs.keyTrack = params.keyTrack;
|
||||||
|
rs.velocityCurve = params.velocityCurve;
|
||||||
|
// The override wins over the intrinsic; absent -> intrinsic (loop) / frame 0 (start).
|
||||||
|
// The bank is never mutated.
|
||||||
|
rs.loop = params.loopOverride ? *params.loopOverride : ref.loop;
|
||||||
|
rs.startFrame = params.startPoint ? *params.startPoint : 0;
|
||||||
|
rs.play = params.play; // SECONDS; buildSampleData resolves to frames
|
||||||
|
return rs;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<ResolvedCapture> resolveFromBank(const std::string& banksJson,
|
||||||
|
const std::string& selectionId,
|
||||||
|
const InstrumentParams& params) {
|
||||||
|
const std::optional<SelectedSample> sel = selectSample(banksJson, selectionId);
|
||||||
|
if (!sel) return std::nullopt;
|
||||||
|
return resolveCapture(*sel, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<ResolvedCapture> resolveFromRefs(const SampleRefs& refs,
|
||||||
|
const std::string& selectionId,
|
||||||
|
const InstrumentParams& params) {
|
||||||
|
const SelectedSample* ref = findRef(refs, selectionId);
|
||||||
|
if (ref == nullptr) return std::nullopt;
|
||||||
|
return resolveCapture(*ref, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded) {
|
||||||
SampleData data;
|
SampleData data;
|
||||||
data.frames = std::move(frames);
|
if (decoded.monoFrames.empty()) return data; // unreadable/empty WAV -> silence
|
||||||
// A second channel only counts when it length-matches channel 0 (else the sample stays
|
assert(decoded.sampleRate > 0 &&
|
||||||
// mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays).
|
"buildSampleData: DecodedPcm::sampleRate must be > 0 (programming error)");
|
||||||
if (!framesR.empty() && framesR.size() == data.frames.size()) {
|
if (decoded.sampleRate <= 0) return data; // safe early-return; assert fires first
|
||||||
data.framesR = std::move(framesR);
|
data.frames = std::move(decoded.monoFrames);
|
||||||
}
|
|
||||||
if (sampleRate <= 0) return Keymap{}; // safe early-return; assert fires first
|
|
||||||
data.sampleRate = sampleRate;
|
|
||||||
data.rootNote = rootNote;
|
|
||||||
data.loop = loop;
|
|
||||||
// Resolve the stored wall-clock SECONDS to the engine's frame domain at the WAV's actual rate.
|
|
||||||
data.play = resolvePlay(play, data.sampleRate);
|
|
||||||
|
|
||||||
return Keymap::singleSampleChromatic(std::move(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Performance map ---------------------------------------------------------
|
|
||||||
|
|
||||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
|
||||||
const PerformanceMap& map) {
|
|
||||||
ResolvedPerformance out;
|
|
||||||
if (map.zones.empty()) return out; // empty map -> empty (shell -> Tier 0)
|
|
||||||
if (banksJson.empty()) return out; // no bank -> nothing resolves
|
|
||||||
std::optional<BankBook> book = BankBook::deserialize(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.
|
|
||||||
const Sample* found = nullptr;
|
|
||||||
for (const Bank& b : book->banks()) {
|
|
||||||
if (const Sample* s = b.index.query(z.sampleId)) {
|
|
||||||
found = s;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!found) {
|
|
||||||
// STALE-ID POLICY: drop the zone cleanly, report the id (editor can prune).
|
|
||||||
out.droppedSampleIds.push_back(z.sampleId);
|
|
||||||
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.
|
|
||||||
out.zones.push_back(foldZone(z, distill(*found)));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
|
|
||||||
const PerformanceMap& map) {
|
|
||||||
ResolvedPerformance out;
|
|
||||||
for (const PerformanceZone& z : map.zones) {
|
|
||||||
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.
|
|
||||||
out.droppedSampleIds.push_back(z.sampleId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool reconcileSingleCaptureZones(PerformanceMap& map, const std::string& selectedId) {
|
|
||||||
if (selectedId.empty() || map.zones.empty()) return false;
|
|
||||||
for (const PerformanceZone& z : map.zones) {
|
|
||||||
// An authored key range marks Zone-view intent — first-match order is load-bearing
|
|
||||||
// there, so the map is left exactly as authored.
|
|
||||||
if (z.lowNote != 0 || z.highNote != 127) return false;
|
|
||||||
}
|
|
||||||
// Every zone is full-range: the map is purely Sample-face-shaped. Keep only the first
|
|
||||||
// zone bound to the selection (preserving its params); drop the stale shadowers.
|
|
||||||
// Decide BEFORE mutating so the no-change path leaves the map bit-identical.
|
|
||||||
std::size_t keepIdx = map.zones.size(); // size() = no zone for the selection
|
|
||||||
for (std::size_t i = 0; i < map.zones.size(); ++i) {
|
|
||||||
if (map.zones[i].sampleId == selectedId) { keepIdx = i; break; }
|
|
||||||
}
|
|
||||||
const std::size_t keptCount = (keepIdx < map.zones.size()) ? 1u : 0u;
|
|
||||||
if (keptCount == map.zones.size()) return false; // one zone, already the selection's
|
|
||||||
if (keptCount == 1 && keepIdx != 0) map.zones[0] = std::move(map.zones[keepIdx]);
|
|
||||||
map.zones.resize(keptCount);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
|
||||||
const std::vector<DecodedZonePcm>& decoded) {
|
|
||||||
Keymap km;
|
|
||||||
const std::size_t n = std::min(zones.size(), decoded.size());
|
|
||||||
for (std::size_t i = 0; i < n; ++i) {
|
|
||||||
// An unreadable/empty WAV drops just this zone (not the whole map).
|
|
||||||
if (decoded[i].monoFrames.empty()) continue;
|
|
||||||
SampleData data;
|
|
||||||
data.frames = decoded[i].monoFrames;
|
|
||||||
// Carry the second channel only when it length-matches channel 0 (channelCount()
|
// Carry the second channel only when it length-matches channel 0 (channelCount()
|
||||||
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
|
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
|
||||||
if (!decoded[i].framesR.empty() &&
|
if (!decoded.framesR.empty() && decoded.framesR.size() == data.frames.size()) {
|
||||||
decoded[i].framesR.size() == data.frames.size()) {
|
data.framesR = std::move(decoded.framesR);
|
||||||
data.framesR = decoded[i].framesR;
|
|
||||||
}
|
}
|
||||||
assert(decoded[i].sampleRate > 0 &&
|
data.sampleRate = decoded.sampleRate;
|
||||||
"buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)");
|
data.rootNote = resolved.rootNote;
|
||||||
if (decoded[i].sampleRate <= 0) continue; // safe skip; assert fires first
|
data.loop = resolved.loop;
|
||||||
data.sampleRate = decoded[i].sampleRate;
|
data.startFrame = resolved.startFrame;
|
||||||
data.rootNote = zones[i].rootNote;
|
data.keyTrack = resolved.keyTrack;
|
||||||
data.loop = zones[i].loop;
|
data.velocityCurve = resolved.velocityCurve;
|
||||||
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
|
|
||||||
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
|
// Resolve the stored wall-clock SECONDS (AHDSR, pitch env A/D) to frames at THIS WAV's
|
||||||
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
|
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
|
||||||
data.play = resolvePlay(zones[i].play, data.sampleRate);
|
data.play = resolvePlay(resolved.play, data.sampleRate);
|
||||||
const std::size_t sampleIndex = km.samples.size();
|
return data;
|
||||||
km.samples.push_back(std::move(data));
|
|
||||||
KeyZone zone;
|
|
||||||
zone.lowNote = zones[i].lowNote;
|
|
||||||
zone.highNote = zones[i].highNote;
|
|
||||||
zone.rootNote = zones[i].rootNote;
|
|
||||||
zone.keyTrack = zones[i].keyTrack; // S-VIEW-6: applied in keyTrackedRatio at play time
|
|
||||||
zone.velocityCurve = zones[i].velocityCurve; // S-VIEW-9: eval'd in Voice::start
|
|
||||||
zone.sampleIndex = sampleIndex;
|
|
||||||
km.zones.push_back(zone);
|
|
||||||
}
|
}
|
||||||
return km; // empty zones in -> empty Keymap (silence)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
} // namespace reasampler::instrument::map
|
} // namespace reasampler::instrument::map
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
// sample_map — PURE mapping logic for the S4 Tier-0 instrument: turn the live
|
// sample_map — turns the live "reasampler" bank ext-state + a decoded WAV into the plain
|
||||||
// "reasampler" bank ext-state + a decoded WAV into the plain data the sampler core
|
// data the sampler core plays, and resolves the instance's one capture + one parameter set.
|
||||||
// plays, and (de)serialize the instance's selected-sample choice for VST3 component
|
// The bank is read over the live-state seam, audio over the file seam; both raw inputs
|
||||||
// state. NO VST3, NO REAPER, NO SWELL, NO vendor/ includes at the boundary — the
|
// cross the bridge/file boundary in the shell, everything after (bank parse via the shared
|
||||||
// mirror of capture_paths / wav_codec / bridge_marshal splitting the fiddly, testable
|
// bank_book JSON path, sample pick, channel policy, SampleData build) is pure and
|
||||||
// arithmetic out of a host-facing shell.
|
// unit-tested here. Links bank_book, wav_codec, and play_params (all pure) — deliberately
|
||||||
//
|
// NOT the voice engine: the build's product is plain SampleData.
|
||||||
// WHY IT EXISTS (S4 seams). The instrument reads the bank over the live-state seam
|
|
||||||
// (the "banks" ext-state blob) and the audio over the file seam (the on-disk WAV).
|
|
||||||
// Both of those raw inputs cross the bridge/file boundary in the shell; everything
|
|
||||||
// after — parse the bank with the SHARED bank_model/bank_book JSON path (NOT a second
|
|
||||||
// parser; the S1 spike's string-scan reader is retired), pick the selected sample,
|
|
||||||
// downmix its decoded PCM to the core's mono contract, and build the Tier-0 chromatic
|
|
||||||
// Keymap — is pure and unit-tested here.
|
|
||||||
//
|
|
||||||
// It links bank_book (the shared BankBook::deserialize) and wav_codec (the shared
|
|
||||||
// 32-bit-float WAV parse — no third WAV reader) and sampler_core (the Keymap /
|
|
||||||
// SampleData it produces). All three are pure; this stays pure.
|
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
@@ -24,82 +13,59 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse)
|
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse)
|
||||||
#include "core/instrument/engine/sampler_core.h" // Keymap, SampleData, SampleLoop
|
#include "core/instrument/engine/play_params.h" // SampleData, SampleLoop, PlayParams
|
||||||
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||||
|
|
||||||
namespace reasampler::instrument::map {
|
namespace reasampler::instrument::map {
|
||||||
|
|
||||||
// Cross-subsystem deps by their real namespace homes (Q-W2v: sample_map now lives in
|
|
||||||
// instrument::map; the engine family stays in flat `reasampler` until its own wave).
|
|
||||||
using audio::AudioSample;
|
using audio::AudioSample;
|
||||||
using instrument::engine::VelocityCurve;
|
using instrument::engine::VelocityCurve;
|
||||||
using instrument::engine::VelocityPoint;
|
using instrument::engine::VelocityPoint;
|
||||||
|
|
||||||
// The bank sample this instance is bound to, distilled from the live "banks" blob:
|
// The bank sample this instance is bound to, distilled from the live "banks" blob: the
|
||||||
// the project-relative WAV path the file seam must resolve+decode, plus the S2 bank
|
// project-relative WAV path the file seam resolves+decodes, plus the bank intrinsics the
|
||||||
// intrinsics the core repitches / loops by. A pure value — no host, no PCM yet.
|
// core repitches/loops by. A pure value — no host, no PCM yet.
|
||||||
struct SelectedSample {
|
struct SelectedSample {
|
||||||
std::string relativePath; // project-relative; the shell resolves it (M4 convention)
|
std::string relativePath; // project-relative; the shell resolves it
|
||||||
int rootNote = 60; // S2 intrinsic; defaults to middle C when the bank left it empty
|
int rootNote = 60; // defaults to middle C when the bank left it empty
|
||||||
SampleLoop loop; // S2 intrinsic; hasLoop=false when the bank left it empty
|
SampleLoop loop; // hasLoop=false when the bank left it empty
|
||||||
int channelCount = 0; // bank intrinsic (capture channel count); 0 = unknown (older
|
int channelCount = 0; // capture channel count; 0 = unknown (older bank entries) —
|
||||||
// bank entries) — the GA channel-mode auto-default skips it
|
// the channel-mode auto-default skips it
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve the bound sample from the live bank blob. `banksJson` is the raw "banks"
|
// `banksJson` is the raw "banks" ext-state value the bridge read (may be empty/malformed —
|
||||||
// ext-state value the bridge read (may be empty / malformed — an unsaved or pre-bank
|
// an unsaved or pre-bank project); `sampleId` is this instance's stored selection.
|
||||||
// project). `sampleId` is this instance's stored selection.
|
|
||||||
//
|
//
|
||||||
// Precedence, all pure:
|
// Precedence: empty/malformed banksJson -> nullopt. Empty sampleId -> nullopt (no selection
|
||||||
// * empty / malformed banksJson -> nullopt (nothing to play)
|
// is SILENCE, not the bank's first sample — deliberate: the metric is time-to-first-note via
|
||||||
// * sampleId empty -> nullopt (NO selection -> silence)
|
// an explicit pick, and mystery auto-play of sample #1 was the anti-pattern). sampleId found
|
||||||
// * sampleId names a sample in ANY bank -> that sample (searched pool + named)
|
// in any bank -> that sample. sampleId set but not found (stale) -> nullopt, same as no
|
||||||
// * sampleId set but not found (stale) -> nullopt (the sample was deleted/moved;
|
// selection — the editor shows its "pick a capture" empty state rather than masking it.
|
||||||
// the editor returns to the empty state)
|
|
||||||
//
|
|
||||||
// POLICY REVERSAL (S10, 2026-07-26 — supersedes the S4 first-sample fallback). A fresh
|
|
||||||
// instance with no stored selection resolves to nullopt (SILENCE), NOT the bank's first
|
|
||||||
// sample: the metric is time-to-first-note via an explicit pick, and a mystery auto-play
|
|
||||||
// of sample #1 was the anti-pattern. A stale stored id (no longer resolves) ALSO returns
|
|
||||||
// nullopt rather than silently substituting a different sample — the editor reflects the
|
|
||||||
// missing selection with its "pick a capture" empty state instead of masking it.
|
|
||||||
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
||||||
const std::string& sampleId);
|
const std::string& sampleId);
|
||||||
|
|
||||||
// GA auto-default rule (pure, tested): given the capture's requested channel count, the
|
// Auto-default rule: given the capture's channel count, current mode, and whether the user
|
||||||
// instance's current mode, and whether the user has explicitly toggled the mode, return
|
// explicitly toggled it, return the mode to apply. Explicit choice is never overridden;
|
||||||
// the mode to apply. Explicit choice is never overridden. An unknown channelCount (0)
|
// channelCount == 0 (unknown) leaves the current mode; >= 2 -> Stereo; == 1 -> Mono.
|
||||||
// leaves the current mode unchanged. Used by reloadInstrument in the single-capture path.
|
|
||||||
// * isExplicit == true -> current (user's choice stands)
|
|
||||||
// * channelCount == 0 -> current (unknown, skip)
|
|
||||||
// * channelCount >= 2 -> Stereo
|
|
||||||
// * channelCount == 1 -> Mono
|
|
||||||
ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit);
|
ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit);
|
||||||
|
|
||||||
// --- Instance-owned sample references (pS self-contained playback) -------------
|
// --- Instance-owned sample references (self-contained playback) -------------
|
||||||
//
|
//
|
||||||
// THE ARCHITECTURE CORRECTION: the instrument must never go silent because the extension's
|
// The instrument must never go silent just because the extension's ext-state hasn't parsed
|
||||||
// ext-state has not parsed yet (or the extension is absent). So the instance persists, in
|
// yet (or the extension is absent). So the instance persists, in its OWN component state, a
|
||||||
// its OWN component state, a small table of everything it needs to PLAY each referenced
|
// table of everything needed to PLAY each referenced bank sample: path + decode intrinsics
|
||||||
// bank sample: the project-relative WAV path + the decode intrinsics (root note, loop,
|
// (root, loop, channel count), keyed by bank sample id. The shell decodes straight from
|
||||||
// channel count) — exactly a SelectedSample, keyed by the bank sample id. On load the
|
// these refs; the bank blob is a browser source that refreshes the table opportunistically
|
||||||
// shell decodes straight from these refs; the bank blob is a BROWSER SOURCE that also
|
// when readable, never a runtime lifeline.
|
||||||
// refreshes this table opportunistically when readable (recapture/root edits stay live),
|
|
||||||
// never a runtime lifeline.
|
|
||||||
//
|
//
|
||||||
// POLICY (follows from ownership): a sample deleted from the BANK no longer silences an
|
// Consequence: a sample deleted from the bank no longer silences an instance that carries
|
||||||
// instance that carries its ref — the instance keeps playing while the FILE exists (normal
|
// its ref — it keeps playing while the file exists (normal sampler behavior; prune deleting
|
||||||
// sampler behavior; prune deleting the file yields the defined no-play). This deliberately
|
// the file yields the defined no-play).
|
||||||
// supersedes the S10 stale-id-silence rule, which was an artifact of bank-side resolution.
|
|
||||||
struct PerformanceMap; // defined below (Tier 1); referencedSampleIds spans both tiers
|
|
||||||
|
|
||||||
struct SampleRefEntry {
|
struct SampleRefEntry {
|
||||||
std::string sampleId; // the bank sample id this ref was copied from (the seam key)
|
std::string sampleId; // the bank sample id this ref was copied from (the seam key)
|
||||||
SelectedSample ref; // path + intrinsics, sufficient to decode + play without a bank
|
SelectedSample ref; // path + intrinsics, sufficient to decode + play without a bank
|
||||||
// The sample's bank display name at copy time — DISPLAY ONLY (the editor's label falls
|
// Bank display name at copy time — DISPLAY ONLY (editor label fallback when the bank
|
||||||
// back to it when the bank snapshot is unavailable, mirroring the waveform/loop ref
|
// snapshot is unavailable); never consulted by resolution.
|
||||||
// fallback); never consulted by resolution. Empty for a table written before the field
|
|
||||||
// existed in-session (it back-fills on the next bank refresh).
|
|
||||||
std::string displayName;
|
std::string displayName;
|
||||||
};
|
};
|
||||||
using SampleRefs = std::vector<SampleRefEntry>;
|
using SampleRefs = std::vector<SampleRefEntry>;
|
||||||
@@ -107,48 +73,36 @@ using SampleRefs = std::vector<SampleRefEntry>;
|
|||||||
// Find the ref for `sampleId` (nullptr on miss). Pointer into `refs` — do not outlive it.
|
// Find the ref for `sampleId` (nullptr on miss). Pointer into `refs` — do not outlive it.
|
||||||
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId);
|
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId);
|
||||||
|
|
||||||
// Every bank sample id this instance plays: the selection (when set) + each zone's
|
// Every bank sample id this instance plays. One capture = at most one id; the list form is
|
||||||
// sampleId, de-duplicated, selection first then map order.
|
// kept because the refs-table helpers below are id-set operations.
|
||||||
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
|
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
|
// Upsert a ref for each id in `ids` that resolves in the live bank blob, copying the display
|
||||||
// distillation selectSample performs), copying the bank display name alongside the decode
|
// name alongside the decode intrinsics. A miss leaves any existing entry untouched — the
|
||||||
// intrinsics. A miss leaves any existing entry untouched — the instance owns its copy; a
|
// instance owns its copy; a bank deletion never strips a ref. Empty/malformed blob -> no-op.
|
||||||
// bank deletion never strips a ref. Empty/malformed blob -> no-op.
|
|
||||||
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
|
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
|
||||||
const std::vector<std::string>& ids);
|
const std::vector<std::string>& ids);
|
||||||
|
|
||||||
// The pre-v10 LEGACY-LIFT terminating decision (pure, so the no-churn rule is provable
|
// Legacy-lift terminating decision: can a refs lift make progress against this bank blob
|
||||||
// without a host): can a refs lift MAKE PROGRESS against this bank blob for the ids the
|
// for the ids the instance references?
|
||||||
// instance references?
|
// * Retry — blob absent/empty/unparseable: not readable yet, keep retrying.
|
||||||
// * Retry — the blob is absent/empty/unparseable: not readable YET, keep retrying (the
|
// * Lift — blob parses and at least one id resolves: copy a ref in (never re-fires once
|
||||||
// project's ext-state may simply not have parsed).
|
// the refs table is non-empty).
|
||||||
// * Lift — the blob parses and at least one id resolves: a lift copies a ref in (the
|
// * Stale — blob parses and no id resolves: provably stale, nothing to lift, ever — the
|
||||||
// refs table then goes non-empty and the lift never re-fires).
|
// shell latches this and stops retrying (no per-tick churn).
|
||||||
// * Stale — the blob parses and NO id resolves (an empty `ids` included): the ids are
|
|
||||||
// PROVABLY stale — the bank is readable and does not know them — so there is nothing
|
|
||||||
// to lift, ever. The shell latches this and stops retrying (no per-tick churn).
|
|
||||||
enum class LegacyLiftDecision { Retry, Lift, Stale };
|
enum class LegacyLiftDecision { Retry, Lift, Stale };
|
||||||
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
|
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
|
||||||
const std::vector<std::string>& ids);
|
const std::vector<std::string>& ids);
|
||||||
|
|
||||||
// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table tracks
|
// Keep only the entries whose id is in `ids` (getState hygiene: the persisted table cannot
|
||||||
// exactly what the instance currently plays, so it cannot grow with browsing history).
|
// grow with browsing history).
|
||||||
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids);
|
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids);
|
||||||
|
|
||||||
// One entry in the capture browser's card list: the stable id + display name plus the S2
|
// One entry in the capture browser's card list: stable id + display name + intrinsics +
|
||||||
// intrinsics + bank the browser draws as a card (peak thumbnail + name + root/key badge,
|
// bank, for a card (peak thumbnail + name + root/key badge, filterable by bank). Peaks are
|
||||||
// filterable by bank). Peaks are NOT here — they are computed shell-side from the decoded
|
// NOT here — computed shell-side from the decoded PCM (reasampler_editor's thumbnail
|
||||||
// PCM (the `Sample` metadata carries no envelope; see reasampler_editor's thumbnail cache,
|
// cache). rootNote is nullopt when the bank left it empty (badge shows no root, never a
|
||||||
// the mirror of bank_panel::thumbnailFor). This carries only what the bank blob already
|
// guessed value). Pure projection over the shared parse — the UI never parses JSON itself.
|
||||||
// holds: the metadata the card badge + bank filter need. Pure projection over the shared
|
|
||||||
// parse — the UI never parses JSON itself.
|
|
||||||
//
|
|
||||||
// - rootNote: the S2 rootNote intrinsic when the bank set it (nullopt otherwise — the
|
|
||||||
// badge shows "root: —" / no root, never a guessed value).
|
|
||||||
// - key: the optional human musical key label ("F#m"), when the bank set it.
|
|
||||||
// - bankId: the id of the bank this sample lives in (the bank filter matches on it).
|
|
||||||
struct SampleChoice {
|
struct SampleChoice {
|
||||||
std::string id;
|
std::string id;
|
||||||
std::string displayName;
|
std::string displayName;
|
||||||
@@ -167,35 +121,27 @@ struct BankChoice {
|
|||||||
};
|
};
|
||||||
std::vector<BankChoice> listBanks(const std::string& banksJson);
|
std::vector<BankChoice> listBanks(const std::string& banksJson);
|
||||||
|
|
||||||
// Downmix interleaved float frames (the shape wav_codec's extractFloatFrames yields:
|
// Downmix interleaved float frames ([f0c0,f0c1,...,f1c0,...]) to ONE channel by AVERAGING
|
||||||
// [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per
|
// channels per frame (`channelCount` is the interleave stride, >= 1) — not "take L", not
|
||||||
// frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0,
|
// summing: a centered mono source stays unity, a hard-panned source is attenuated rather
|
||||||
// documented + surfaced): the S3 core is mono-per-sample by design; bank WAVs preserve
|
// than silenced or doubled. Empty/zero-stride in -> empty out. Pure.
|
||||||
// their source channel count, so a stereo (or N-channel) capture is folded to a single
|
|
||||||
// mono stream here by an equal-weight average. Averaging (not "take L", not summing) is
|
|
||||||
// the least-surprising, no-clip default — a centered mono source stays unity, and a
|
|
||||||
// hard-panned source is attenuated rather than silenced or doubled. Empty / zero-stride
|
|
||||||
// in -> empty out. Pure.
|
|
||||||
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
|
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
|
||||||
int channelCount);
|
int channelCount);
|
||||||
|
|
||||||
// Deinterleave one channel (`which`, 0-based) out of interleaved frames. `channelCount` is
|
// Deinterleave one channel (`which`, 0-based). `which` clamps to a valid channel (a request
|
||||||
// the interleave stride (>= 1); `which` is clamped to a valid channel (a request past the
|
// past the last channel reads the last channel, so a mono source asked for channel 1 yields
|
||||||
// source's last channel reads the last channel, so a mono source asked for channel 1 yields
|
// channel 0 — the dual-mono building block). Empty/zero-stride in -> empty out. Pure.
|
||||||
// channel 0 again — the dual-mono building block). Empty / zero-stride in -> empty out. Pure.
|
|
||||||
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
|
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
|
||||||
int channelCount, int which);
|
int channelCount, int which);
|
||||||
|
|
||||||
// --- Stored (wall-clock SECONDS) per-zone play params -------------------------
|
// --- Stored (wall-clock SECONDS) play params ----------------------------------
|
||||||
//
|
//
|
||||||
// DOMAIN SPLIT (S12 remediation — Daniel's ruling: no hardcoded sample rate in the program).
|
// Daniel's standing ruling: no hardcoded sample rate anywhere in the program. The
|
||||||
// The instrument stores and edits WALL-CLOCK performance times as SECONDS, rate-free; the
|
// instrument stores/edits wall-clock performance times (AHDSR A/H/D/R, pitch-env A/D) as
|
||||||
// engine (sampler_core's ZonePlayParams, on SampleData) receives FRAMES resolved from the
|
// SECONDS, rate-free; the engine receives FRAMES resolved from the LIVE sample rate at
|
||||||
// LIVE sample rate at keymap build. AHDSR (A/H/D/S/R) and the AD pitch envelope (attack/decay)
|
// build. Quantities anchored to the source file's timeline (start point, loop points,
|
||||||
// are wall-clock — the voice advances them once per OUTPUT frame — so they live here in seconds.
|
// Trigger %-length + fades) stay in source frames/fractions, carried through unchanged
|
||||||
// Quantities anchored to the source file's timeline (start point, loop points, Trigger %-length
|
// (TriggerParams reused verbatim).
|
||||||
// and its fades — the fades anchor to the source-frame read offset, PLAN.md §S15) stay in source
|
|
||||||
// frames / fractions and are carried through unchanged (TriggerParams is reused verbatim).
|
|
||||||
//
|
//
|
||||||
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
|
// The stored AHDSR times (seconds). sustainLevel is dimensionless (0..1), not a time.
|
||||||
struct AdsrSeconds {
|
struct AdsrSeconds {
|
||||||
@@ -214,209 +160,140 @@ struct PitchEnvSeconds {
|
|||||||
double peakSemitones = 0.0; // signed depth at the peak
|
double peakSemitones = 0.0; // signed depth at the peak
|
||||||
};
|
};
|
||||||
|
|
||||||
// The stored per-zone play bundle: wall-clock times in SECONDS, source-timeline quantities in
|
// The stored mirror of the engine's FilterParams (play_params.h, which owns what each field
|
||||||
// frames/fractions (TriggerParams). This is the instrument-owned (D-B), serialized, editor-facing
|
// MEANS). Only the envelope differs between the two: the control positions and depths are
|
||||||
// representation — distinct from sampler_core's engine-facing ZonePlayParams (frames). The keymap
|
// rate-free already, so this block is a seconds/frames split of one field, not of the whole
|
||||||
// builders resolve this to a frame-domain ZonePlayParams against the live sample rate.
|
// struct. The env default is a flat unity, so `enabled` is the only thing standing between a
|
||||||
struct ZonePlaySeconds {
|
// loaded blob and the pre-filter sound.
|
||||||
|
struct FilterSeconds {
|
||||||
|
bool enabled = false;
|
||||||
|
engine::filter::FilterSettings settings;
|
||||||
|
double modAmount = 0.0;
|
||||||
|
double velAmount = 0.0;
|
||||||
|
double keyTrack = 0.0;
|
||||||
|
AdsrSeconds env{0.0, 0.0, 0.0, 1.0, 0.0};
|
||||||
|
VelocityCurve velocityCurve = VelocityCurve::linear();
|
||||||
|
};
|
||||||
|
|
||||||
|
// The stored play bundle: wall-clock times in SECONDS, source-timeline quantities in
|
||||||
|
// frames/fractions (TriggerParams). Instrument-owned, serialized, editor-facing — distinct
|
||||||
|
// from the engine-facing PlayParams (frames).
|
||||||
|
struct PlaySeconds {
|
||||||
PlayMode playMode = PlayMode::Gate;
|
PlayMode playMode = PlayMode::Gate;
|
||||||
AdsrSeconds adsr; // Gate: AHDSR (seconds)
|
AdsrSeconds adsr; // Gate: AHDSR (seconds)
|
||||||
TriggerParams trigger; // Trigger: %-length + fades (source frames)
|
TriggerParams trigger; // Trigger: %-length + fades (source frames)
|
||||||
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve (S16-F1)
|
PitchEngine pitchEngine = kDefaultPitchEngine; // product default: Preserve
|
||||||
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
|
PitchEnvSeconds pitchEnv; // AD pitch modulation (seconds), off by default
|
||||||
|
FilterSeconds filter; // per-voice filter, off by default
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve a stored seconds bundle to the engine's frame-domain ZonePlayParams against a live
|
// Resolve a stored seconds bundle to the engine's frame-domain PlayParams against a live
|
||||||
// sample rate (frames = round(seconds * rate)). Source-timeline fields (trigger, engine, mode,
|
// sample rate (frames = round(seconds * rate)). Source-timeline fields carry through
|
||||||
// peak, enabled) carry through unchanged. `sampleRate` must be > 0 (the caller guards this).
|
// unchanged. `sampleRate` must be > 0 (the caller guards this).
|
||||||
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate);
|
PlayParams resolvePlay(const PlaySeconds& stored, int sampleRate);
|
||||||
|
|
||||||
// Build the Tier-0 chromatic keymap for one decoded sample: one zone spanning the whole
|
// --- The instrument's ONE parameter set (its OWN state) -----------------------
|
||||||
// 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.
|
|
||||||
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) ---------------
|
|
||||||
//
|
//
|
||||||
// The performance map is the keymap the user authors IN the instrument: several bank
|
// One loaded capture, one set of playback parameters governing it across the whole
|
||||||
// samples zoned across the keyboard, each with a key range and a root note. It is a
|
// keyboard. A performance choice, so it lives in the instrument (VST3 component state),
|
||||||
// PERFORMANCE CHOICE (D-B), so it lives in the instrument (VST3 component state), never
|
// never written back to the bank. Pure value type: names no sample (the ComponentState's
|
||||||
// written back to the bank. Root note per zone is SEEDED from the S2 bank intrinsic but
|
// selection id is the capture) and holds no PCM — the shell resolves + decodes the WAV, and
|
||||||
// OVERRIDABLE here — the override lives on the zone, never on `Sample`.
|
// the pure build stitches the decoded frames + this set into one SampleData.
|
||||||
//
|
//
|
||||||
// Pure value type: it names bank samples by id (the stable seam key) and holds no PCM.
|
// rootOverride absent -> repitch from the capture's own rootNote intrinsic (or middle C when
|
||||||
// The shell resolves each id's WAV over the file seam and decodes it; the pure zone-build
|
// the bank left it empty). loopOverride/startPoint mirror it: the sustain loop and initial
|
||||||
// stitches the decoded frames + this map into a sampler_core Keymap.
|
// read position are facts about the file, but the instrument may override them without
|
||||||
|
// writing back to the bank (loopOverride wins when set; startPoint sets the voice's initial
|
||||||
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range,
|
// read frame, absent -> 0). resolveCapture folds override-beats-intrinsic into the effective
|
||||||
// with an optional root-note override. rootOverride absent -> repitch from the bank
|
// ResolvedCapture.
|
||||||
// sample's own S2 rootNote intrinsic (or middle C when the bank left it empty).
|
struct InstrumentParams {
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
struct PerformanceZone {
|
|
||||||
std::string sampleId; // bank sample id this zone plays
|
|
||||||
int lowNote = 0; // inclusive
|
|
||||||
int highNote = 127; // inclusive
|
|
||||||
std::optional<int> rootOverride; // instrument-owned override; absent -> bank intrinsic
|
std::optional<int> rootOverride; // instrument-owned override; absent -> bank intrinsic
|
||||||
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
|
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> intrinsic
|
||||||
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
|
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
|
||||||
|
|
||||||
// S-VIEW-6 key-tracking scalar (instrument-owned, D-B — mirror of rootOverride): how far
|
// Key-tracking scalar: how far playback pitch tracks the keyboard around the root. 1.0
|
||||||
// playback pitch tracks the keyboard around the root. 1.0 (100%) is standard 12-tone-ET (the
|
// (100%, standard 12-tone-ET) is the default — a blob predating this field lifts to
|
||||||
// DEFAULT; a pre-S-VIEW-6 blob with no keyTrack tail lifts to exactly 1.0, so already-saved
|
// exactly 1.0, so already-saved instances are bit-identical. 0.0 = no tracking (every
|
||||||
// instances are bit-identical); 0.0 = no tracking (every key plays root pitch); 2.0 = double.
|
// key plays root pitch); 2.0 = double. Applied in keyTrackedRatio inside both repitch
|
||||||
// NOT flag-gated — always present in the CURRENT payload (v6). Carried through to KeyZone by
|
// engines.
|
||||||
// resolvePerformance and applied in keyTrackedRatio inside BOTH repitch engines.
|
|
||||||
double keyTrack = 1.0;
|
double keyTrack = 1.0;
|
||||||
|
|
||||||
// S-VIEW-9 velocity->amp transfer curve (instrument-owned, D-B — mirror of keyTrack): maps the
|
// Velocity->amp transfer curve: maps note-on MIDI velocity (0..127) to voice amp gain,
|
||||||
// note-on MIDI velocity (0..127) to the voice's amp gain, replacing the fixed linear velocity/127.
|
// replacing the old fixed linear velocity/127. Default = flat y=1 (Daniel-approved):
|
||||||
// A per-sound performance characteristic, so it varies PER ZONE. DEFAULT = flat y=1 (R10-F1
|
// every velocity plays at unity. DELIBERATE non-back-compat behavior change — a blob
|
||||||
// Option A, Daniel-approved): every velocity plays at unity. This is a DELIBERATE, non-back-compat
|
// predating this field lifts to flat y=1, so an already-saved instance's soft hits play
|
||||||
// behavior change — a pre-S-VIEW-9 blob (no velocityCurve field) lifts to flat y=1, so an
|
// LOUDER than under the old linear map. Do NOT preserve the linear response. Eval'd in
|
||||||
// already-saved zone's soft hits play LOUDER than under the old linear map. Intended; do NOT
|
// Voice::start.
|
||||||
// preserve the linear response. Carried to KeyZone by resolvePerformance, eval'd in Voice::start.
|
|
||||||
// Sequenced on the zones-payload axis AFTER keyTrack (payload v6 -> v7).
|
|
||||||
VelocityCurve velocityCurve = VelocityCurve::flat();
|
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||||
|
|
||||||
// S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch
|
// Play parameters (play mode + AHDSR + Trigger %-length/fades; pitch engine + AD pitch
|
||||||
// engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the
|
// envelope). Instrument-owned, never a bank fact. Wall-clock times stored in SECONDS
|
||||||
// loop/start overrides. Wall-clock times are stored in SECONDS (rate-free); the keymap build
|
// (rate-free); the build resolves to frames at the live sample rate. Defaults: Gate,
|
||||||
// resolves them to frames at the live sample rate. Defaults to the PRODUCT defaults for a NEW
|
// tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades, Preserve pitch
|
||||||
// zone: Gate play mode, tier-0 AHDSR seconds (0.003 attack / 0.060 release), hold 0, no fades,
|
// engine, pitch env off. An older blob lacking this tail lifts to exactly these.
|
||||||
// PRESERVE pitch engine (S16-F1), pitch env off. An older zone-payload blob (no S15/S16 tail)
|
PlaySeconds play;
|
||||||
// lifts to exactly these defaults on read (see the PAYLOAD versioning).
|
|
||||||
ZonePlaySeconds play;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The instrument's performance map: an ordered list of zones. Order is authoritative for
|
// The loaded capture resolved for decode + build: project-relative WAV path (file seam)
|
||||||
// overlap resolution (OVERLAP POLICY: first zone in order wins, mirroring the S3 core's
|
// plus the effective values after override-beats-intrinsic. Distinct from InstrumentParams
|
||||||
// first-match Keymap::resolve — overlaps are neither rejected nor clamped, the earlier
|
// (which holds optional overrides) — this is the parameter set folded against the capture.
|
||||||
// zone simply takes the contested keys; documented, deterministic).
|
struct ResolvedCapture {
|
||||||
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).
|
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
//
|
|
||||||
// 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).
|
|
||||||
// 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.
|
|
||||||
struct ResolvedZone {
|
|
||||||
std::string relativePath; // project-relative; the shell resolves + decodes it
|
std::string relativePath; // project-relative; the shell resolves + decodes it
|
||||||
int lowNote = 0;
|
|
||||||
int highNote = 127;
|
|
||||||
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
||||||
double keyTrack = 1.0; // S-VIEW-6 key-tracking scalar, carried from PerformanceZone (1.0 = 100% ET)
|
double keyTrack = 1.0;
|
||||||
VelocityCurve velocityCurve = VelocityCurve::flat(); // S-VIEW-9 velocity->amp curve, carried from PerformanceZone
|
VelocityCurve velocityCurve = VelocityCurve::flat();
|
||||||
SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11)
|
SampleLoop loop; // effective: loopOverride, else bank intrinsic
|
||||||
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11)
|
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0
|
||||||
ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
|
PlaySeconds play; // stored SECONDS; resolved to frames at build
|
||||||
};
|
};
|
||||||
|
|
||||||
// The result of resolving a performance map against the live bank blob. `zones` are the
|
// The ONE override-beats-intrinsic fold, shared by both resolve paths below so they cannot
|
||||||
// zones whose sampleId still resolves to a bank sample, IN MAP ORDER (so overlap-order is
|
// drift.
|
||||||
// preserved). `droppedSampleIds` are the ids that no longer resolve (STALE-ID POLICY: a
|
ResolvedCapture resolveCapture(const SelectedSample& ref, const InstrumentParams& params);
|
||||||
// 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).
|
|
||||||
struct ResolvedPerformance {
|
|
||||||
std::vector<ResolvedZone> zones;
|
|
||||||
std::vector<std::string> droppedSampleIds;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Resolve a performance map against the live "banks" ext-state blob. Pure: shared
|
// Resolve the selection against the live "banks" ext-state blob. Empty/malformed blob, an
|
||||||
// bank_book parse, no host, no PCM. Each zone's sampleId is looked up across every bank
|
// empty selection, or a stale id -> nullopt.
|
||||||
// (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.
|
|
||||||
//
|
//
|
||||||
// NOT the live load path since pS: reloadInstrument resolves via resolvePerformanceFromRefs
|
// NOT the live load path — reloadInstrument resolves via resolveFromRefs (the instance-owned
|
||||||
// (the instance-owned refs). This bank-side resolver is retained as the TESTED REFERENCE
|
// refs). Retained as the TESTED REFERENCE the refs path is verified against (both share
|
||||||
// the refs path is verified against (testResolveFromRefsMatchesBankResolve) — both share
|
// resolveCapture, so the drift test keeps the shared fold honest).
|
||||||
// foldZone, so the drift test is what keeps the shared fold honest.
|
std::optional<ResolvedCapture> resolveFromBank(const std::string& banksJson,
|
||||||
ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
const std::string& selectionId,
|
||||||
const PerformanceMap& map);
|
const InstrumentParams& params);
|
||||||
|
|
||||||
// Resolve a performance map against the INSTANCE-OWNED refs table (pS self-contained
|
// The bank-free mirror, against the INSTANCE-OWNED refs table — shares the same fold, so the
|
||||||
// playback) — the bank-free mirror of resolvePerformance, sharing the same override-
|
// two paths cannot drift. A selection with no ref -> nullopt (the defined no-play).
|
||||||
// beats-intrinsic fold, so the two paths cannot drift. A zone whose sampleId has no ref
|
std::optional<ResolvedCapture> resolveFromRefs(const SampleRefs& refs,
|
||||||
// is dropped + reported (same stale-id shape as the bank path). Pure.
|
const std::string& selectionId,
|
||||||
ResolvedPerformance resolvePerformanceFromRefs(const SampleRefs& refs,
|
const InstrumentParams& params);
|
||||||
const PerformanceMap& map);
|
|
||||||
|
|
||||||
// Build a zoned Keymap from resolved zones + their decoded mono PCM. `decoded[i]` is the
|
// Freshly-decoded PCM under the instance's channel policy, ready for the SampleData build.
|
||||||
// downmixed frames + sample rate for `zones[i]` (same length + order as `zones`). One
|
struct DecodedPcm {
|
||||||
// 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).
|
|
||||||
struct DecodedZonePcm {
|
|
||||||
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
|
std::vector<AudioSample> monoFrames; // channel 0 (mono, or L of a stereo decode)
|
||||||
int sampleRate = 0; // 0 is explicitly invalid; every consumer must
|
int sampleRate = 0; // 0 is explicitly invalid
|
||||||
// receive the WAV's real rate before use.
|
|
||||||
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
|
std::vector<AudioSample> framesR; // channel 1 (R); EMPTY for a mono decode
|
||||||
};
|
};
|
||||||
Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
|
||||||
const std::vector<DecodedZonePcm>& decoded);
|
|
||||||
|
|
||||||
// Apply the S7 cross-mode channel policy (D-E) to freshly-decoded interleaved PCM, yielding
|
// Apply the cross-mode channel policy to freshly-decoded interleaved PCM, yielding the 1- or
|
||||||
// the 1- or 2-channel DecodedZonePcm the keymap build consumes. `interleaved` is the WAV's
|
// 2-channel DecodedPcm the build consumes. `interleaved` is the WAV's float frames (stride =
|
||||||
// float frames (stride = `sourceChannels`); `mode` is the instance's channel mode.
|
// `sourceChannels`); `mode` is the instance's channel mode.
|
||||||
// * MONO mode -> downmix to one channel (the existing policy: average all source
|
// * MONO mode -> downmix to one channel (average all source channels).
|
||||||
// channels). framesR EMPTY. A mono or stereo source both collapse.
|
// * STEREO mode, mono src -> dual-mono: channel 0 duplicated into channel 1 (centered).
|
||||||
// * STEREO mode, mono src -> DUAL-MONO: channel 0 duplicated into channel 1 (centered).
|
// * STEREO mode, stereo+ src -> channels 0 and 1 as-is (no surround fold on >2 channels).
|
||||||
// * STEREO mode, stereo src -> channels 0 and 1 taken as-is (L/R). A source with >2 channels
|
// Empty/zero-channel input -> empty frames (caller plays silence).
|
||||||
// takes channels 0 and 1 (documented; the sampler's stereo image is
|
DecodedPcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
||||||
// the first two channels — no surround fold).
|
|
||||||
// Empty / zero-channel input -> a DecodedZonePcm with empty frames (the caller drops the zone
|
|
||||||
// or plays silence). Pure — the shell does the file I/O and hands the interleaved buffer here.
|
|
||||||
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
|
|
||||||
int sourceChannels, ChannelMode mode, int sampleRate);
|
int sourceChannels, ChannelMode mode, int sampleRate);
|
||||||
|
|
||||||
// The ComponentState envelope + zones-payload binary codec (serializePerformance /
|
// Stitch the resolved parameter set + the decoded PCM into the one SampleData the engine
|
||||||
// serializeComponentState / serializeSelection + the deserializers and every version
|
// plays across the whole keyboard, repitched from the effective root. A second channel is
|
||||||
// constant) lives in component_state_io.h (Q-W2v split, T4-13 ≡ T2-07): the codec grows
|
// carried only when it length-matches channel 0 (SampleData::channelCount() enforces the
|
||||||
// on every envelope bump and is consumed by the EXTENSION's preset-blob path too — the
|
// same rule, so a bad pair never half-plays). Resolves the stored wall-clock SECONDS to
|
||||||
// split lets both artifacts share the codec while only the VST links the voice engine.
|
// frames against the DECODE's actual rate. Empty PCM or a non-positive rate yields an
|
||||||
|
// unplayable SampleData (silence, never a crash).
|
||||||
|
SampleData buildSampleData(const ResolvedCapture& resolved, DecodedPcm decoded);
|
||||||
|
|
||||||
|
// The ComponentState envelope + params-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
|
} // 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"
|
#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.
|
// trigger_seam — converts Trigger fade lengths between the engine domain (TriggerParams:
|
||||||
// NO VST3, NO REAPER, NO SWELL/LICE types at the boundary.
|
// SOURCE FRAMES, anchored to the source-timeline read pointer) and the overlay domain
|
||||||
|
// (AmpEnvelope: FRACTIONS in [0,1] of the played span, so the drawn shape stays invariant
|
||||||
|
// across sample-rate changes). Owns the one shared pack/unpack formula so both directions
|
||||||
|
// stay consistent; reasampler_editor calls these from packEnvelope/unpackEnvelope.
|
||||||
//
|
//
|
||||||
// The TRIGGER SEAM (documented in envelope_overlay.h) converts between the two representations
|
|
||||||
// of Trigger fade lengths:
|
|
||||||
//
|
|
||||||
// ENGINE domain (TriggerParams / sampler_core): SOURCE FRAMES — int64_t absolute frame counts
|
|
||||||
// that anchor directly to the voice's source-timeline read pointer.
|
|
||||||
//
|
|
||||||
// OVERLAY domain (AmpEnvelope / envelope_overlay): FRACTIONS — doubles in [0,1] of the played
|
|
||||||
// span, where the played span is:
|
|
||||||
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
|
// playLengthFrames = round(lengthFraction * (frameCount - startFrame))
|
||||||
// The overlay stores fractions so the drawn shape stays invariant across sample-rate changes;
|
|
||||||
// the engine stores frames so the voice advances correctly at the live rate.
|
|
||||||
//
|
|
||||||
// This module owns the one shared formula so the pack (frames->fractions) and unpack
|
|
||||||
// (fractions->frames) paths are provably consistent and unit-tested independently of the shell.
|
|
||||||
// The shell (reasampler_editor.cpp) calls these two functions from packEnvelope / unpackEnvelope.
|
|
||||||
//
|
|
||||||
// S-VIEW-F2 safety: the fractions produced here are in [0,1] by construction; a caller that
|
|
||||||
// clamps the fractions to [0,1] before writing the AmpEnvelope preserves the slider-range
|
|
||||||
// invariant (a drag can never produce a value a slider couldn't reach).
|
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
@@ -27,25 +12,18 @@
|
|||||||
|
|
||||||
namespace reasampler::instrument::map {
|
namespace reasampler::instrument::map {
|
||||||
|
|
||||||
// The source-frame length of the Trigger played span:
|
// postStart = max(0, frameCount - startFrame); playLength = round(lengthFraction * postStart).
|
||||||
// postStart = max(0, frameCount - startFrame)
|
// `startFrame` is the effective start point (0 when absent). Returns 0 when postStart == 0
|
||||||
// playLength = round(lengthFraction * postStart)
|
// or lengthFraction <= 0.
|
||||||
// `frameCount` is the total decoded sample length in source frames.
|
|
||||||
// `startFrame` is the effective start point (zone.startPoint, or 0 when absent).
|
|
||||||
// `lengthFraction` is TriggerParams::lengthFraction — (0,1], the fraction of the post-start span.
|
|
||||||
// Returns 0 when postStart == 0 or lengthFraction <= 0.
|
|
||||||
std::int64_t triggerPlayLength(double lengthFraction,
|
std::int64_t triggerPlayLength(double lengthFraction,
|
||||||
std::int64_t frameCount,
|
std::int64_t frameCount,
|
||||||
std::int64_t startFrame);
|
std::int64_t startFrame);
|
||||||
|
|
||||||
// Convert a source-frame fade count to a fraction of the play span (PACK direction, draw path).
|
// PACK direction (draw path): frames -> fraction of play span. Not clamped here — the
|
||||||
// Returns 0.0 when playLength == 0 (degenerate sample or zero %-length); the fraction is
|
// caller clamps to [0,1] when filling AmpEnvelope (envelope_edit owns that logic).
|
||||||
// NOT clamped — the caller clamps to [0,1] when filling AmpEnvelope so the overlay clamp logic
|
|
||||||
// stays in envelope_edit, not here.
|
|
||||||
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength);
|
double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength);
|
||||||
|
|
||||||
// Convert a fade fraction to a source-frame count (UNPACK direction, commit path).
|
// UNPACK direction (commit path): fraction -> nearest source frame.
|
||||||
// Rounds to nearest integer frame. Returns 0 when playLength == 0.
|
|
||||||
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength);
|
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength);
|
||||||
|
|
||||||
} // namespace reasampler::instrument::map
|
} // namespace reasampler::instrument::map
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# The geometry vocabulary every instrument UI module speaks (the Rect alias + contains())
|
||||||
|
# is header-only, hence INTERFACE.
|
||||||
|
add_library(editor_geometry INTERFACE)
|
||||||
|
target_include_directories(editor_geometry INTERFACE ${REASAMPLER_SRC_DIR})
|
||||||
|
|
||||||
|
reasampler_pure_library(sample_bands SOURCES sample_bands.cpp LINK PUBLIC editor_geometry)
|
||||||
|
reasampler_test(sample_bands LINK sample_bands)
|
||||||
|
|
||||||
|
reasampler_pure_library(sample_chrome SOURCES sample_chrome.cpp LINK PUBLIC sample_bands)
|
||||||
|
reasampler_test(sample_chrome LINK sample_chrome)
|
||||||
|
|
||||||
|
reasampler_pure_library(embed_strip SOURCES embed_strip.cpp LINK PUBLIC editor_geometry)
|
||||||
|
reasampler_test(embed_strip LINK embed_strip)
|
||||||
|
|
||||||
|
reasampler_pure_library(capture_browser SOURCES capture_browser.cpp LINK PUBLIC editor_geometry)
|
||||||
|
reasampler_test(capture_browser LINK capture_browser)
|
||||||
|
|
||||||
|
reasampler_pure_library(keyboard_strip SOURCES keyboard_strip.cpp LINK PUBLIC editor_geometry)
|
||||||
|
reasampler_test(keyboard_strip LINK keyboard_strip sample_bands sample_chrome)
|
||||||
|
|
||||||
|
# sample_bands is PRIVATE: the lane split is used internally and nothing in the public
|
||||||
|
# header needs it.
|
||||||
|
reasampler_pure_library(waveform_view
|
||||||
|
SOURCES waveform_view.cpp
|
||||||
|
LINK PUBLIC editor_geometry peaks PRIVATE sample_bands)
|
||||||
|
# sample_bands is linked directly here because the test exercises the lane metrics that
|
||||||
|
# waveform_view does not re-export.
|
||||||
|
reasampler_test(waveform_view LINK waveform_view sample_bands)
|
||||||
|
|
||||||
|
reasampler_pure_library(browser_scroll
|
||||||
|
SOURCES browser_scroll.cpp
|
||||||
|
LINK PUBLIC capture_browser sample_chrome)
|
||||||
|
reasampler_test(browser_scroll LINK browser_scroll)
|
||||||
|
|
||||||
|
reasampler_pure_library(param_slider SOURCES param_slider.cpp LINK PUBLIC editor_geometry)
|
||||||
|
reasampler_test(param_slider LINK param_slider)
|
||||||
|
|
||||||
|
reasampler_pure_library(envelope_overlay SOURCES envelope_overlay.cpp LINK PUBLIC editor_geometry)
|
||||||
|
reasampler_test(envelope_overlay LINK envelope_overlay)
|
||||||
|
|
||||||
|
reasampler_pure_library(envelope_edit SOURCES envelope_edit.cpp LINK PUBLIC envelope_overlay)
|
||||||
|
reasampler_test(envelope_edit LINK envelope_edit)
|
||||||
|
|
||||||
|
reasampler_pure_library(knob_deck SOURCES knob_deck.cpp LINK PUBLIC editor_geometry)
|
||||||
|
reasampler_test(knob_deck LINK knob_deck)
|
||||||
|
|
||||||
|
# The deck's group COMPOSITION, split from its layout: knob_deck stays engine-free (see
|
||||||
|
# core/instrument/CLAUDE.md's deck_groups entry for why this module, not knob_deck, reads
|
||||||
|
# PlayMode). velocity_curve is the filter's own curve field; peaks is play_params.h's
|
||||||
|
# AudioSample dependency. play_params.h also drags in filter/'s headers (FilterSettings,
|
||||||
|
# MorphLaw) for the v9 filter tail -- plain value types, no filter symbol linked.
|
||||||
|
reasampler_pure_library(deck_groups
|
||||||
|
SOURCES deck_groups.cpp
|
||||||
|
LINK PUBLIC knob_deck velocity_curve peaks)
|
||||||
|
# sample_bands is linked directly for the test only: the deck-fits-the-floor-window assertion
|
||||||
|
# needs the band allocator deck_groups itself has no reason to depend on.
|
||||||
|
reasampler_test(deck_groups LINK deck_groups sample_bands)
|
||||||
|
|
||||||
|
reasampler_pure_library(curve_popup SOURCES curve_popup.cpp LINK PUBLIC editor_geometry)
|
||||||
|
reasampler_test(curve_popup LINK curve_popup)
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
// browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10
|
// browser_scroll.cpp — see browser_scroll.h. Pure scroll + search geometry; no host types.
|
||||||
// capture_browser. No host types; only the shared Rect + BrowserLayout.
|
|
||||||
|
|
||||||
#include "core/instrument/ui/browser_scroll.h"
|
#include "core/instrument/ui/browser_scroll.h"
|
||||||
|
|
||||||
#include "core/instrument/ui/editor_geometry.h" // kPad / kTitleHeight / kNavButtonWidth (Q-W2v hoist)
|
// The Browse modal is a full-window sheet drawn over the Sample face, so it reuses that
|
||||||
|
// face's chrome metrics rather than minting its own — a divergent title height or pad would
|
||||||
|
// make the sheet visibly not line up with what it covers.
|
||||||
|
#include "core/instrument/ui/sample_bands.h" // kPad / kTitleHeight
|
||||||
|
#include "core/instrument/ui/sample_chrome.h" // kNavButtonWidth (the Back button's slot)
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
@@ -50,11 +53,10 @@ VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int of
|
|||||||
return vr;
|
return vr;
|
||||||
}
|
}
|
||||||
if (offset < 0) offset = 0;
|
if (offset < 0) offset = 0;
|
||||||
// First visible ROW: the topmost row whose bottom edge is below the offset. Floor so a row
|
// First row: floored so a row partially scrolled off the top still draws. Last row:
|
||||||
// partially scrolled off the top still draws (its lower part is visible).
|
// the row containing pixel (offset + gridH - 1), +1 for the exclusive end, so a row
|
||||||
|
// straddling the bottom edge still draws.
|
||||||
const int firstRow = offset / kBrowserCardHeight;
|
const int firstRow = offset / kBrowserCardHeight;
|
||||||
// Last visible ROW: the row containing the pixel (offset + gridH - 1), inclusive; +1 for
|
|
||||||
// the exclusive end. A row straddling the bottom edge still draws.
|
|
||||||
const int lastRow = (offset + gridH - 1) / kBrowserCardHeight + 1;
|
const int lastRow = (offset + gridH - 1) / kBrowserCardHeight + 1;
|
||||||
int first = firstRow * columns;
|
int first = firstRow * columns;
|
||||||
int last = lastRow * columns;
|
int last = lastRow * columns;
|
||||||
@@ -84,13 +86,11 @@ Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) {
|
|||||||
const int trackLeft = trackRight - kScrollbarWidth;
|
const int trackLeft = trackRight - kScrollbarWidth;
|
||||||
const int trackTop = layout.grid.y;
|
const int trackTop = layout.grid.y;
|
||||||
|
|
||||||
// Thumb height proportional to the visible fraction, floored at a grabbable minimum but
|
// Thumb height proportional to the visible fraction, floored/capped to the track.
|
||||||
// never taller than the track.
|
|
||||||
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
|
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
|
||||||
thumbH = (std::max)(kMinThumbHeight, thumbH);
|
thumbH = (std::max)(kMinThumbHeight, thumbH);
|
||||||
thumbH = (std::min)(thumbH, gridH);
|
thumbH = (std::min)(thumbH, gridH);
|
||||||
|
|
||||||
// Thumb top proportional to the offset over the movable track span.
|
|
||||||
const int trackSpan = gridH - thumbH; // >= 0
|
const int trackSpan = gridH - thumbH; // >= 0
|
||||||
int thumbTop = trackTop;
|
int thumbTop = trackTop;
|
||||||
if (maxOff > 0 && trackSpan > 0) {
|
if (maxOff > 0 && trackSpan > 0) {
|
||||||
@@ -106,7 +106,7 @@ int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffse
|
|||||||
const int gridH = (std::max)(0, layout.grid.height);
|
const int gridH = (std::max)(0, layout.grid.height);
|
||||||
if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset);
|
if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset);
|
||||||
|
|
||||||
// Thumb height (same formula as scrollThumbRect) -> movable track span in thumb pixels.
|
// Same thumb-height formula as scrollThumbRect.
|
||||||
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
|
int thumbH = static_cast<int>(static_cast<long long>(gridH) * gridH / content);
|
||||||
thumbH = (std::max)(kMinThumbHeight, thumbH);
|
thumbH = (std::max)(kMinThumbHeight, thumbH);
|
||||||
thumbH = (std::min)(thumbH, gridH);
|
thumbH = (std::min)(thumbH, gridH);
|
||||||
@@ -157,8 +157,6 @@ std::vector<int> filterNameIndices(const std::vector<std::string>& names,
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Browse-modal regions (hoisted from the editor shell, Q-W2v/T2-06 — body verbatim;
|
|
||||||
// the band metrics come from editor_geometry, the search height from searchBoxRect).
|
|
||||||
BrowseModal computeBrowseModal(int w, int h) {
|
BrowseModal computeBrowseModal(int w, int h) {
|
||||||
constexpr int kBrowseFooterH = 30;
|
constexpr int kBrowseFooterH = 30;
|
||||||
BrowseModal m;
|
BrowseModal m;
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
// browser_scroll.h — PURE scroll + type-to-filter geometry LAYERED over the S10
|
// browser_scroll.h — scroll + type-to-filter geometry layered over capture_browser. Mirror
|
||||||
// capture_browser. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror of
|
// of capture_browser/editor_geometry; the shell draws the clipped card window, scrollbar,
|
||||||
// capture_browser / editor_geometry: the fiddly scroll-window + scrollbar-thumb + search-box
|
// and search field, and routes wheel/drag/keystrokes into these functions.
|
||||||
// arithmetic lives here, unit-tested outside the DAW, while the editor shell draws the
|
|
||||||
// clipped card window + the scrollbar + the search field and routes wheel/drag/keystrokes
|
|
||||||
// into these functions.
|
|
||||||
//
|
//
|
||||||
// WHY IT EXISTS (S12). capture_browser (S10) lays out EVERY card top-down and the shell
|
// capture_browser lays out every card top-down and the shell clips at the browser bottom —
|
||||||
// clips at the browser bottom — a bank longer than the panel runs off with no way to reach
|
// a bank longer than the panel has no way to reach the rest. This module adds scroll (a
|
||||||
// it (the S12 gap). This module adds the two things S12 layers over that stable geometry:
|
// vertical pixel offset with max-offset clamp, visible-row window, scrollbar thumb, and
|
||||||
// * SCROLL — a vertical pixel offset into the card grid, with the max-offset clamp, the
|
// thumb-drag<->offset mapping) and search (a case-insensitive name-substring filter that
|
||||||
// visible-row window, a scrollbar thumb rect, and the thumb-drag<->offset mapping so a
|
// composes with capture_browser's bank filter — the shell applies the bank filter first,
|
||||||
// wheel tick or a thumb drag reaches every card; and
|
// then this search narrows within it).
|
||||||
// * SEARCH — a name-substring filter (case-insensitive) that narrows the drawn cards,
|
|
||||||
// COMPOSING with capture_browser's bank filter (the shell applies the bank filter first,
|
|
||||||
// then this search narrows within it) + the search-box rect the shell draws the field in.
|
|
||||||
//
|
//
|
||||||
// It holds NO card data and draws nothing — it knows only the browser layout (from
|
// Holds no card data and draws nothing — knows only the browser layout, counts, and the
|
||||||
// capture_browser), COUNTS, and the scroll OFFSET the shell owns as transient UI state. It
|
// scroll offset the shell owns as transient UI state.
|
||||||
// reuses capture_browser's BrowserLayout + the shared Rect (one geometry idiom).
|
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
@@ -28,96 +21,74 @@
|
|||||||
|
|
||||||
namespace reasampler::instrument::ui {
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
// The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell
|
// Width of the vertical scrollbar gutter at the grid's right edge. When content fits (no
|
||||||
// draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so
|
// scroll needed), scrollThumbRect returns empty and the shell may reclaim the gutter.
|
||||||
// the shell and tests agree. When the content fits (no scroll needed) the scrollbar is
|
|
||||||
// suppressed (scrollThumbRect returns empty) and the shell may reclaim the gutter.
|
|
||||||
inline constexpr int kScrollbarWidth = 10;
|
inline constexpr int kScrollbarWidth = 10;
|
||||||
|
|
||||||
// The height (px) of the type-to-filter search box the shell draws ABOVE the tab strip (a
|
// Height of the type-to-filter search box the shell draws above the tab strip.
|
||||||
// thin band spanning the browser width). Exposed so the shell reserves the band and tests
|
// capture_browser's tab strip + grid sit below this band.
|
||||||
// agree. capture_browser's tab strip + grid sit BELOW this band (the shell offsets the
|
|
||||||
// BrowserLayout it feeds to capture_browser by kSearchBoxHeight).
|
|
||||||
inline constexpr int kSearchBoxHeight = 22;
|
inline constexpr int kSearchBoxHeight = 22;
|
||||||
|
|
||||||
// The total pixel HEIGHT the card grid needs to draw all `cardCount` cards at `layout`'s
|
// Total pixel height the card grid needs for `cardCount` cards at `layout`'s column
|
||||||
// column count: the number of ROWS (ceil(cardCount / columns)) times the fixed cell height.
|
// count: rows (ceil(cardCount / columns)) times the fixed cell height.
|
||||||
// Zero cards -> 0. Pure — the content extent the scroll offset ranges over.
|
|
||||||
int scrollContentHeight(const BrowserLayout& layout, int cardCount);
|
int scrollContentHeight(const BrowserLayout& layout, int cardCount);
|
||||||
|
|
||||||
// The maximum scroll offset (px): content height minus the visible grid height, floored at 0.
|
// Maximum scroll offset: content height minus visible grid height, floored at 0.
|
||||||
// When the content fits within the grid this is 0 (nothing to scroll). Pure — the clamp
|
|
||||||
// ceiling for every offset the shell tracks.
|
|
||||||
int scrollMaxOffset(const BrowserLayout& layout, int cardCount);
|
int scrollMaxOffset(const BrowserLayout& layout, int cardCount);
|
||||||
|
|
||||||
// Clamp a proposed scroll offset into [0, scrollMaxOffset]. The shell clamps after every wheel
|
// Clamps a proposed scroll offset into [0, scrollMaxOffset].
|
||||||
// tick / thumb drag so an over-scroll pins to an edge rather than showing past the last card
|
|
||||||
// or above the first. Pure.
|
|
||||||
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset);
|
int clampScrollOffset(const BrowserLayout& layout, int cardCount, int proposedOffset);
|
||||||
|
|
||||||
// The half-open range of card INDICES [first, last) at least partially visible in the grid at
|
// Half-open range of card indices [first, last) at least partially visible at scroll
|
||||||
// scroll `offset`. The shell draws only these cards (the S12 clip window) rather than every
|
// `offset` (assumed pre-clamped). The shell draws only these cards.
|
||||||
// card. `offset` is assumed pre-clamped (the shell clamps on input); a first past the last row
|
|
||||||
// yields an empty range (first==last==cardCount). Pure.
|
|
||||||
struct VisibleRange {
|
struct VisibleRange {
|
||||||
int first = 0; // first card index drawn (inclusive)
|
int first = 0;
|
||||||
int last = 0; // one past the last card index drawn (exclusive)
|
int last = 0;
|
||||||
};
|
};
|
||||||
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset);
|
VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int offset);
|
||||||
|
|
||||||
// The cell rect of card `index` SHIFTED UP by the scroll offset, ready to draw (the shell
|
// Cell rect of card `index` shifted up by the scroll offset (the shell still adds the
|
||||||
// still adds the browser sub-area origin). Equivalent to capture_browser::cardCellRect with
|
// browser sub-area origin).
|
||||||
// the offset subtracted from top/bottom. Pure — the one place the offset applies to a card.
|
|
||||||
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset);
|
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset);
|
||||||
|
|
||||||
// The vertical scrollbar THUMB rect within the grid's right-edge gutter, sized proportional to
|
// Vertical scrollbar thumb rect within the grid's right-edge gutter, sized proportional
|
||||||
// the visible fraction (grid height / content height) and positioned proportional to the
|
// to the visible fraction and positioned proportional to the scroll offset. Empty when
|
||||||
// scroll offset. Returns an EMPTY rect when the content fits (no scroll needed) — the shell
|
// the content fits. A minimum thumb height keeps a tiny thumb grabbable on a long bank.
|
||||||
// suppresses the scrollbar then. A minimum thumb height keeps a tiny thumb grabbable on a very
|
|
||||||
// long bank. Pure — the geometry the shell draws + hit-tests the thumb grab against.
|
|
||||||
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset);
|
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset);
|
||||||
|
|
||||||
// Map a thumb-drag to a scroll offset. Given the offset the thumb held at grab time
|
// Maps a thumb-drag to a new (clamped) scroll offset: `startOffset` shifted by the pixel
|
||||||
// (`startOffset`) and the vertical pixel delta since grab (`dyPixels`), returns the new
|
// delta scaled from thumb-track pixels to content pixels. A degenerate track or fitting
|
||||||
// (clamped) scroll offset: startOffset shifted by the delta scaled from thumb-track pixels to
|
// content pins to startOffset.
|
||||||
// content pixels (a 1px thumb move covers content/track px of content). A degenerate track /
|
|
||||||
// fitting content pins to startOffset. Pure — the inverse of scrollThumbRect's position map.
|
|
||||||
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels);
|
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset, int dyPixels);
|
||||||
|
|
||||||
// The search-box rect: a full-width band of height kSearchBoxHeight at the TOP of the browser
|
// Search-box rect: full-width band of height kSearchBoxHeight at the top of the browser
|
||||||
// area (above where capture_browser's tab strip draws). `w` is the browser sub-area width;
|
// area. `w` is the browser sub-area width; the shell adds its origin.
|
||||||
// the shell adds its origin. A zero/negative width yields an empty rect. Pure.
|
|
||||||
Rect searchBoxRect(int w);
|
Rect searchBoxRect(int w);
|
||||||
|
|
||||||
// True iff `name` contains `query` as a case-insensitive ASCII substring. An EMPTY query
|
// True iff `name` contains `query` as a case-insensitive ASCII substring. An empty query
|
||||||
// matches everything (the no-filter identity). Matching is ASCII case-folded (the display
|
// matches everything.
|
||||||
// names are ASCII until the Phase L type kit lands, mirroring the editor's other ASCII-only
|
|
||||||
// text). Pure — the single match predicate the shell's search narrow is built from.
|
|
||||||
bool nameMatchesQuery(const std::string& name, const std::string& query);
|
bool nameMatchesQuery(const std::string& name, const std::string& query);
|
||||||
|
|
||||||
// Narrow a list of display `names` to the INDICES whose name matches `query`, preserving
|
// Narrows a list of display `names` to the indices whose name matches `query`, preserving
|
||||||
// order. An EMPTY query returns every index [0, names.size()) (the composition base so "bank
|
// order. An empty query returns every index. Kept name-only (indices, not card structs)
|
||||||
// filter, no search" == today's browser). Kept name-only (indices, not card structs) so this
|
// so this module stays free of the sample_map/bank_book chain — the shell applies the
|
||||||
// module stays free of the sample_map/bank_book chain — the shell owns the SampleChoice list
|
// bank filter first, then feeds the surviving display names here.
|
||||||
// and applies the bank filter FIRST, then feeds the surviving display names here (search
|
|
||||||
// narrows within the bank). Pure.
|
|
||||||
std::vector<int> filterNameIndices(const std::vector<std::string>& names,
|
std::vector<int> filterNameIndices(const std::vector<std::string>& names,
|
||||||
const std::string& query);
|
const std::string& query);
|
||||||
|
|
||||||
// --- The Browse-modal (S-VIEW-5) top-level regions (Q-W2v hoist, T2-06) -------
|
// --- Browse-modal top-level regions --------------------------------------------
|
||||||
//
|
//
|
||||||
// A title band with a Back button, the search box, the browser sub-area (tabs + card
|
// A title band with a Back button, the search box, the browser sub-area (tabs + card
|
||||||
// grid — layoutBrowser's origin), and a footer with Cancel / Load-confirm. The picker
|
// grid — layoutBrowser's origin), and a footer with Cancel / Load-confirm. The picker
|
||||||
// covers the full window (F3: full-window overlay). Draw + hit-test both derive from
|
// covers the full window. Homed here (not editor_geometry) because the search-box
|
||||||
// this single layout so they never drift. Homed here (not editor_geometry) because the
|
// height feeds it.
|
||||||
// search-box height feeds it — browser_scroll already owns the search/scroll geometry.
|
|
||||||
struct BrowseModal {
|
struct BrowseModal {
|
||||||
Rect title;
|
Rect title;
|
||||||
Rect back; // the "Back" title-band button
|
Rect back;
|
||||||
Rect search; // the type-to-filter box (absolute)
|
Rect search;
|
||||||
Rect content; // the browser sub-area (tabs + grid) — layoutBrowser's origin
|
Rect content; // browser sub-area (tabs + grid) — layoutBrowser's origin
|
||||||
Rect cancel; // footer Cancel
|
Rect cancel;
|
||||||
Rect confirm; // footer Load (confirm)
|
Rect confirm;
|
||||||
};
|
};
|
||||||
BrowseModal computeBrowseModal(int w, int h);
|
BrowseModal computeBrowseModal(int w, int h);
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ namespace reasampler::instrument::ui {
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
// The left edge of tab i in a strip of the given x-origin and width divided into `count`
|
// Left edge of tab i in a strip divided into `count` equal segments (mirror of
|
||||||
// equal segments (mirror of mode_switch::segmentEdge). Every boundary derives from the same
|
// mode_switch::segmentEdge). Same formula for every boundary so consecutive tabs share
|
||||||
// formula, so consecutive tabs share an exact edge and the last tab reaches x+width exactly.
|
// an exact edge.
|
||||||
int tabEdge(int x, int width, int i, int count) {
|
int tabEdge(int x, int width, int i, int count) {
|
||||||
return x + (i * width) / count;
|
return x + (i * width) / count;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,92 +1,67 @@
|
|||||||
// capture_browser.h — PURE layout + hit-test for the S10 capture-first editor's default
|
// capture_browser.h — layout + hit-test for the capture-first editor's default face: a
|
||||||
// face: a scannable grid of capture CARDS with a bank-FILTER tab strip above it. NO VST3,
|
// scannable grid of capture cards with a bank-filter tab strip above it. Mirror of
|
||||||
// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry /
|
// editor_geometry/embed_strip/mode_switch; the shell draws thumbnails/names/badges and
|
||||||
// embed_strip / mode_switch: the fiddly card-grid + tab arithmetic lives here so it is
|
// routes clicks into these functions.
|
||||||
// unit-tested outside the DAW, while the editor shell draws each card's peak thumbnail +
|
|
||||||
// name + root/key badge and routes clicks into these functions.
|
|
||||||
//
|
//
|
||||||
// The browser replaces the old text item-list (the named anti-pattern). It lays out N
|
// This module knows only counts and rects — it draws nothing and holds no sample data;
|
||||||
// cards in a fixed-cell grid that wraps across the browser width, and a horizontal tab
|
// the shell owns the SampleChoice list, peak envelopes, and filter state.
|
||||||
// strip of bank filters (one tab per bank_book bank + an "All" tab) above the grid. This
|
|
||||||
// module knows only COUNTS and RECTS — it draws nothing and holds no sample data; the
|
|
||||||
// shell owns the SampleChoice list, the peak envelopes, and the filter state, and asks this
|
|
||||||
// module only "where does card i draw" / "what did the user click".
|
|
||||||
//
|
//
|
||||||
// Scroll is NOT here (S12 layers it over this module). The browser lays out every card
|
// Scroll is layered on top by browser_scroll — this module lays out every card top-down
|
||||||
// top-down; the shell clips at the browser's bottom until S12 adds a scroll offset. Keeping
|
// and the shell clips at the bottom until a scroll offset is applied.
|
||||||
// scroll out keeps this module the stable card/tab geometry S12 builds on.
|
|
||||||
//
|
|
||||||
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom).
|
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
|
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
|
||||||
|
|
||||||
namespace reasampler::instrument::ui {
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
// Fixed browser metrics, exposed so the shell and tests agree. The card is sized to show a
|
// Fixed browser metrics, exposed so the shell and tests agree.
|
||||||
// peak thumbnail with a name + badge line under it — scannable by eye, not a dense list.
|
inline constexpr int kBrowserTabHeight = 26;
|
||||||
inline constexpr int kBrowserTabHeight = 26; // the bank-filter tab strip band height
|
inline constexpr int kBrowserCardWidth = 132;
|
||||||
inline constexpr int kBrowserCardWidth = 132; // one card cell width (incl. gutter)
|
inline constexpr int kBrowserCardHeight = 84;
|
||||||
inline constexpr int kBrowserCardHeight = 84; // one card cell height (incl. gutter)
|
inline constexpr int kBrowserCardGutter = 8;
|
||||||
inline constexpr int kBrowserCardGutter = 8; // inset between the cell edge and the card
|
inline constexpr int kBrowserThumbHeight = 44;
|
||||||
inline constexpr int kBrowserThumbHeight = 44; // the peak-thumbnail band inside a card
|
|
||||||
|
|
||||||
// The browser's regions, derived from the (w x h) area the shell allots it. Both clamp to
|
// Clamped so a degenerate (tiny/zero) size never yields an inverted rect.
|
||||||
// the area so a degenerate (tiny/zero) size never yields an inverted rect.
|
|
||||||
struct BrowserLayout {
|
struct BrowserLayout {
|
||||||
Rect tabStrip; // top: the bank-filter tabs
|
Rect tabStrip;
|
||||||
Rect grid; // below the tabs: where the capture cards tile
|
Rect grid;
|
||||||
int columns = 1; // cards per row in `grid` (>= 1); derived from grid.width
|
int columns = 1; // cards per row in `grid` (>= 1)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Divide a (w x h) browser area into its regions and compute the column count. Pure: same
|
// Divide a (w x h) browser area into its regions and compute the column count. columns =
|
||||||
// inputs -> same layout. The tab strip takes a fixed height at the top (clamped so it never
|
// max(1, grid.width/cardWidth) so a browser narrower than one card still lays out a
|
||||||
// exceeds the area); the grid takes the rest. columns = max(1, grid.width/cardWidth) so a
|
// single column.
|
||||||
// browser narrower than one card still lays out a single column. A zero/negative size
|
|
||||||
// yields empty rects + columns==1.
|
|
||||||
BrowserLayout layoutBrowser(int w, int h);
|
BrowserLayout layoutBrowser(int w, int h);
|
||||||
|
|
||||||
// The cell rect of capture card `index` (0-based) in the grid, laid out left-to-right then
|
// Cell rect of capture card `index` (0-based), left-to-right then top-to-bottom across
|
||||||
// top-to-bottom across `columns`. This is the full CELL (card + gutter); cardContentRect
|
// `columns`. This is the full cell (card + gutter); cardContentRect insets it.
|
||||||
// insets it to the drawable card. Rows past the visible grid are still computed (the shell
|
|
||||||
// clips at paint time). A negative index yields an empty rect. Pure.
|
|
||||||
Rect cardCellRect(const BrowserLayout& layout, int index);
|
Rect cardCellRect(const BrowserLayout& layout, int index);
|
||||||
|
|
||||||
// The drawable card rect inside a cell: the cell inset by kBrowserCardGutter on all sides.
|
// Drawable card rect inside a cell: the cell inset by kBrowserCardGutter on all sides.
|
||||||
// The shell fills this (background + border) and draws the thumbnail/name/badge inside it. Pure.
|
|
||||||
Rect cardContentRect(const BrowserLayout& layout, int index);
|
Rect cardContentRect(const BrowserLayout& layout, int index);
|
||||||
|
|
||||||
// The peak-thumbnail sub-rect at the top of a card's content: full card width, the top
|
// Peak-thumbnail sub-rect at the top of a card's content: full card width, the top
|
||||||
// kBrowserThumbHeight (clamped to the card height). The shell draws the envelope here; the
|
// kBrowserThumbHeight (clamped to the card height).
|
||||||
// name + badge go in the remaining strip below. Pure.
|
|
||||||
Rect cardThumbnailRect(const BrowserLayout& layout, int index);
|
Rect cardThumbnailRect(const BrowserLayout& layout, int index);
|
||||||
|
|
||||||
// The name/badge sub-rect below the thumbnail: the card content minus the thumbnail band.
|
// Name/badge sub-rect below the thumbnail.
|
||||||
// The shell draws the display name + root/key badge here. Pure.
|
|
||||||
Rect cardLabelRect(const BrowserLayout& layout, int index);
|
Rect cardLabelRect(const BrowserLayout& layout, int index);
|
||||||
|
|
||||||
// The card a click at (x, y) lands on, given `cardCount` cards, or -1 for a click outside
|
// Card a click at (x, y) lands on, given `cardCount` cards, or -1 for a miss. Only the
|
||||||
// every card (in a gutter, past the last card, or on the tab strip). Only the card CONTENT
|
// card content rect counts as a hit — a click in the inter-card gutter is a miss.
|
||||||
// rect counts as a hit — a click in the inter-card gutter is a miss. Pure.
|
|
||||||
int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y);
|
int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y);
|
||||||
|
|
||||||
// --- Bank-filter tabs --------------------------------------------------------
|
// --- Bank-filter tabs ---------------------------------------------------------
|
||||||
//
|
//
|
||||||
// The tab strip divides tabStrip into `tabCount` equal segments (mirror of mode_switch):
|
// Divides tabStrip into `tabCount` equal segments: one tab per bank plus a leading "All"
|
||||||
// one tab per bank_book bank plus a leading "All" tab the shell prepends, so tabCount ==
|
// tab the shell prepends. This module only divides the strip + hit-tests; the shell
|
||||||
// bankCount + 1 in practice. This module only divides the strip + hit-tests; the shell
|
// supplies labels and tracks the active tab.
|
||||||
// supplies the labels and tracks which tab is active. A tab click narrows the card list to
|
|
||||||
// that bank (the shell filters its SampleChoice list before laying out cards).
|
|
||||||
|
|
||||||
// The rect of tab `index` (0-based) when the strip is divided into `tabCount` equal
|
// Rect of tab `index` when the strip is divided into `tabCount` equal segments. The last
|
||||||
// segments. The last tab absorbs any width remainder so the tabs tile the whole strip with
|
// tab absorbs any width remainder so the tabs tile the whole strip with no gap.
|
||||||
// no gap (mirror of mode_switch's segment split). A negative index or tabCount<=0 yields an
|
|
||||||
// empty rect. Pure.
|
|
||||||
Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index);
|
Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index);
|
||||||
|
|
||||||
// The tab a click at (x, y) lands on, given `tabCount` tabs, or -1 for a click outside the
|
|
||||||
// tab strip. Pure.
|
|
||||||
int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y);
|
int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y);
|
||||||
|
|
||||||
} // namespace reasampler::instrument::ui
|
} // namespace reasampler::instrument::ui
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
// curve_popup.h — PURE sheet geometry + dismissal test for the r11 velocity-curve popup
|
// curve_popup.h — sheet geometry + dismissal test for the velocity-curve popup editor.
|
||||||
// editor (Wave B, FB1). NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
|
// Mirror of overflow_menu; the shell draws through the L1 kit and routes clicks via
|
||||||
// of overflow_menu: the size-clamp / centering / title-row arithmetic lives here, unit-tested
|
// these rects.
|
||||||
// at the clamps outside the DAW, while the editor shell draws the wash + sheet through the
|
|
||||||
// L1 kit and routes clicks (close / curve box / outside-sheet dismiss) via these rects.
|
|
||||||
//
|
//
|
||||||
// THE POPUP (CONTEXT.md §S-VIEW r11). Summoned by the mini curve-preview button, a CENTERED
|
// A centered sheet over the Sample face (a lighter wash than Browse's, since this is a
|
||||||
// SHEET over the Sample face (a 0.50-alpha bg/base wash behind it — lighter than Browse's
|
// focused sub-editor, not a view change): width/height each clamp to a fraction of the
|
||||||
// 0.82; a focused sub-editor, not a view change): width clamp(60% of window, 360..520),
|
// window within min/max bounds. A title row sits over the curve box. The curve box rect
|
||||||
// height clamp(55% of window, 260..380). Inside: a ~22px title row ("VELOCITY -> AMP"
|
// here is the border rect — the shell derives the mapping box via its curveBoxFromRect
|
||||||
// micro-caps left, an 18x18 Close button right) over the full-size curve box filling the
|
// formula.
|
||||||
// remainder. The curve box rect here is the BORDER rect — the shell derives the mapping box
|
|
||||||
// through its ONE curveBoxFromRect formula (the landed inset grammar), so the popup editor
|
|
||||||
// and the Zone-panel inline editor share coordinates by construction.
|
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
@@ -19,7 +14,7 @@
|
|||||||
|
|
||||||
namespace reasampler::instrument::ui {
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
// Fixed popup metrics (spec r11), exposed so the shell and tests agree.
|
// Fixed popup metrics, exposed so the shell and tests agree.
|
||||||
inline constexpr int kCurvePopupMinW = 360;
|
inline constexpr int kCurvePopupMinW = 360;
|
||||||
inline constexpr int kCurvePopupMaxW = 520;
|
inline constexpr int kCurvePopupMaxW = 520;
|
||||||
inline constexpr int kCurvePopupMinH = 260;
|
inline constexpr int kCurvePopupMinH = 260;
|
||||||
@@ -29,20 +24,19 @@ inline constexpr int kCurvePopupCloseSize = 18;
|
|||||||
inline constexpr int kCurvePopupPad = 8; // sheet inner padding (title inset + box margins)
|
inline constexpr int kCurvePopupPad = 8; // sheet inner padding (title inset + box margins)
|
||||||
|
|
||||||
struct CurvePopupLayout {
|
struct CurvePopupLayout {
|
||||||
Rect sheet; // the bg/panel sheet, centered in the window
|
Rect sheet;
|
||||||
Rect title; // the caption text rect (left part of the title row)
|
Rect title;
|
||||||
Rect close; // the 18x18 Close (x) button, right-anchored in the title row
|
Rect close; // Close (x) button, right-anchored in the title row
|
||||||
Rect curveBox; // the full-size curve editor BORDER rect (shell insets via curveBoxFromRect)
|
Rect curveBox; // full-size curve editor border rect (shell insets via curveBoxFromRect)
|
||||||
};
|
};
|
||||||
|
|
||||||
// The popup geometry for a (w x h) window: sheet width clamp(60% w, 360..520) and height
|
// Popup geometry for a (w x h) window: sheet width clamp(60% w, min..max) and height
|
||||||
// clamp(55% h, 260..380) — each additionally capped at the window dimension so a degenerate
|
// clamp(55% h, min..max), each additionally capped at the window dimension so a
|
||||||
// window never yields an overhanging sheet — centered; title row + close button at the top;
|
// degenerate window never yields an overhanging sheet — centered.
|
||||||
// the curve box filling the remainder inside kCurvePopupPad margins. Pure.
|
|
||||||
CurvePopupLayout computeCurvePopup(int w, int h);
|
CurvePopupLayout computeCurvePopup(int w, int h);
|
||||||
|
|
||||||
// True when (x, y) lands OUTSIDE the sheet (on the wash) — the click-outside dismissal test.
|
// True when (x, y) lands outside the sheet (on the wash) — the click-outside dismissal
|
||||||
// The shell additionally gates on "no drag in flight" (spec). Pure.
|
// test. The shell additionally gates on "no drag in flight".
|
||||||
bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y);
|
bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y);
|
||||||
|
|
||||||
} // namespace reasampler::instrument::ui
|
} // namespace reasampler::instrument::ui
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// deck_groups.cpp — see deck_groups.h. Pure data; no host types.
|
||||||
|
|
||||||
|
#include "core/instrument/ui/deck_groups.h"
|
||||||
|
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
int id(DeckParam p) { return static_cast<int>(p); }
|
||||||
|
double clamp(double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
double deckBipolarFromNorm(double norm) { return clamp(norm, 0.0, 1.0) * 2.0 - 1.0; }
|
||||||
|
double deckNormFromBipolar(double value) { return clamp(value, -1.0, 1.0) * 0.5 + 0.5; }
|
||||||
|
|
||||||
|
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode) {
|
||||||
|
std::vector<DeckGroupDesc> out;
|
||||||
|
{
|
||||||
|
DeckGroupDesc pitch;
|
||||||
|
pitch.id = kGroupPitch;
|
||||||
|
pitch.captionWidth = 38;
|
||||||
|
pitch.captionToggle = {id(DeckParam::kPitchEngine), 48};
|
||||||
|
pitch.cellIds = {id(DeckParam::kKeyTrack)};
|
||||||
|
out.push_back(std::move(pitch));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
DeckGroupDesc penv;
|
||||||
|
penv.id = kGroupPitchEnv;
|
||||||
|
penv.captionWidth = 58;
|
||||||
|
penv.captionToggle = {id(DeckParam::kPitchEnvEnable), 32};
|
||||||
|
penv.cellIds = {id(DeckParam::kPitchEnvAttack),
|
||||||
|
id(DeckParam::kPitchEnvDecay),
|
||||||
|
id(DeckParam::kPitchEnvDepth)};
|
||||||
|
out.push_back(std::move(penv));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Tone shaping left-to-right, then the three modulation depths that all target cutoff.
|
||||||
|
DeckGroupDesc filter;
|
||||||
|
filter.id = kGroupFilter;
|
||||||
|
filter.captionWidth = 46;
|
||||||
|
filter.captionToggle = {id(DeckParam::kFilterEnable), 32};
|
||||||
|
filter.cellIds = {id(DeckParam::kFilterMorph),
|
||||||
|
id(DeckParam::kFilterCutoff),
|
||||||
|
id(DeckParam::kFilterQ),
|
||||||
|
id(DeckParam::kFilterDrive),
|
||||||
|
id(DeckParam::kFilterModAmt),
|
||||||
|
id(DeckParam::kFilterVel),
|
||||||
|
id(DeckParam::kFilterKeyTrack)};
|
||||||
|
filter.rowToggle = {id(DeckParam::kFilterLaw), 44};
|
||||||
|
out.push_back(std::move(filter));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
DeckGroupDesc fenv;
|
||||||
|
fenv.id = kGroupFilterEnv;
|
||||||
|
fenv.captionWidth = 66;
|
||||||
|
fenv.cellIds = {id(DeckParam::kFilterEnvAttack),
|
||||||
|
id(DeckParam::kFilterEnvHold),
|
||||||
|
id(DeckParam::kFilterEnvDecay),
|
||||||
|
id(DeckParam::kFilterEnvSustain),
|
||||||
|
id(DeckParam::kFilterEnvRelease)};
|
||||||
|
out.push_back(std::move(fenv));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
DeckGroupDesc amp;
|
||||||
|
amp.id = kGroupAmpEnv;
|
||||||
|
amp.captionWidth = 78;
|
||||||
|
amp.captionToggle = {id(DeckParam::kPlayMode), 44};
|
||||||
|
if (playMode == PlayMode::Gate) {
|
||||||
|
amp.cellIds = {id(DeckParam::kAttack), id(DeckParam::kHold),
|
||||||
|
id(DeckParam::kDecay), id(DeckParam::kSustain),
|
||||||
|
id(DeckParam::kRelease)};
|
||||||
|
} else {
|
||||||
|
// Trigger, time-ordered left-to-right (Fade In / Length % / Fade Out — matches
|
||||||
|
// the drawn envelope), plus two blanks (see knob_deck.h's blank-cell contract).
|
||||||
|
amp.cellIds = {id(DeckParam::kTrigFadeIn), id(DeckParam::kTrigLength),
|
||||||
|
id(DeckParam::kTrigFadeOut), -1, -1};
|
||||||
|
}
|
||||||
|
out.push_back(std::move(amp));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
DeckGroupDesc voice;
|
||||||
|
voice.id = kGroupVoice;
|
||||||
|
voice.captionWidth = 38;
|
||||||
|
voice.captionToggle = {id(DeckParam::kVoiceMode), 40};
|
||||||
|
voice.cellIds = {id(DeckParam::kVoiceCount)};
|
||||||
|
voice.rowToggle = {id(DeckParam::kMonoTrigger), 44};
|
||||||
|
out.push_back(std::move(voice));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
DeckGroupDesc master;
|
||||||
|
master.id = kGroupMaster;
|
||||||
|
master.captionWidth = 46;
|
||||||
|
master.cellIds = {id(DeckParam::kMasterGain)};
|
||||||
|
out.push_back(std::move(master));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::ui
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// deck_groups.h — WHICH groups the Sample face's knob deck carries and in what order, plus
|
||||||
|
// the control-id space they are built from. Pure data: knob_deck lays out whatever descriptors
|
||||||
|
// it is handed, and this module decides what those descriptors are, so the deck's signal-flow
|
||||||
|
// ordering is provable without a host.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/instrument/engine/play_params.h" // PlayMode (the AMP group's Gate/Trigger face)
|
||||||
|
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc
|
||||||
|
|
||||||
|
namespace reasampler::instrument::ui {
|
||||||
|
|
||||||
|
// Deck control ids. Opaque to knob_deck, resolved by the shell's hit-test and value binding.
|
||||||
|
// Runtime-only — nothing persists them, so the ordering here is free to change.
|
||||||
|
enum class DeckParam {
|
||||||
|
kPlayMode = 0, // Gate | Trigger toggle
|
||||||
|
kPitchEngine, // Varispeed | Preserve toggle
|
||||||
|
kAttack, // AHDSR attack (Gate) / —
|
||||||
|
kHold, // AHDSR hold (Gate)
|
||||||
|
kDecay, // AHDSR decay (Gate)
|
||||||
|
kSustain, // AHDSR sustain (Gate)
|
||||||
|
kRelease, // AHDSR release (Gate)
|
||||||
|
kTrigLength, // Trigger %-length
|
||||||
|
kTrigFadeIn, // Trigger fade-in
|
||||||
|
kTrigFadeOut, // Trigger fade-out
|
||||||
|
kPitchEnvEnable, // AD pitch envelope on|off
|
||||||
|
kPitchEnvAttack, // AD pitch attack
|
||||||
|
kPitchEnvDecay, // AD pitch decay
|
||||||
|
kPitchEnvDepth, // AD pitch depth in +/- semitones
|
||||||
|
kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds)
|
||||||
|
// Filter. The four control positions map through filter_params' own laws; the three
|
||||||
|
// depths are bipolar and centred at zero.
|
||||||
|
kFilterEnable, // filter on|off caption toggle
|
||||||
|
kFilterMorph, // morph position: high-pass .. low-pass
|
||||||
|
kFilterCutoff, // cutoff, log across the audio band
|
||||||
|
kFilterQ, // resonance
|
||||||
|
kFilterDrive, // in-loop drive depth
|
||||||
|
kFilterModAmt, // filter envelope -> cutoff, +/-100%
|
||||||
|
kFilterVel, // velocity -> cutoff, +/-100%
|
||||||
|
kFilterKeyTrack, // note -> cutoff, 0..200%
|
||||||
|
kFilterLaw, // morph law row toggle: HP-BP-LP | HP-notch-LP
|
||||||
|
kFilterEnvAttack,
|
||||||
|
kFilterEnvHold,
|
||||||
|
kFilterEnvDecay,
|
||||||
|
kFilterEnvSustain,
|
||||||
|
kFilterEnvRelease,
|
||||||
|
// Deck-only controls: processor-side per-instance params — routed to the processor
|
||||||
|
// setters, never through the parameter set.
|
||||||
|
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
|
||||||
|
kVoiceMode, // Poly | Mono caption toggle (VOICE group)
|
||||||
|
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
|
||||||
|
kMasterGain, // post-mixer master gain knob (-inf..+24 dB taper, MASTER group)
|
||||||
|
kCount
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deck group ids. Unscoped so the shell's caption switch reads against the plain `id` int
|
||||||
|
// knob_deck carries.
|
||||||
|
enum DeckGroupId {
|
||||||
|
kGroupPitch = 0,
|
||||||
|
kGroupPitchEnv,
|
||||||
|
kGroupFilter,
|
||||||
|
kGroupFilterEnv,
|
||||||
|
kGroupAmpEnv,
|
||||||
|
kGroupVoice,
|
||||||
|
kGroupMaster,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The deck's groups, left to right, in SIGNAL-FLOW order: pitch -> filter -> amp, then the
|
||||||
|
// two instance-wide groups. `playMode` picks the AMP group's face, via knob_deck's blank-cell
|
||||||
|
// reservation (knob_deck.h) so a mode flip never reflows the neighbouring groups.
|
||||||
|
std::vector<DeckGroupDesc> sampleDeckGroups(PlayMode playMode);
|
||||||
|
|
||||||
|
// The deck's BIPOLAR knob law: 0.5 of the knob's travel is zero depth, the ends are -1 and
|
||||||
|
// +1. Exact inverses, and exact at the centre detent (0.5 -> 0 -> 0.5), so a knob parked at
|
||||||
|
// centre can never persist a hair of modulation. Out-of-range norm clamps to the endpoints.
|
||||||
|
double deckBipolarFromNorm(double norm);
|
||||||
|
double deckNormFromBipolar(double value);
|
||||||
|
|
||||||
|
} // namespace reasampler::instrument::ui
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
// editor_geometry.cpp — see editor_geometry.h. Pure math; no host types.
|
|
||||||
|
|
||||||
#include "core/instrument/ui/editor_geometry.h"
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
|
|
||||||
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;
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
const int bx = out.canvas.x + kButtonMargin;
|
|
||||||
const int by = out.canvas.y + kButtonMargin;
|
|
||||||
const int bRight = std::min(bx + kButtonWidth, out.canvas.right());
|
|
||||||
const int bBottom = std::min(by + kButtonHeight, out.canvas.bottom());
|
|
||||||
out.button = Rect::ltrb(bx, by, std::max(bx, bRight), std::max(by, bBottom));
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
HitTarget hitTest(const EditorLayout& layout, int x, int y) {
|
|
||||||
if (contains(layout.button, x, y)) return HitTarget::kButton;
|
|
||||||
return HitTarget::kNone;
|
|
||||||
}
|
|
||||||
|
|
||||||
Rect sampleRowRect(const EditorLayout& layout, int index) {
|
|
||||||
if (index < 0) return Rect{};
|
|
||||||
const int top = layout.canvas.y + index * kSampleRowHeight;
|
|
||||||
return Rect::ltrb(layout.canvas.x, top, layout.canvas.right(), top + kSampleRowHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) {
|
|
||||||
if (rowCount <= 0) return -1;
|
|
||||||
// Must be within the canvas horizontally and at/below its top.
|
|
||||||
if (x < layout.canvas.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Keymap editor -----------------------------------------------------------
|
|
||||||
|
|
||||||
KeymapEditorLayout layoutKeymapEditor(int w, int h) {
|
|
||||||
KeymapEditorLayout out;
|
|
||||||
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);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
Rect keymapSampleRowRect(const KeymapEditorLayout& layout, int index) {
|
|
||||||
if (index < 0) return Rect{};
|
|
||||||
const int top = layout.sampleList.y + index * kSampleRowHeight;
|
|
||||||
return Rect::ltrb(layout.sampleList.x, top, layout.sampleList.right(),
|
|
||||||
top + kSampleRowHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
int keymapSampleRowHitTest(const KeymapEditorLayout& layout, int rowCount, int x, int y) {
|
|
||||||
if (rowCount <= 0) return -1;
|
|
||||||
const Rect& list = layout.sampleList;
|
|
||||||
if (x < list.x || x >= list.right()) return -1;
|
|
||||||
if (y < list.y || y >= list.bottom()) return -1;
|
|
||||||
const int index = (y - list.y) / kSampleRowHeight;
|
|
||||||
if (index < 0 || index >= rowCount) return -1;
|
|
||||||
const Rect r = keymapSampleRowRect(layout, index);
|
|
||||||
if (y >= r.bottom()) return -1;
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
|
|
||||||
Rect zoneRowRect(const KeymapEditorLayout& layout, int index) {
|
|
||||||
if (index < 0) return Rect{};
|
|
||||||
const int top = layout.zoneRowArea.y + index * kZoneRowHeight;
|
|
||||||
return Rect::ltrb(layout.zoneRowArea.x, top, layout.zoneRowArea.right(),
|
|
||||||
top + kZoneRowHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int y) {
|
|
||||||
if (zoneCount <= 0) return ZoneHit{};
|
|
||||||
const Rect& area = layout.zoneRowArea;
|
|
||||||
if (x < area.x || x >= area.right()) return ZoneHit{};
|
|
||||||
if (y < area.y || y >= area.bottom()) return ZoneHit{};
|
|
||||||
const int index = (y - area.y) / kZoneRowHeight;
|
|
||||||
if (index < 0 || index >= zoneCount) return ZoneHit{};
|
|
||||||
const Rect row = zoneRowRect(layout, index);
|
|
||||||
if (y >= row.bottom()) return ZoneHit{};
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
const ZoneField fields[7] = {
|
|
||||||
ZoneField::kLowDown, ZoneField::kLowUp, ZoneField::kHighDown,
|
|
||||||
ZoneField::kHighUp, ZoneField::kRootDown, ZoneField::kRootUp,
|
|
||||||
ZoneField::kDelete,
|
|
||||||
};
|
|
||||||
const int slots = 7;
|
|
||||||
const int ctrlBlockLeft = row.right() - slots * kZoneCtrlWidth;
|
|
||||||
if (x < ctrlBlockLeft) return ZoneHit{index, ZoneField::kZoneNone}; // label -> select
|
|
||||||
const int slot = (x - ctrlBlockLeft) / kZoneCtrlWidth;
|
|
||||||
if (slot < 0 || slot >= slots) return ZoneHit{index, ZoneField::kZoneNone};
|
|
||||||
return ZoneHit{index, fields[slot]};
|
|
||||||
}
|
|
||||||
|
|
||||||
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) {
|
|
||||||
return contains(layout.addZoneButton, x, y);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ----------------------
|
|
||||||
// Bodies moved verbatim from the reasampler_editor shell (behavior-identical); the
|
|
||||||
// only signature change is clusterRects' `knobSize` parameter (formerly knob_deck's
|
|
||||||
// kDeckKnobSize read directly — passed in so this module stays knob_deck-free).
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// Fixed band metrics (formerly the editor shell's anon-ns constants).
|
|
||||||
constexpr int kHeroMinHeight = 150; // the elastic hero's floor (r11)
|
|
||||||
constexpr int kClusterHeight = 52; // root strip + preview + vel knob + curve btn + channel toggle
|
|
||||||
constexpr int kStripBandHeight = 40; // the keyboard-strip band height (root strip + zone strip)
|
|
||||||
|
|
||||||
// The r11 cluster's fixed right-anchored run (left -> right: Preview button, the radial
|
|
||||||
// preview-velocity knob cell, the mini curve-preview button, Mono|Stereo).
|
|
||||||
constexpr int kPreviewBtnW = 64;
|
|
||||||
constexpr int kVelCellW = 48; // the Vel knob cell (deck cell grammar)
|
|
||||||
constexpr int kCurveBtnSize = 28; // the square curve-preview button
|
|
||||||
|
|
||||||
// The S7 mono/stereo toggle segments.
|
|
||||||
constexpr int kChanSegW = 52;
|
|
||||||
constexpr int kChanSegH = 18;
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
// r11 band order: title (fixed) -> hero (ELASTIC: absorbs all height left after the fixed
|
|
||||||
// bands, floor kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom-
|
|
||||||
// anchored). When the window is too short for the floor (below the checkSizeConstraint
|
|
||||||
// minimum — a defensive case), the hero keeps its floor and the lower bands clip past the
|
|
||||||
// window bottom gracefully.
|
|
||||||
SampleBands computeSampleBands(int w, int h, int deckH) {
|
|
||||||
SampleBands b;
|
|
||||||
const int titleH = (std::min)(kTitleHeight, h);
|
|
||||||
b.title = Rect::ltrb(0, 0, w, titleH);
|
|
||||||
// Two nav buttons right-anchored in the title band (Browse then Zone).
|
|
||||||
const int navTop = 2;
|
|
||||||
const int navBot = (std::max)(navTop, titleH - 2);
|
|
||||||
const Rect zone = Rect::ltrb(w - kPad - kNavButtonWidth, navTop, w - kPad, navBot);
|
|
||||||
const Rect browse = Rect::ltrb(zone.x - 4 - kNavButtonWidth, navTop, zone.x - 4, navBot);
|
|
||||||
b.navBrowse = browse;
|
|
||||||
b.navZone = zone;
|
|
||||||
|
|
||||||
int deckTop = h - kPad - deckH;
|
|
||||||
int clusterTop = deckTop - kClusterHeight - 4;
|
|
||||||
int heroBottom = clusterTop - 4;
|
|
||||||
if (heroBottom - titleH < kHeroMinHeight) {
|
|
||||||
heroBottom = titleH + kHeroMinHeight; // hero floor wins; lower bands clip below
|
|
||||||
clusterTop = heroBottom + 4;
|
|
||||||
deckTop = clusterTop + kClusterHeight + 4;
|
|
||||||
}
|
|
||||||
b.hero = Rect::ltrb(kPad, titleH, w - kPad, heroBottom);
|
|
||||||
b.cluster = Rect::ltrb(0, clusterTop, w, clusterTop + kClusterHeight);
|
|
||||||
b.deck = Rect::ltrb(kPad, deckTop, w - kPad, deckTop + deckH);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw + hit-test both derive from this ONE formula.
|
|
||||||
ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize) {
|
|
||||||
ClusterRects r;
|
|
||||||
const int stripTop = cluster.y + (cluster.height - kStripBandHeight) / 2;
|
|
||||||
const int stripBot = stripTop + kStripBandHeight;
|
|
||||||
const int curveTop = cluster.y + (cluster.height - kCurveBtnSize) / 2;
|
|
||||||
r.curveBtn = Rect::ltrb(chanMono.x - kPad - kCurveBtnSize, curveTop,
|
|
||||||
chanMono.x - kPad, curveTop + kCurveBtnSize);
|
|
||||||
r.velCell = Rect::ltrb(r.curveBtn.x - kPad - kVelCellW, stripTop,
|
|
||||||
r.curveBtn.x - kPad, stripBot);
|
|
||||||
const int knobLeft = r.velCell.x + (kVelCellW - knobSize) / 2;
|
|
||||||
r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + knobSize,
|
|
||||||
r.velCell.y + knobSize);
|
|
||||||
r.velLabel = Rect::ltrb(r.velCell.x, r.velKnob.bottom(), r.velCell.right(), r.velCell.bottom());
|
|
||||||
r.preview = Rect::ltrb(r.velCell.x - kPad - kPreviewBtnW, stripTop,
|
|
||||||
r.velCell.x - kPad, stripBot);
|
|
||||||
r.rootStrip = Rect::ltrb(cluster.x + kPad, stripTop, r.preview.x - kPad, stripBot);
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
ChannelToggleRects channelToggleRects(const Rect& area) {
|
|
||||||
const int top = area.y + (area.height - kChanSegH) / 2;
|
|
||||||
const int right = area.right() - kPad;
|
|
||||||
const Rect stereo = Rect::ltrb(right - kChanSegW, top, right, top + kChanSegH);
|
|
||||||
const Rect mono = Rect::ltrb(stereo.x - kChanSegW, top, stereo.x, top + kChanSegH);
|
|
||||||
return {mono, stereo};
|
|
||||||
}
|
|
||||||
|
|
||||||
Rect zoneContentArea(int w, int h) {
|
|
||||||
const int titleH = (std::min)(kTitleHeight, h);
|
|
||||||
return Rect::ltrb(0, titleH, w, h);
|
|
||||||
}
|
|
||||||
|
|
||||||
Rect zoneBackRect(int w, int h) {
|
|
||||||
return Rect::ltrb(w - kPad - kNavButtonWidth, 2, w - kPad,
|
|
||||||
(std::max)(2, (std::min)(kTitleHeight, h) - 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
Rect zoneAddRect(const Rect& content) {
|
|
||||||
return Rect::ltrb(content.x + kPad, content.y + 4, content.x + kPad + 96,
|
|
||||||
content.y + 4 + 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
Rect zoneDeleteRect(const Rect& addR) {
|
|
||||||
return Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zone content sits below the "+ Add Zone" affordance (top+4, height 20) with a 12px
|
|
||||||
// gap, padded kPad horizontally. All call sites use this formula.
|
|
||||||
Rect zonesStripArea(const Rect& content) {
|
|
||||||
const int stripTop = content.y + 4 + 20 + 12; // addR.bottom() + 12
|
|
||||||
return Rect::ltrb(content.x + kPad, stripTop, content.right() - kPad,
|
|
||||||
stripTop + kStripBandHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Anchored off zonesStripArea.bottom() so the legend top tracks the strip bottom
|
|
||||||
// without re-inlining the strip arithmetic here.
|
|
||||||
Rect noteEntryFieldsArea(const Rect& content) {
|
|
||||||
const int stripBottom = zonesStripArea(content).bottom();
|
|
||||||
const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom() + 8)
|
|
||||||
return Rect::ltrb(content.x + 8 + 128, top, content.right() - 8, top + 18);
|
|
||||||
}
|
|
||||||
|
|
||||||
Rect noteEntryFieldRect(const Rect& fields, int f) {
|
|
||||||
if (f < 0 || f > 2 || fields.width <= 0) return Rect{};
|
|
||||||
const int segW = fields.width / 3;
|
|
||||||
const int left = fields.x + f * segW + (f > 0 ? 4 : 0); // small inter-field gap
|
|
||||||
const int right = (f == 2) ? fields.right() : fields.x + (f + 1) * segW;
|
|
||||||
return Rect::ltrb(left, fields.y, right, fields.bottom());
|
|
||||||
}
|
|
||||||
|
|
||||||
Rect zonesControlPanel(const Rect& content) {
|
|
||||||
const Rect strip = zonesStripArea(content);
|
|
||||||
const int panelTop = strip.bottom() + 8 + 18 + 8; // strip + the 18px legend row + gap
|
|
||||||
return Rect::ltrb(content.x + kPad, panelTop, content.right() - kPad,
|
|
||||||
content.bottom() - 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
// FB2 (R11-F2 parity): the deck lays out from the panel top (top-anchored), with a
|
|
||||||
// column at the panel's right reserved for the mini curve-preview button so no deck row
|
|
||||||
// starts inside it.
|
|
||||||
Rect zonesDeckArea(const Rect& content) {
|
|
||||||
const Rect panel = zonesControlPanel(content);
|
|
||||||
return Rect::ltrb(panel.x, panel.y, panel.right() - kCurveBtnSize - kPad, panel.bottom());
|
|
||||||
}
|
|
||||||
|
|
||||||
Rect zonesCurveButton(const Rect& content) {
|
|
||||||
const Rect panel = zonesControlPanel(content);
|
|
||||||
return Rect::ltrb(panel.right() - kCurveBtnSize, panel.y, panel.right(), panel.y + kCurveBtnSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace reasampler::instrument::ui
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user