Compare commits

..

52 Commits

Author SHA1 Message Date
daniel b34a543b81 Merge bump-1-0-0: version 1.0.0 2026-07-29 14:23:52 -04:00
daniel 1a62e10467 1.0.0 2026-07-29 14:23:44 -04:00
daniel 8a9e2a1194 Merge phase-q: Phase Q — Quality (1.0 structural reorganization) complete
Q-W0 audit (59 findings, 6 fix-nows incl. linked-lag stereo SOLA) through Q-W6 (OCP registration table). src/ reorganized into core//shell//app with matching sub-namespaces; god-modules split to responsibility seams under the ~600-line ceiling; core/json + core/wire dedupe; deletion authority concentrated in prune_fs; FOREVER-STABLE contract strings byte-identical throughout. 61/61 green.
2026-07-29 14:09:49 -04:00
daniel f529554673 docs: CLAUDE.md post-Phase-Q architecture refresh — core/shell/app module map, split-TU realities, registration-table mechanism; contract text unchanged 2026-07-29 14:06:56 -04:00
daniel 4d2316b77c docs(phase-q): record Q-W4/Q-W5/Q-W6 landings — all seven waves structurally complete; remaining: DAW verification batch, CLAUDE.md refresh, dev merge 2026-07-29 13:57:44 -04:00
daniel d1202b3174 Merge pq-w6-table: Q-W6 — OCP registration table (adding an action touches one row); bank verbs lifted to shell/bank_ops(Session&); persist.h umbrella, wav_trim shim, and core/namespaces.h all retired; grow-loop rehomed to core/wire; FOREVER-STABLE strings byte-identical; 61/61 green 2026-07-29 13:51:47 -04:00
daniel cd12b97631 Q-W6 review follow-ups: drop stale wav_trim clause, fix stale include comment, idempotent action-table clear, reflow ragged comment, align session.h trailing comments 2026-07-29 13:51:35 -04:00
daniel f3be4d8cce Q-W6: registration table (OCP) in main.cpp; bank verbs -> shell/bank_ops(Session&); persist.h + wav_trim + namespaces.h shims deleted; 61/61
capture.h realtime seam split to capture_realtime_shell.h; GetProjExtState grow-loop rehomed to core/wire/ext_state_read; stale persist.cpp/bank_panel.cpp comment refs fixed; CLAUDE.md persist/bank_book/actions bullets updated. Command-id suffixes, display phrases, and undo labels byte-identical.
2026-07-29 13:40:09 -04:00
daniel 4831e0e172 Merge pq-w5-persist: Q-W5 — persist split into session/ext_state_io/prune_fs (deletion authority concentrated); GetProjExtState grow-loop unified in bridge_marshal (T2-04); bank_book_json extraction via private static nameKey; persist.h compat umbrella; 61/61 green 2026-07-29 13:07:32 -04:00
daniel 0c39a716a5 docs: scope deletion-authority wording to bank folder + guard growing-read NUL terminator 2026-07-29 13:06:38 -04:00
daniel 75aa93f913 Q-W5: persist → session/ext_state_io/prune_fs (deletion authority concentrated); one GetProjExtState grow-loop in bridge_marshal (T2-04, ×3 rewired); bank_book JSON codec → bank_book_json via private static nameKey; persist.h stays umbrella. 61/61 green. 2026-07-29 13:06:38 -04:00
daniel bbbb69ee55 Merge pq-w4-actions: Q-W4 — actions.cpp split into design_view_actions/bank_actions/prune_action + action_registry; bank verbs deduped to promptless bankOp* inner verbs in panel_bank_ops; command-ids byte-identical; persistBankOp guards; 61/61 green 2026-07-29 13:06:31 -04:00
daniel 4f587258b2 fix: guard persistBankOp/persistBook against a null session; rename promptText to promptBankName 2026-07-29 13:06:12 -04:00
daniel 430e117620 Q-W4: split actions.cpp into design_view_actions/bank_actions/prune_action + shared action_registry; bank verbs deduped into promptless bankOp* inner verbs in panel_bank_ops (one mutation home, two UX skins); command-id strings byte-identical; actions.h shim carrier retired 2026-07-29 12:56:02 -04:00
daniel 5232227323 docs(phase-q): record Q-W2 + Q-W2v + Q-W3 landings — points to COMPLETED.md; ceiling overages recorded, in-DAW verification marked pending, Q-W4 dedupe-shape notes preserved 2026-07-29 11:43:22 -04:00
daniel 3bcd4dcc7b Merge pq-w3-main: Q-W3 — main.cpp → pointers+entry+dispatch via 4 capture hoists; one pure wav_codec RIFF owner (golden hash pinned); ICaptureBackend deleted; capture_realtime rename + finalize split; stampCaptureSample dedupe; makeUniqueTag monotonic counter; 61/61 green 2026-07-29 11:37:13 -04:00
daniel 6108673c84 Q-W3 rebase fix: hoisted capture TUs include the Q-W2 panel seam headers (panel_bank_ops/panel_input) instead of the deleted bank_panel.h 2026-07-29 11:36:32 -04:00
daniel 8bc5aa1257 Q-W3 review follow-ups: golden hash literal test, CLAUDE.md wav_codec bullet, dead RecordedCapture field comment, makeUniqueTag residual note 2026-07-29 11:33:28 -04:00
daniel 09f7173db2 Q-W3: main.cpp → pointers+entry+dispatch via 4 capture hoists; one pure wav_codec RIFF owner; ICaptureBackend deleted; capture_realtime rename + finalize split; shared stampCaptureSample; makeUniqueTag gains monotonic counter (fixes same-second batch collisions). 60/60 green. 2026-07-29 11:33:28 -04:00
daniel d7d7f7e084 Merge pq-w2v-vst: Q-W2v — VST god-modules split (editor 8 face-axis TUs + pure layout hoist, processor 3 TUs, component_state_io codec — extension drops the voice engine), zone_params.h, core/wire putLE; formats frozen, golden full-blob fixture; 61/61 green 2026-07-29 11:30:25 -04:00
daniel f0f91f7698 Q-W2v review follow-ups: golden full-blob fixture test, dead-local cleanup, bool-guard static_assert, explicit VelocityCurve qualification 2026-07-29 11:29:36 -04:00
daniel ea86f540b8 Q-W2v: split VST god-modules — editor 8 face-axis TUs (+pure layout hoist), processor 3 TUs, component_state_io codec split (extension drops the voice engine), zone_params.h, core/wire putLE; formats frozen, 61/61 green 2026-07-29 11:29:36 -04:00
daniel 9d5783453c Merge pq-w2-panel: Q-W2 — bank_panel.cpp (3459 LOC) split into eight shell/panel TUs, per-seam headers, audition direct call-through preserved; anon-namespace follow-ups; 60/60 green 2026-07-29 11:29:27 -04:00
daniel 19b12186ac Q-W2 review follow-ups: anon-namespace TU-private panel helpers, fix stale bank_panel.cpp comments, correct shim-transitivity claim
Wraps ~50 file-local helpers across all eight panel TUs in namespace{} (dissolves the menuAppend default-arg ODR trap); zero behavior change, 60/60 green.
2026-07-29 11:29:10 -04:00
daniel 30a4ffd01b Q-W2: split bank_panel.cpp (3459 LOC) into eight shell/panel TUs — reasampler::panel internals, per-seam public headers, shim retired; zero behavior change, 60/60 green 2026-07-29 11:29:10 -04:00
daniel b5788c82f6 docs(todo-1.0): record second fourth-batch answer round
Item 16 migration settled (adopt first zone); item 15 capture window specced, reset scope ratified; new item 17: provenance/usage consolidation. Nothing awaits Daniel.
2026-07-29 11:18:41 -04:00
daniel d8651fb7a7 docs(todo-1.0): item 15 answer round; item 16 retires zone mapping
Five blocking questions folded; capture-signal popup specced; items 2, 9,
11, 12 carry supersession notes. Sole remaining Daniel-blocker: multi-zone
migration.
2026-07-29 11:03:17 -04:00
daniel b8e8bb4c75 docs(todo-1.0): item 15 — one-click in-sampler resample (fourth batch, 2026-07-29); five questions await Daniel 2026-07-29 10:39:35 -04:00
daniel 9dd3440b75 docs(phase-q): record Q-W0 close + Q-W1 landing — points moved to COMPLETED.md; skipped riders and bank_book nameKey residual preserved 2026-07-29 09:50:37 -04:00
daniel 0ce3f4894a Merge pq-w1-layout: Q-W1 safe opener — core/json + core/wire + readFileBytes dedup (5 JSON decoders, 3 cursor copies deleted); core/shell/app relocation + sub-namespaces; ui::Rect unification; slot_map split; BankIndex→BankModel; 60/60 green 2026-07-29 09:42:27 -04:00
daniel 2d59bbe35d Q-W1 code-review follow-ups: restore 104 trailing newlines, relocate reasampler_uid.h to core/wire, drop ext_keys namespaces shim, add slot_map_tests, golden serialize literals for 4 modules, namespaces.h/pragma-once ordering sweep. 60/60 green. 2026-07-28 21:34:09 -04:00
daniel 847936f813 Q-W1 pt2: core/shell/app relocation + sub-namespaces; one concrete ui::Rect (LTRB fork retired); slot_map split from bank_book; BankIndex→BankModel; 59/59 green 2026-07-28 21:34:09 -04:00
daniel 67a41728f3 Q-W1 pt1: extract core/json (json::Reader/Writer), collapse wire Cursor family into core/wire, shared readFileBytes — five JSON decoders and three cursor copies deleted, byte-identical formats, 59/59 green 2026-07-28 21:34:09 -04:00
daniel 88e7765ee5 docs(TODO-1.0): filter envelope follows the mode-driven shape; no open questions await a Daniel decision 2026-07-28 20:09:13 -04:00
daniel 32785606d4 docs(TODO-1.0): item 14 — Trigger-mode amp fades replaced by AHD with curves; amp envelope shape follows playback mode 2026-07-28 20:04:59 -04:00
daniel b2013f2056 docs(TODO-1.0): close item 8 — Hold is a share of the post-A+D remainder, so the length bound holds by construction; 1:1 overlay scoped to sustain-less envelopes 2026-07-28 20:00:26 -04:00
daniel 587032ffa4 docs(TODO-1.0): fold second-batch answers — pitch AD becomes AHD, VELOCITY deck supersedes MASTER placement, full-height overlays with linked stereo 2026-07-28 19:49:05 -04:00
daniel 113d268553 docs(TODO-1.0): append second batch (items 4-13) — three bugs, loop-point regression plus Gate loop-sustain spec, and seven UI enhancements 2026-07-28 19:31:58 -04:00
daniel 546927ee43 Merge pq-w0-fixes: six Q-W0 audit fix-nows + review riders — linked-lag stereo SOLA w/ follower self-heal, prime playable-span bound, provenance cursor hardening, rate-derived gain ramp + fade ceiling; 56/56 green 2026-07-28 19:29:15 -04:00
daniel 16b2a1b8ca Q-W0 code-review remediation: pitch_shift follower self-heal, T3-03 rate bail, doc/comment riders 2026-07-28 19:28:48 -04:00
daniel 15d293b42f fix(q-w0): six audit fix-nows — linked-lag stereo SOLA, playable-span prime bound, declick dead-state, provenance cursor hardening, rate-derived gain ramp + fade ceiling 2026-07-28 19:28:48 -04:00
daniel 35f02cece2 docs(TODO-1.0): fold third answer round — spline point ceiling 128, right-click delete; doc fully settled 2026-07-28 19:04:36 -04:00
daniel 678274c19b docs(TODO-1.0): fold second answer round — items 1-2 fully settled, item 3 down to point bounds and popup gesture convergence 2026-07-28 18:59:46 -04:00
daniel 78a214b247 docs(phase-q): fold Q-W0 sign-off into PLAN/CONTEXT — all 59 dispositions settled; Q-W2v wave, T4-18 VST placement, ~600 ceiling + structural heuristics, W3 riders (wav_codec, ICaptureBackend, Q-9) 2026-07-28 18:44:48 -04:00
daniel a4571cc074 docs(TODO-1.0): fold in Daniel's answers — radio none-state, knot curve-drag, Filter ranges/label, dual-state Spline EGs 2026-07-28 18:44:41 -04:00
daniel b06b226182 docs: queue TODO-1.0 — post-1.0 specs for envelope curve dials/overlay radio, MM preamp filter stage, and hard-point Spline EGs 2026-07-28 17:58:19 -04:00
daniel 57e509c64f Merge pq-w0-audit: Q-W0 pre-restructure audit — 4 track notes + committed code-quality audit (59 findings triaged); docs-only 2026-07-28 17:02:47 -04:00
daniel 3eaa0b886e docs(Q-W0): committed code-quality audit — 59 findings triaged, Q-W2v VST wave + Q-W2/Q-W3 reshapes proposed, Daniel decision list for sign-off 2026-07-28 17:00:06 -04:00
daniel 2dbefb8c01 audit(q-w0-t4): sizing census — 14 oversize files, VST side unowned; propose Q-W2v wave, 8-seam bank_panel, ICaptureBackend is dead 2026-07-28 16:50:47 -04:00
daniel ca086f4009 docs(q-w0): T3 env-coupled-constant audit — 7 findings (2 fix-now: gain-ramp step, fade-ceiling frames); persistence surfaces clean 2026-07-28 16:50:38 -04:00
daniel dfe6ccbddd docs(q-w0): T2 architecture audit — wire-cursor hardening drift, 5th JSON decoder, 5x readFileBytes, rect-type zoo; 11 triaged findings 2026-07-28 16:43:34 -04:00
daniel f0d5d23171 docs(q-w0): T1 DSP audit — 11 triaged findings, SOLA engine sound; stereo splice decorrelation is the headline 2026-07-28 16:43:24 -04:00
301 changed files with 26337 additions and 19131 deletions
+28 -13
View File
File diff suppressed because one or more lines are too long
+300 -179
View File
@@ -29,9 +29,9 @@ cmake_minimum_required(VERSION 3.19)
# There is a SECOND set(REASAMPLER_VERSION ...) inside the padding-canary function
# further down. That one is a permanent test fixture ("0.9.01") and must NEVER be
# bumped on release.
set(REASAMPLER_VERSION "0.9.9")
set(REASAMPLER_VERSION "1.0.0")
project(reaper_reasampler VERSION 0.9.9 LANGUAGES CXX)
project(reaper_reasampler VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -69,7 +69,7 @@ endif()
# Regenerated at configure time whenever either changes; the exact string is substituted
# verbatim and the channel bit fans out through app_version.
configure_file(
${CMAKE_CURRENT_SOURCE_DIR}/src/version_generated.h.in
${CMAKE_CURRENT_SOURCE_DIR}/src/core/version/version_generated.h.in
${CMAKE_CURRENT_BINARY_DIR}/generated/version_generated.h
@ONLY)
@@ -82,18 +82,40 @@ set(SDK_INC ${CMAKE_CURRENT_SOURCE_DIR}/vendor/reaper-sdk/sdk)
set(WDL_INC ${CMAKE_CURRENT_SOURCE_DIR}/vendor/WDL/WDL)
set(SWELL ${WDL_INC}/swell)
# ---------------------------------------------------------------------------
# 0) core/ — the Q-W1 shared pure substrate. NO REAPER, NO SWELL, NO VST3.
# json: the ONE JSON lexical layer (reader + writer) behind the five
# persisted-blob (de)serializers (bank_model / bank_book /
# view_mode_model / owned_manifest / tail_control). T2-02 / §2.
# wire: the ONE length-prefixed ext-state wire codec (putField + Cursor +
# the guarded decimal accumulate) behind provenance /
# assignment_request / sample_usage / bank_sync. T2-01(b).
# file_bytes: the ONE whole-file byte loader both artifacts link. T2-03.
# Headers are included as "core/json/json.h" etc. (rooted at src/), so the
# include paths survive the Q-W1 part-2 directory relocation unchanged.
# ---------------------------------------------------------------------------
add_library(json STATIC src/core/json/json.cpp)
target_include_directories(json PUBLIC src)
add_library(wire STATIC src/core/wire/wire.cpp)
target_include_directories(wire PUBLIC src)
add_library(file_bytes STATIC src/core/util/file_bytes.cpp)
target_include_directories(file_bytes PUBLIC src)
# ---------------------------------------------------------------------------
# 1) Pure model library — NO REAPER, NO SWELL. Builds & tests anywhere.
# The sampler's heart: Sample metadata + BankIndex (Milestone 1).
# ---------------------------------------------------------------------------
add_library(bank_model STATIC src/bank_model.cpp)
add_library(bank_model STATIC src/core/model/bank_model.cpp)
target_include_directories(bank_model PUBLIC src)
target_link_libraries(bank_model PRIVATE json)
# ---------------------------------------------------------------------------
# 2) Pure peaks library — NO REAPER, NO SWELL. Waveform min/max thumbnails from
# raw PCM (Milestone 2). A sibling pure lib, kept distinct from bank_model.
# ---------------------------------------------------------------------------
add_library(peaks STATIC src/peaks.cpp)
add_library(peaks STATIC src/core/audio/peaks.cpp)
target_include_directories(peaks PUBLIC src)
# ---------------------------------------------------------------------------
@@ -101,7 +123,7 @@ target_include_directories(peaks PUBLIC src)
# file-name / project-relative path derivation for the capture shell (M3).
# Split out so the fiddly path logic is unit-tested outside the DAW.
# ---------------------------------------------------------------------------
add_library(capture_paths STATIC src/capture_paths.cpp)
add_library(capture_paths STATIC src/core/capture/capture_paths.cpp)
target_include_directories(capture_paths PUBLIC src)
# ---------------------------------------------------------------------------
@@ -110,7 +132,7 @@ target_include_directories(capture_paths PUBLIC src)
# cache key for the docked bank_panel (M5). Split out so the layout logic is
# unit-tested outside the DAW; the panel shell (SWELL/LICE/PCM) is DAW-verified.
# ---------------------------------------------------------------------------
add_library(bank_grid STATIC src/bank_grid.cpp)
add_library(bank_grid STATIC src/core/ui/bank_grid.cpp)
target_include_directories(bank_grid PUBLIC src)
# ---------------------------------------------------------------------------
@@ -120,7 +142,7 @@ target_include_directories(bank_grid PUBLIC src)
# unit-tested outside the DAW; the bank_panel header strip that draws it and
# routes clicks to view::applyMode is DAW-verified. Mirror of bank_grid.
# ---------------------------------------------------------------------------
add_library(mode_switch STATIC src/mode_switch.cpp)
add_library(mode_switch STATIC src/core/view/mode_switch.cpp)
target_include_directories(mode_switch PUBLIC src)
# ---------------------------------------------------------------------------
@@ -131,7 +153,7 @@ target_include_directories(mode_switch PUBLIC src)
# overflow/scroll math is unit-tested outside the DAW; the bank_panel region
# that draws it and routes clicks is DAW-verified. Mirror of mode_switch.
# ---------------------------------------------------------------------------
add_library(tab_strip STATIC src/tab_strip.cpp)
add_library(tab_strip STATIC src/core/ui/tab_strip.cpp)
target_include_directories(tab_strip PUBLIC src)
# ---------------------------------------------------------------------------
@@ -140,8 +162,9 @@ target_include_directories(tab_strip PUBLIC src)
# visibility derivation + parking/restore planner + JSON round-trip. Mirror of
# bank_model; the folder tree is an INPUT supplied by the D2 shell.
# ---------------------------------------------------------------------------
add_library(view_mode_model STATIC src/view_mode_model.cpp)
add_library(view_mode_model STATIC src/core/view/view_mode_model.cpp)
target_include_directories(view_mode_model PUBLIC src)
target_link_libraries(view_mode_model PRIVATE json)
# The pure lane-minting decision (planLaneMinting) names managed lanes via the ONE
# durable-key convention in lane_keys (laneNameForMode), so the model depends on that
# pure sibling. PUBLIC so every consumer (tests + module) resolves the symbol.
@@ -154,7 +177,7 @@ target_link_libraries(view_mode_model PUBLIC lane_keys)
# view.cpp; this fiddly folder-depth walk is unit-tested here (mirrors
# capture_paths splitting the path math out of the capture shell).
# ---------------------------------------------------------------------------
add_library(view_tree STATIC src/view_tree.cpp)
add_library(view_tree STATIC src/core/view/view_tree.cpp)
target_include_directories(view_tree PUBLIC src)
target_link_libraries(view_tree PUBLIC view_mode_model)
@@ -166,7 +189,7 @@ target_link_libraries(view_tree PUBLIC view_mode_model)
# live track/item GUID set and applies the tags is DAW-verified. Mirror of
# view_tree splitting the folder-depth walk out of view.cpp.
# ---------------------------------------------------------------------------
add_library(guid_diff STATIC src/guid_diff.cpp)
add_library(guid_diff STATIC src/core/view/guid_diff.cpp)
target_include_directories(guid_diff PUBLIC src)
# ---------------------------------------------------------------------------
@@ -177,7 +200,7 @@ target_include_directories(guid_diff PUBLIC src)
# exemption) and #2 (name-keyed identity survives ordinal renumber). Split out
# so the prefix rule is unit-tested; view.cpp reads the names from REAPER.
# ---------------------------------------------------------------------------
add_library(lane_keys STATIC src/lane_keys.cpp)
add_library(lane_keys STATIC src/core/view/lane_keys.cpp)
target_include_directories(lane_keys PUBLIC src)
# ---------------------------------------------------------------------------
@@ -186,7 +209,7 @@ target_include_directories(lane_keys PUBLIC src)
# load-bearing bit computation (no silent stretch, opt-in conform) is
# unit-tested outside the DAW; the InsertMedia call itself is DAW-verified.
# ---------------------------------------------------------------------------
add_library(insert_plan STATIC src/insert_plan.cpp)
add_library(insert_plan STATIC src/core/capture/insert_plan.cpp)
target_include_directories(insert_plan PUBLIC src)
# ---------------------------------------------------------------------------
@@ -197,7 +220,7 @@ target_include_directories(insert_plan PUBLIC src)
# the DAW; the render-driving + selection reads stay in capture.cpp / main.cpp.
# Depends on bank_model for the pure SourceMode enum.
# ---------------------------------------------------------------------------
add_library(render_settings STATIC src/render_settings.cpp)
add_library(render_settings STATIC src/core/capture/render_settings.cpp)
target_include_directories(render_settings PUBLIC src)
target_link_libraries(render_settings PUBLIC bank_model)
@@ -210,7 +233,7 @@ target_link_libraries(render_settings PUBLIC bank_model)
# the selection read, transient re-selection, and render loop stay in main.cpp.
# No dependency on bank_model — it takes plain ranges/values at its boundary.
# ---------------------------------------------------------------------------
add_library(batch_capture STATIC src/batch_capture.cpp)
add_library(batch_capture STATIC src/core/capture/batch_capture.cpp)
target_include_directories(batch_capture PUBLIC src)
# ---------------------------------------------------------------------------
@@ -221,9 +244,20 @@ target_include_directories(batch_capture PUBLIC src)
# outside the DAW; the bank_panel footer that draws it + routes clicks is
# DAW-verified. Depends on render_settings for the pure TailMode enum + caps.
# ---------------------------------------------------------------------------
add_library(tail_control STATIC src/tail_control.cpp)
add_library(tail_control STATIC src/core/capture/tail_control.cpp)
target_include_directories(tail_control PUBLIC src)
target_link_libraries(tail_control PUBLIC render_settings)
target_link_libraries(tail_control PRIVATE json)
# ---------------------------------------------------------------------------
# 2g''') Pure slot_map library — NO REAPER, NO SWELL. The L7 gap-preserving
# display-position carrier for one bank (extracted from bank_book, Q-W1
# T4-05): sample id -> slot, gap-preserving append/remove/reorder/reconcile,
# JSON round-trip. Mirror of bank_model; wrapped (not merged) by bank_book.
# ---------------------------------------------------------------------------
add_library(slot_map STATIC src/core/model/slot_map.cpp)
target_include_directories(slot_map PUBLIC src)
target_link_libraries(slot_map PRIVATE json)
# ---------------------------------------------------------------------------
# 2g') Pure bank_book library — NO REAPER, NO SWELL. The multi-bank phase heart
@@ -233,9 +267,13 @@ target_link_libraries(tail_control PUBLIC render_settings)
# sample between banks, JSON round-trip + legacy-bank_index→pool migration.
# Mirror of bank_model / view_mode_model; wraps BankIndex (bank_model untouched).
# ---------------------------------------------------------------------------
add_library(bank_book STATIC src/bank_book.cpp)
add_library(bank_book STATIC
src/core/model/bank_book.cpp
src/core/model/bank_book_json.cpp)
target_include_directories(bank_book PUBLIC src)
target_link_libraries(bank_book PUBLIC bank_model)
target_link_libraries(bank_book PUBLIC slot_map)
target_link_libraries(bank_book PRIVATE json)
# ---------------------------------------------------------------------------
# 2g'') Pure owned_manifest library — NO REAPER, NO SWELL. The owned-file manifest
@@ -243,11 +281,12 @@ target_link_libraries(bank_book PUBLIC bank_model)
# itself created, so Phase R prune can tell the bank system's own orphans from
# hand-dropped files. Deliberately DECOUPLED from bank_book — it tracks files
# CREATED, not index membership (sample-remove is not manifest-remove). Small
# pure type + JSON round-trip; mirror of wav_trim / tab_strip. B-cap writes +
# pure type + JSON round-trip; mirror of wav_codec / tab_strip. B-cap writes +
# persists it; Phase R (R1/R2) consumes it — no prune logic here.
# ---------------------------------------------------------------------------
add_library(owned_manifest STATIC src/owned_manifest.cpp)
add_library(owned_manifest STATIC src/core/model/owned_manifest.cpp)
target_include_directories(owned_manifest PUBLIC src)
target_link_libraries(owned_manifest PRIVATE json)
# ---------------------------------------------------------------------------
# 2g''') Pure prune_reconcile library — NO REAPER, NO SWELL, NO filesystem. The
@@ -258,7 +297,7 @@ target_include_directories(owned_manifest PUBLIC src)
# pure function; R2/R3 wrap the two ends (folder enumeration + deletion) in the
# shell. Standalone — depends only on the standard library.
# ---------------------------------------------------------------------------
add_library(prune_reconcile STATIC src/prune_reconcile.cpp)
add_library(prune_reconcile STATIC src/core/reclaim/prune_reconcile.cpp)
target_include_directories(prune_reconcile PUBLIC src)
# ---------------------------------------------------------------------------
@@ -269,34 +308,39 @@ target_include_directories(prune_reconcile PUBLIC src)
# outside the DAW; the bank_panel footer that draws it and dispatches the
# "Prune bank folder" command is DAW-verified. Mirror of mode_switch / tab_strip.
# ---------------------------------------------------------------------------
add_library(prune_button STATIC src/prune_button.cpp)
add_library(prune_button STATIC src/core/ui/prune_button.cpp)
target_include_directories(prune_button PUBLIC src)
# ---------------------------------------------------------------------------
# 2g) Pure realtime_record library — NO REAPER, NO SWELL. The M8 realtime-record
# logic: capture scope + FX-tap point -> I_RECMODE / I_RECMODE_FLAGS values,
# wet/dry -> tap point, and the recorded-file -> Sample mapping. Split out so
# the fiddly record-mode bit values + Sample population are unit-tested outside
# the DAW; the transport/temp-track/send/file-move recipe stays in capture.cpp.
# 2g) Pure capture_realtime library — NO REAPER, NO SWELL. (Renamed from
# realtime_record in Q-W3 — the Q-9 naming rider: pure module takes the stem,
# the shell takes the suffix, matching drag_out <-> drag_out_win.) The M8
# realtime-record logic: capture scope + FX-tap point -> I_RECMODE /
# I_RECMODE_FLAGS values, wet/dry -> tap point, the recorded-file -> Sample
# mapping, and the async record-phase state machine. Split out so the fiddly
# record-mode bit values + Sample population + phase transitions are
# unit-tested outside the DAW; the transport/temp-track/send recipe stays in
# capture_realtime_shell.cpp (+ the file-side capture_realtime_finalize.cpp).
# Depends on bank_model for the pure Sample / SourceMode types.
# ---------------------------------------------------------------------------
add_library(realtime_record STATIC src/realtime_record.cpp)
target_include_directories(realtime_record PUBLIC src)
target_link_libraries(realtime_record PUBLIC bank_model)
add_library(capture_realtime STATIC src/core/capture/capture_realtime.cpp)
target_include_directories(capture_realtime PUBLIC src)
target_link_libraries(capture_realtime PUBLIC bank_model)
# ---------------------------------------------------------------------------
# 2h) Pure wav_trim library — NO REAPER, NO SWELL. The realtime tail's (T2) PCM
# decay-scan trim needs to TRUNCATE the recorded 32-bit-float WAV at a frame
# boundary without corrupting the RIFF container. This module holds the fiddly,
# easy-to-get-wrong part unit-tested outside the DAW: parse the WAV geometry
# (fmt/data chunk walk + 32-bit-float verification), extract the tail-region
# floats to scan, and compute the truncate plan (kept byte length + the two
# patched RIFF/data size fields). The file read/write/truncate I/O stays in the
# realtime shell. Depends on peaks for the AudioSample float alias.
# 2h) Pure wav_codec library — NO REAPER, NO SWELL. The ONE owner of the WAV/RIFF
# byte format (Q-W3, audit §4e: T2-08 / T4-10 / T4-23 consolidation): the RIFF
# chunk walker + layout parse (formerly wav_trim), the tail-trim truncate plan +
# size-field patch (formerly duplicated in capture_realtime), the float32 WAV
# build (formerly hand-rolled in ingest), and the WAV-aware content hashes
# (formerly in capture_paths). The dedup-by-hash and null-test invariants rest
# on this one implementation. File I/O stays in the shells. Depends on peaks
# for the AudioSample float alias. (The transitional `wav_trim` alias was
# retired in Q-W6 — every includer points here directly.)
# ---------------------------------------------------------------------------
add_library(wav_trim STATIC src/wav_trim.cpp)
target_include_directories(wav_trim PUBLIC src)
target_link_libraries(wav_trim PUBLIC peaks)
add_library(wav_codec STATIC src/core/capture/wav_codec.cpp)
target_include_directories(wav_codec PUBLIC src)
target_link_libraries(wav_codec PUBLIC peaks)
# ---------------------------------------------------------------------------
# 2i) Pure app_version library — NO REAPER, NO SWELL. The Phase V (V1) version-identity
@@ -308,7 +352,7 @@ target_link_libraries(wav_trim PUBLIC peaks)
# DAW; the ext-state write/read (persist) and the show-version action (main) are shell.
# Depends on the generated header in the build tree (PUBLIC so every consumer sees it).
# ---------------------------------------------------------------------------
add_library(app_version STATIC src/app_version.cpp)
add_library(app_version STATIC src/core/version/app_version.cpp)
target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated)
# ---------------------------------------------------------------------------
@@ -318,11 +362,12 @@ target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/ge
# FX-chain identity fold, and the pure parent-detection decision (resample-from-
# sample by resolved file path). Split out so the encoding + decision logic are
# unit-tested outside the DAW; the FX-chain query, capture re-run, and action
# registration stay in the shell (main.cpp / actions.cpp). No dependency on
# registration stay in the shell (main.cpp / shell/actions/). No dependency on
# bank_model — it takes plain strings/values at its boundary.
# ---------------------------------------------------------------------------
add_library(provenance STATIC src/provenance.cpp)
add_library(provenance STATIC src/core/model/provenance.cpp)
target_include_directories(provenance PUBLIC src)
target_link_libraries(provenance PRIVATE wire)
# ---------------------------------------------------------------------------
# 2j') Pure assignment_request library — NO REAPER, NO SWELL, NO VST3. The S8 ingest
@@ -334,8 +379,9 @@ target_include_directories(provenance PUBLIC src)
# depend on is unit-tested outside the DAW; the reader lands in a separate artifact,
# so the round-trip test is the contract guard. No dependency — plain strings + int64.
# ---------------------------------------------------------------------------
add_library(assignment_request STATIC src/assignment_request.cpp)
add_library(assignment_request STATIC src/core/wire/assignment_request.cpp)
target_include_directories(assignment_request PUBLIC src)
target_link_libraries(assignment_request PRIVATE wire)
# ---------------------------------------------------------------------------
# 2j'') Pure sample_usage library — NO REAPER, NO SWELL, NO VST3. The pS-usage seam:
@@ -347,8 +393,9 @@ target_include_directories(assignment_request PUBLIC src)
# matcher.
# Linked by BOTH artifacts — the mirror of assignment_request, reversed direction.
# ---------------------------------------------------------------------------
add_library(sample_usage STATIC src/sample_usage.cpp)
add_library(sample_usage STATIC src/core/wire/sample_usage.cpp)
target_include_directories(sample_usage PUBLIC src)
target_link_libraries(sample_usage PRIVATE wire)
# ---------------------------------------------------------------------------
# 2l) Pure drag_out library — NO REAPER, NO SWELL, NO OS/OLE. The Milestone 11
@@ -361,7 +408,7 @@ target_include_directories(sample_usage PUBLIC src)
# initiation (drag_out_win) and the bank_panel gesture hook are DAW-verified. Mirror
# of mode_switch.
# ---------------------------------------------------------------------------
add_library(drag_out STATIC src/drag_out.cpp)
add_library(drag_out STATIC src/core/ui/drag_out.cpp)
target_include_directories(drag_out PUBLIC src)
# ---------------------------------------------------------------------------
@@ -371,18 +418,19 @@ target_include_directories(drag_out PUBLIC src)
# TrackFX_SetPreset so a freshly-added ReaSampler 9000 plays that capture (the
# former "vst_chunk" named-config-parm write fed REAPER raw component bytes its
# VST3 wrapper framing cannot apply — the blank-on-drop regression). Reuses the
# instrument's OWN serializer (sample_map::serializeComponentState) — NOT a
# parallel byte writer — so the cross-artifact contract cannot drift; links
# sample_map (which pulls bank_book/wav_trim/sampler_core transitively) and
# instrument's OWN serializer (component_state_io::serializeComponentState) — NOT
# a parallel byte writer — so the cross-artifact contract cannot drift; links
# component_state_io (Q-W2v codec split, T4-13 ≡ T2-07: the extension no longer
# links sampler_core/pitch_shift object code to serialize one preset blob) and
# NEITHER SDK. The class-ID string derives from the FROZEN UID macros
# (src/vst/reasampler_uid.h, SDK-free), channel-selected via the generated
# (src/core/wire/reasampler_uid.h, SDK-free), channel-selected via the generated
# version header — hence the generated include dir. The round-trip test parses
# the container and decodes back through the instrument's own reader. Mirror of
# assignment_request.
# ---------------------------------------------------------------------------
add_library(instrument_drop STATIC src/instrument_drop.cpp)
target_include_directories(instrument_drop PUBLIC src src/vst ${CMAKE_CURRENT_BINARY_DIR}/generated)
target_link_libraries(instrument_drop PUBLIC sample_map)
add_library(instrument_drop STATIC src/core/wire/instrument_drop.cpp)
target_include_directories(instrument_drop PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated)
target_link_libraries(instrument_drop PUBLIC component_state_io)
# ---------------------------------------------------------------------------
# 2m) Pure theme library — NO REAPER, NO SWELL, NO LICE. The Phase L (L1) palette
@@ -394,7 +442,7 @@ target_link_libraries(instrument_drop PUBLIC sample_map)
# switching the visual direction is a one-file edit. The draw shell (draw_kit) turns
# a KitColor into a LICE_pixel at the boundary. Mirror of mode_switch — pure.
# ---------------------------------------------------------------------------
add_library(theme STATIC src/theme.cpp)
add_library(theme STATIC src/core/ui/theme.cpp)
target_include_directories(theme PUBLIC src)
# ---------------------------------------------------------------------------
@@ -407,7 +455,7 @@ target_include_directories(theme PUBLIC src)
# SliderGeometry/ListRowBox avoid the existing ButtonRect/CellRect collisions.
# Mirror of prune_button — pure, CTest-covered.
# ---------------------------------------------------------------------------
add_library(component_geometry STATIC src/component_geometry.cpp)
add_library(component_geometry STATIC src/core/ui/component_geometry.cpp)
target_include_directories(component_geometry PUBLIC src)
# ---------------------------------------------------------------------------
@@ -422,7 +470,7 @@ target_include_directories(component_geometry PUBLIC src)
# Main_OnCommand dispatch + kbd_getTextFromCmd query are DAW-verified. Mirror of
# mode_switch / prune_button.
# ---------------------------------------------------------------------------
add_library(action_bar STATIC src/action_bar.cpp)
add_library(action_bar STATIC src/core/ui/action_bar.cpp)
target_include_directories(action_bar PUBLIC src)
# ---------------------------------------------------------------------------
@@ -436,7 +484,7 @@ target_include_directories(action_bar PUBLIC src)
# DAW-verified. Reuses prune_button's FooterRect input type. Mirror of action_bar /
# mode_switch / prune_button.
# ---------------------------------------------------------------------------
add_library(footer_bar STATIC src/footer_bar.cpp)
add_library(footer_bar STATIC src/core/ui/footer_bar.cpp)
target_include_directories(footer_bar PUBLIC src)
target_link_libraries(footer_bar PUBLIC prune_button)
@@ -449,7 +497,7 @@ target_link_libraries(footer_bar PUBLIC prune_button)
# L1-kit draw + TrackPopupMenu popup + NamedCommandLookup/Main_OnCommand dispatch are
# DAW-verified. Mirror of prune_button / mode_switch.
# ---------------------------------------------------------------------------
add_library(overflow_menu STATIC src/overflow_menu.cpp)
add_library(overflow_menu STATIC src/core/ui/overflow_menu.cpp)
target_include_directories(overflow_menu PUBLIC src)
# ---------------------------------------------------------------------------
@@ -461,7 +509,7 @@ target_include_directories(overflow_menu PUBLIC src)
# draws disabled buttons in the kit Disabled state. Depends on view_mode_model for the
# seed mode-id constants (kArrangeModeId / kDesignModeId — ONE home for the ids).
# ---------------------------------------------------------------------------
add_library(mode_enable STATIC src/mode_enable.cpp)
add_library(mode_enable STATIC src/core/ui/mode_enable.cpp)
target_include_directories(mode_enable PUBLIC src)
target_link_libraries(mode_enable PUBLIC view_mode_model)
@@ -473,7 +521,7 @@ target_link_libraries(mode_enable PUBLIC view_mode_model)
# prefix strip are unit-tested outside the DAW; the bank_panel hover timer + LICE overlay
# draw are DAW-verified. Mirror of prune_button / component_geometry.
# ---------------------------------------------------------------------------
add_library(tooltip STATIC src/tooltip.cpp)
add_library(tooltip STATIC src/core/ui/tooltip.cpp)
target_include_directories(tooltip PUBLIC src)
# ---------------------------------------------------------------------------
@@ -484,7 +532,7 @@ target_include_directories(tooltip PUBLIC src)
# edge cases) is unit-tested outside the DAW; the bank_panel kit-text overlay draw is
# DAW-verified. Mirror of tooltip's prefix-strip helper — pure, CTest-covered.
# ---------------------------------------------------------------------------
add_library(card_meta STATIC src/card_meta.cpp)
add_library(card_meta STATIC src/core/ui/card_meta.cpp)
target_include_directories(card_meta PUBLIC src)
# ---------------------------------------------------------------------------
@@ -497,7 +545,7 @@ target_include_directories(card_meta PUBLIC src)
# are DAW-verified. Reuses drag_out's PanelClientRect/DragState + bank_grid's CellRect/
# GridSpec. Mirror of drag_out::decideGesture / bank_grid.
# ---------------------------------------------------------------------------
add_library(card_drag STATIC src/card_drag.cpp)
add_library(card_drag STATIC src/core/ui/card_drag.cpp)
target_include_directories(card_drag PUBLIC src)
target_link_libraries(card_drag PUBLIC drag_out bank_grid)
@@ -507,10 +555,10 @@ target_link_libraries(card_drag PUBLIC drag_out bank_grid)
# bounded stealing, an ADSR amplitude envelope, a key/velocity keymap with
# (note, velocity) -> zone resolution, and repitch/interpolation from a root note
# with loop-point-aware sustain. The mirror of bank_model / peaks / bank_book,
# tested hard outside any host. Lives under src/vst/ (it is instrument code) but
# tested hard outside any host. Lives under core/instrument/engine/ but
# links NEITHER SDK — the plain-data boundary is enforced structurally: the test
# target below links only sampler_core (+ its peaks dep for the AudioSample alias,
# the one house precedent wav_trim also relies on). The VST3 shell (src/vst/
# the one house precedent wav_codec also relies on). The VST3 shell (shell/instrument/
# reasampler_processor.cpp) marshals MIDI/audio to/from it and is DAW-verified.
# ---------------------------------------------------------------------------
# pitch_shift (S16) — the pure duration-preserving PitchShifter (Preserve-engine DSP core).
@@ -518,8 +566,8 @@ target_link_libraries(card_drag PUBLIC drag_out bank_grid)
# because that header drags <windows.h> (via wdltypes.h) into any TU that includes it, which
# cannot enter the pure sampler_core. Links only peaks (the AudioSample alias). sampler_core
# depends on it (Voice owns two PitchShifters).
add_library(pitch_shift STATIC src/vst/pitch_shift.cpp)
target_include_directories(pitch_shift PUBLIC src src/vst)
add_library(pitch_shift STATIC src/core/instrument/engine/pitch_shift.cpp)
target_include_directories(pitch_shift PUBLIC src)
target_link_libraries(pitch_shift PUBLIC peaks)
# velocity_curve (S-VIEW-9) — the pure velocity->amp transfer curve (eval + editing/clamp/inverse
@@ -527,17 +575,31 @@ target_link_libraries(pitch_shift PUBLIC peaks)
# explicit pixel box, not a Rect) so the engine can depend on it WITHOUT gaining a transitive
# dependency on the editor's layout types. sampler_core depends on it (KeyZone carries a
# VelocityCurve; Voice::start eval's it). Mirror of pitch_shift's role, one layer below the engine.
add_library(velocity_curve STATIC src/vst/velocity_curve.cpp)
target_include_directories(velocity_curve PUBLIC src/vst)
add_library(velocity_curve STATIC src/core/instrument/engine/velocity_curve.cpp)
target_include_directories(velocity_curve PUBLIC src)
add_library(sampler_core STATIC src/vst/sampler_core.cpp)
target_include_directories(sampler_core PUBLIC src src/vst)
add_library(sampler_core STATIC src/core/instrument/engine/sampler_core.cpp)
target_include_directories(sampler_core PUBLIC src)
target_link_libraries(sampler_core PUBLIC peaks pitch_shift velocity_curve)
# ---------------------------------------------------------------------------
# 3) Standalone tests for the pure modules (run without launching REAPER).
# ---------------------------------------------------------------------------
enable_testing()
# core/ (Q-W1): the shared JSON lexical layer, wire codec, and file loader.
add_executable(json_tests tests/test_json.cpp)
target_link_libraries(json_tests PRIVATE json)
add_test(NAME json_tests COMMAND json_tests)
add_executable(wire_tests tests/test_wire.cpp)
target_link_libraries(wire_tests PRIVATE wire)
add_test(NAME wire_tests COMMAND wire_tests)
add_executable(file_bytes_tests tests/test_file_bytes.cpp)
target_link_libraries(file_bytes_tests PRIVATE file_bytes)
add_test(NAME file_bytes_tests COMMAND file_bytes_tests)
add_executable(bank_model_tests tests/test_bank_model.cpp)
target_link_libraries(bank_model_tests PRIVATE bank_model)
add_test(NAME bank_model_tests COMMAND bank_model_tests)
@@ -594,17 +656,21 @@ add_executable(tail_control_tests tests/test_tail_control.cpp)
target_link_libraries(tail_control_tests PRIVATE tail_control)
add_test(NAME tail_control_tests COMMAND tail_control_tests)
add_executable(realtime_record_tests tests/test_realtime_record.cpp)
target_link_libraries(realtime_record_tests PRIVATE realtime_record)
add_test(NAME realtime_record_tests COMMAND realtime_record_tests)
add_executable(capture_realtime_tests tests/test_capture_realtime.cpp)
target_link_libraries(capture_realtime_tests PRIVATE capture_realtime)
add_test(NAME capture_realtime_tests COMMAND capture_realtime_tests)
add_executable(bank_book_tests tests/test_bank_book.cpp)
target_link_libraries(bank_book_tests PRIVATE bank_book)
add_test(NAME bank_book_tests COMMAND bank_book_tests)
add_executable(wav_trim_tests tests/test_wav_trim.cpp)
target_link_libraries(wav_trim_tests PRIVATE wav_trim)
add_test(NAME wav_trim_tests COMMAND wav_trim_tests)
add_executable(slot_map_tests tests/test_slot_map.cpp)
target_link_libraries(slot_map_tests PRIVATE slot_map json)
add_test(NAME slot_map_tests COMMAND slot_map_tests)
add_executable(wav_codec_tests tests/test_wav_codec.cpp)
target_link_libraries(wav_codec_tests PRIVATE wav_codec)
add_test(NAME wav_codec_tests COMMAND wav_codec_tests)
add_executable(owned_manifest_tests tests/test_owned_manifest.cpp)
target_link_libraries(owned_manifest_tests PRIVATE owned_manifest)
@@ -628,7 +694,7 @@ add_test(NAME app_version_tests COMMAND app_version_tests)
# threaded verbatim or reconstructed from numeric components, at any version, padded or
# not — the live suite cannot detect a reconstruct-from-components regression at all.
# So: run the SAME version_generated.h.in template through configure_file a second time
# with a SYNTHETIC padded version, and compile the SAME src/app_version.cpp against that
# with a SYNTHETIC padded version, and compile the SAME src/core/version/app_version.cpp against that
# header (include-dir substitution — the canary target never sees the live generated/
# dir). The "0.9.01" here is a permanent test fixture, NOT the shipped version (see also
# the source-of-truth comment at the top of this file for the live-version invariant); it
@@ -645,20 +711,20 @@ function(_configure_padding_canary)
# and CMAKE_CURRENT_SOURCE_DIR / CMAKE_CURRENT_BINARY_DIR are inherited read-only.
set(REASAMPLER_VERSION "0.9.01")
configure_file(
${CMAKE_CURRENT_SOURCE_DIR}/src/version_generated.h.in
${CMAKE_CURRENT_SOURCE_DIR}/src/core/version/version_generated.h.in
${CMAKE_CURRENT_BINARY_DIR}/generated_padding_canary/version_generated.h
@ONLY)
endfunction()
_configure_padding_canary()
# NOTE: app_version_padding_tests deliberately recompiles src/app_version.cpp rather
# NOTE: app_version_padding_tests deliberately recompiles src/core/version/app_version.cpp rather
# than linking the app_version library target. This is required for the include-dir
# substitution to work — the canary needs to see generated_padding_canary/ instead of
# the live generated/ dir. If app_version ever gains a link dependency (e.g. a new
# pure-module link), the canary target_link_libraries must mirror it here.
add_executable(app_version_padding_tests
tests/test_app_version_padding.cpp
src/app_version.cpp)
src/core/version/app_version.cpp)
target_include_directories(app_version_padding_tests PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/generated_padding_canary src)
add_test(NAME app_version_padding_tests COMMAND app_version_padding_tests)
@@ -753,54 +819,65 @@ add_test(NAME velocity_curve_tests COMMAND velocity_curve_tests)
# 2i) Pure VST3-instrument helpers (Phase S1) — NO VST3, NO REAPER, NO SWELL/LICE.
# editor_geometry: the IPlugView LICE editor's rectangle layout + hit-test math
# (mirror of mode_switch/bank_grid). bridge_marshal: the REAPER VST-host bridge
# read marshalling — GetProjExtState result decode + a small JSON string-field
# reader (mirror of capture_paths/wav_trim). Both are unit-tested outside the DAW;
# the VST3 shell (src/vst/*) that draws/routes/invokes is DAW-verified.
# read marshalling — GetProjExtState result decode + the ONE grow-loop retry
# policy (readProjExtStateGrowing, Q-W5 rider T2-04) shared by the VST bridge
# read AND the extension's persist/usage_scan ext-state reads (hence linked into
# reaper_reasampler too). Both are unit-tested outside the DAW;
# the VST3 shell (shell/instrument/*) that draws/routes/invokes is DAW-verified.
# ---------------------------------------------------------------------------
add_library(editor_geometry STATIC src/vst/editor_geometry.cpp)
target_include_directories(editor_geometry PUBLIC src/vst)
add_library(editor_geometry STATIC src/core/instrument/ui/editor_geometry.cpp)
target_include_directories(editor_geometry PUBLIC src)
add_library(bridge_marshal STATIC src/vst/bridge_marshal.cpp)
target_include_directories(bridge_marshal PUBLIC src/vst)
add_library(bridge_marshal STATIC src/core/instrument/map/bridge_marshal.cpp)
target_include_directories(bridge_marshal PUBLIC src)
# embed_strip (Phase S6) — PURE layout + hit-test for the embedded TCP/MCP strip: the
# 128-key span -> zone-segment rects, point -> zone selection, and the level-band fill.
# The mirror of editor_geometry (whose Rect + contains() it reuses); unit-tested outside
# the DAW, while the embed shell (src/vst/reasampler_embed.cpp) marshals REAPER's embed
# the DAW, while the embed shell (src/shell/instrument/reasampler_embed.cpp) marshals REAPER's embed
# messages (paint bitmap + mouse coords) into it. Links editor_geometry for the shared Rect.
add_library(embed_strip STATIC src/vst/embed_strip.cpp)
target_include_directories(embed_strip PUBLIC src/vst)
add_library(embed_strip STATIC src/core/instrument/ui/embed_strip.cpp)
target_include_directories(embed_strip PUBLIC src)
target_link_libraries(embed_strip PUBLIC editor_geometry)
# sample_map (Phase S4) — PURE mapping logic for the Tier-0 instrument: the live bank
# blob -> selected sample (via the SHARED bank_book JSON parse, NOT a second parser),
# interleaved->mono downmix (the Tier-0 channel policy), the Tier-0 chromatic keymap
# build, and the selected-sample instance-state (de)serialization. Links the three pure
# modules it composes — bank_book (shared JSON), wav_trim (shared WAV parse), and
# sampler_core (the Keymap/SampleData it yields) — and NEITHER SDK. The VST3 shell
# (reasampler_processor.cpp) does the bridge read + file I/O off the audio thread, then
# calls these; the process callback stays allocation-free.
add_library(sample_map STATIC src/vst/sample_map.cpp)
target_include_directories(sample_map PUBLIC src/vst src)
# master_gain: the v8 component-state master-gain field validates against the pure taper's
# linear cap at the (de)serialization boundary (one cap, shared with the knob + the processor).
target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core master_gain)
# sample_map (Phase S4; RESOLUTION half since Q-W2v) — PURE mapping logic for the
# instrument: the live bank blob -> selected sample (via the SHARED bank_book JSON parse,
# NOT a second parser), interleaved->mono downmix (the channel policy), the Tier-0/zoned
# keymap builds, and the refs/performance resolution. Links the three pure modules it
# composes — bank_book (shared JSON), wav_codec (shared WAV parse), and sampler_core (the
# Keymap/SampleData it yields) — and NEITHER SDK. The VST3 shell (reasampler_processor)
# does the bridge read + file I/O off the audio thread, then calls these; the process
# callback stays allocation-free. The ComponentState codec is component_state_io below.
add_library(sample_map STATIC src/core/instrument/map/sample_map.cpp)
target_include_directories(sample_map PUBLIC src)
target_link_libraries(sample_map PUBLIC bank_book wav_codec sampler_core)
# component_state_io (Q-W2v split of sample_map, T4-13 ≡ T2-07) — the ComponentState
# ENVELOPE + zones-payload binary codec (envelope v1..v11, zones payload v1..v7, every
# lift preserved byte-identically). Split so the codec — which grows on every envelope
# bump and is shared with the EXTENSION's preset-blob path (instrument_drop) — links
# WITHOUT the voice engine: its deps are velocity_curve (the per-zone curve field) and
# master_gain (the v8 wire cap) only; sampler_core/pitch_shift object code never enters
# the extension binary. Its own test target linking exactly these is the structural proof.
add_library(component_state_io STATIC src/core/instrument/map/component_state_io.cpp)
target_include_directories(component_state_io PUBLIC src)
target_link_libraries(component_state_io PUBLIC velocity_curve master_gain)
# capture_browser (Phase S10) — PURE card-grid + bank-filter-tab layout + hit-test for the
# capture-first editor's default face. The mirror of mode_switch/editor_geometry: the fiddly
# grid/tab arithmetic lives here, unit-tested outside the DAW; the editor shell draws each
# card's peak thumbnail + name + badge and routes clicks into it. Links editor_geometry for
# the shared Rect + contains(). NEITHER SDK.
add_library(capture_browser STATIC src/vst/capture_browser.cpp)
target_include_directories(capture_browser PUBLIC src/vst)
add_library(capture_browser STATIC src/core/instrument/ui/capture_browser.cpp)
target_include_directories(capture_browser PUBLIC src)
target_link_libraries(capture_browser PUBLIC editor_geometry)
# keyboard_strip (Phase S10) — PURE key-span<->pixel mapping, root marker, zone-bar rects +
# edge-grab hit regions, and the drag-delta note resolver for the capture-first editor's
# keyboard strip (single-capture root-set) and the opt-in Zones panel (S10-Z). The mirror of
# embed_strip; links editor_geometry for the shared Rect. NEITHER SDK.
add_library(keyboard_strip STATIC src/vst/keyboard_strip.cpp)
target_include_directories(keyboard_strip PUBLIC src/vst)
add_library(keyboard_strip STATIC src/core/instrument/ui/keyboard_strip.cpp)
target_include_directories(keyboard_strip PUBLIC src)
target_link_libraries(keyboard_strip PUBLIC editor_geometry)
# waveform_view (Phase S11) — PURE frame<->pixel mapping, marker grab regions, drag-delta
@@ -808,8 +885,8 @@ target_link_libraries(keyboard_strip PUBLIC editor_geometry)
# (draggable start + loop markers over the picked capture's decoded PCM). The mirror of
# keyboard_strip; links editor_geometry for the shared Rect and peaks for the AudioSample
# alias the snap scans. NEITHER SDK.
add_library(waveform_view STATIC src/vst/waveform_view.cpp)
target_include_directories(waveform_view PUBLIC src/vst src)
add_library(waveform_view STATIC src/core/instrument/ui/waveform_view.cpp)
target_include_directories(waveform_view PUBLIC src)
target_link_libraries(waveform_view PUBLIC editor_geometry peaks)
# bank_sync (Phase S9/S8 reader) — PURE decision logic for the instrument's off-audio-thread
@@ -819,24 +896,25 @@ target_link_libraries(waveform_view PUBLIC editor_geometry peaks)
# setSelectedSampleId, component-state marker); this owns only the yes/no maths, unit-tested
# outside the DAW. Links assignment_request for the decoded AssignmentRequest it consumes.
# NEITHER SDK.
add_library(bank_sync STATIC src/vst/bank_sync.cpp)
target_include_directories(bank_sync PUBLIC src/vst src)
add_library(bank_sync STATIC src/core/instrument/map/bank_sync.cpp)
target_include_directories(bank_sync PUBLIC src)
target_link_libraries(bank_sync PUBLIC assignment_request)
target_link_libraries(bank_sync PRIVATE wire)
# browser_scroll (Phase S12) — PURE scroll-window + scrollbar-thumb + type-to-filter-search
# geometry LAYERED over the S10 capture_browser: the visible-card window, thumb rect +
# thumb-drag<->offset mapping, and the name-substring filter that composes with the bank
# filter. The mirror of capture_browser; links capture_browser (for BrowserLayout + the card
# metrics/cell rect) which pulls editor_geometry transitively. NEITHER SDK.
add_library(browser_scroll STATIC src/vst/browser_scroll.cpp)
target_include_directories(browser_scroll PUBLIC src/vst)
add_library(browser_scroll STATIC src/core/instrument/ui/browser_scroll.cpp)
target_include_directories(browser_scroll PUBLIC src)
target_link_libraries(browser_scroll PUBLIC capture_browser)
# note_entry (Phase S12) — PURE text->clamped-MIDI-note parse for the direct numeric entry of
# a zone's low/high/root (decimal integer OR note name under the C4==60 convention, clamped to
# [0,127]). No dependency beyond the standard library. NEITHER SDK.
add_library(note_entry STATIC src/vst/note_entry.cpp)
target_include_directories(note_entry PUBLIC src/vst)
add_library(note_entry STATIC src/core/instrument/map/note_entry.cpp)
target_include_directories(note_entry PUBLIC src)
# param_slider (Phase S12 + the S15/S16 control surfaces deferred here) — PURE control-surface
# layout + hit-test + normalized value<->pixel mapping for the editor parameter panel (the
@@ -844,16 +922,16 @@ target_include_directories(note_entry PUBLIC src/vst)
# mirror of keyboard_strip; links editor_geometry for the shared Rect. Deliberately engine-free
# (no sampler_core types) — the shell owns the control-id -> param binding + the value DOMAIN
# mapping. NEITHER SDK.
add_library(param_slider STATIC src/vst/param_slider.cpp)
target_include_directories(param_slider PUBLIC src/vst)
add_library(param_slider STATIC src/core/instrument/ui/param_slider.cpp)
target_include_directories(param_slider PUBLIC src)
target_link_libraries(param_slider PUBLIC editor_geometry)
# trigger_seam (Phase S-VIEW-3) — PURE Trigger-mode frames<->fraction converter for the TRIGGER
# SEAM documented in envelope_overlay.h: triggerPlayLength / framesToFadeFraction /
# fadeFractionToFrames. Owns the one formula so pack (draw) and unpack (commit) are provably
# consistent. No shell/LICE/REAPER types — only <cstdint>. NEITHER SDK.
add_library(trigger_seam STATIC src/vst/trigger_seam.cpp)
target_include_directories(trigger_seam PUBLIC src/vst)
add_library(trigger_seam STATIC src/core/instrument/map/trigger_seam.cpp)
target_include_directories(trigger_seam PUBLIC src)
# envelope_overlay (Phase S-VIEW-3) — PURE amp-envelope -> polyline geometry for the Sample-view
# envelope overlay: AHDSR (Gate) / fade+%-length (Trigger) params + the sample's wall-clock
@@ -861,16 +939,16 @@ target_include_directories(trigger_seam PUBLIC src/vst)
# The mirror of waveform_view / param_slider; links editor_geometry for the shared Rect.
# Deliberately engine-free (no sample_map / sampler_core) — the shell packs the zone's stored
# AdsrSeconds / TriggerParams into the small AmpEnvelope view struct. NEITHER SDK.
add_library(envelope_overlay STATIC src/vst/envelope_overlay.cpp)
target_include_directories(envelope_overlay PUBLIC src/vst)
add_library(envelope_overlay STATIC src/core/instrument/ui/envelope_overlay.cpp)
target_include_directories(envelope_overlay PUBLIC src)
target_link_libraries(envelope_overlay PUBLIC editor_geometry)
# envelope_edit (Phase S-VIEW-3) — PURE node hit-test + pixel-delta -> clamped-param inverse map
# for the draggable envelope nodes: monotonic-in-time + range-clamped (against caller-supplied
# slider maxima) so a drag can never produce a param a slider couldn't. The mirror of card_drag;
# links envelope_overlay for the shared node vocabulary + the timeToX/levelToY maps. NEITHER SDK.
add_library(envelope_edit STATIC src/vst/envelope_edit.cpp)
target_include_directories(envelope_edit PUBLIC src/vst)
add_library(envelope_edit STATIC src/core/instrument/ui/envelope_edit.cpp)
target_include_directories(envelope_edit PUBLIC src)
target_link_libraries(envelope_edit PUBLIC envelope_overlay)
# knob_deck (Wave B FB1, r11) — PURE knob-deck layout + hit-test for the recomposed Sample face:
@@ -878,23 +956,23 @@ target_link_libraries(envelope_edit PUBLIC envelope_overlay)
# toggle), deterministic whole-group wrap, point -> control-id hit-test. The mirror of
# action_bar / param_slider; links editor_geometry for the shared Rect. Engine-free — cells and
# toggles carry opaque shell-owned control ids. NEITHER SDK.
add_library(knob_deck STATIC src/vst/knob_deck.cpp)
target_include_directories(knob_deck PUBLIC src/vst)
add_library(knob_deck STATIC src/core/instrument/ui/knob_deck.cpp)
target_include_directories(knob_deck PUBLIC src)
target_link_libraries(knob_deck PUBLIC editor_geometry)
# curve_popup (Wave B FB1, r11) — PURE centered-sheet geometry for the velocity-curve popup
# editor: size clamps (60%/55% of window, 360..520 x 260..380), title row + close button, the
# curve-box border rect, and the outside-sheet dismissal test. The mirror of overflow_menu;
# links editor_geometry for the shared Rect. NEITHER SDK.
add_library(curve_popup STATIC src/vst/curve_popup.cpp)
target_include_directories(curve_popup PUBLIC src/vst)
add_library(curve_popup STATIC src/core/instrument/ui/curve_popup.cpp)
target_include_directories(curve_popup PUBLIC src)
target_link_libraries(curve_popup PUBLIC editor_geometry)
# master_gain (Wave B FB1) — PURE dB<->linear<->knob-taper math for the post-mixer master gain
# (-inf..+24 dB; norm 0 = TRUE zero linear). One formula shared by the editor's Gain knob, the
# v8 component-state wire cap, and the processor's applied gain. Standard library only. NEITHER SDK.
add_library(master_gain STATIC src/vst/master_gain.cpp)
target_include_directories(master_gain PUBLIC src/vst)
add_library(master_gain STATIC src/core/instrument/engine/master_gain.cpp)
target_include_directories(master_gain PUBLIC src)
add_executable(editor_geometry_tests tests/test_editor_geometry.cpp)
target_link_libraries(editor_geometry_tests PRIVATE editor_geometry)
@@ -908,13 +986,21 @@ add_executable(embed_strip_tests tests/test_embed_strip.cpp)
target_link_libraries(embed_strip_tests PRIVATE embed_strip)
add_test(NAME embed_strip_tests COMMAND embed_strip_tests)
# sample_map: the S4 mapping heart. Links ONLY sample_map (+ its pure deps) — NEITHER
# the VST3 SDK nor the REAPER SDK — the same structural plain-data-boundary proof the
# sampler_core test enforces.
# sample_map: the S4 mapping heart. Links ONLY sample_map + component_state_io (+ their
# pure deps) — NEITHER the VST3 SDK nor the REAPER SDK — the same structural
# plain-data-boundary proof the sampler_core test enforces. (The historical suite spans
# both halves of the Q-W2v split; the frozen-format assertions live here unmodified.)
add_executable(sample_map_tests tests/test_sample_map.cpp)
target_link_libraries(sample_map_tests PRIVATE sample_map)
target_link_libraries(sample_map_tests PRIVATE sample_map component_state_io)
add_test(NAME sample_map_tests COMMAND sample_map_tests)
# component_state_io (Q-W2v): the ComponentState codec's OWN target. Links ONLY
# component_state_io (velocity_curve + master_gain transitively) — deliberately NO
# sampler_core/pitch_shift — the structural proof the codec is engine-free (T2-07).
add_executable(component_state_io_tests tests/test_component_state_io.cpp)
target_link_libraries(component_state_io_tests PRIVATE component_state_io)
add_test(NAME component_state_io_tests COMMAND component_state_io_tests)
add_executable(capture_browser_tests tests/test_capture_browser.cpp)
target_link_libraries(capture_browser_tests PRIVATE capture_browser)
add_test(NAME capture_browser_tests COMMAND capture_browser_tests)
@@ -1004,42 +1090,61 @@ set(LICE_SRC
)
add_library(reaper_reasampler MODULE
src/main.cpp
src/capture.cpp
src/capture_realtime.cpp
src/realtime_record.cpp
src/persist.cpp
src/bank_panel.cpp
src/draw_kit.cpp
src/mode_switch.cpp
src/tab_strip.cpp
src/insert.cpp
src/insert_plan.cpp
src/app/main.cpp
src/shell/capture/capture.cpp
src/shell/capture/capture_orchestrator.cpp
src/shell/capture/capture_batch.cpp
src/shell/capture/scope_resolve.cpp
src/shell/capture/realtime_lifecycle.cpp
src/shell/capture/capture_realtime_shell.cpp
src/shell/capture/capture_realtime_finalize.cpp
src/core/capture/capture_realtime.cpp
src/shell/persist/session.cpp
src/shell/persist/ext_state_io.cpp
src/shell/persist/prune_fs.cpp
src/shell/bank_ops/bank_ops.cpp
src/shell/panel/panel_audition.cpp
src/shell/panel/panel_bank_ops.cpp
src/shell/panel/panel_drag.cpp
src/shell/panel/panel_input.cpp
src/shell/panel/panel_layout.cpp
src/shell/panel/panel_render.cpp
src/shell/panel/panel_thumbnails.cpp
src/shell/panel/panel_window.cpp
src/shell/panel/draw_kit.cpp
src/core/view/mode_switch.cpp
src/core/ui/tab_strip.cpp
src/shell/capture/insert.cpp
src/core/capture/insert_plan.cpp
${LICE_SRC}
src/view_mode_model.cpp
src/view_tree.cpp
src/view.cpp
src/track_guid.cpp
src/provenance_shell.cpp
src/guid_diff.cpp
src/lane_keys.cpp
src/item_read.cpp
src/actions.cpp
src/core/view/view_mode_model.cpp
src/core/view/view_tree.cpp
src/shell/view/view.cpp
src/shell/capture/track_guid.cpp
src/shell/capture/provenance_shell.cpp
src/core/view/guid_diff.cpp
src/core/view/lane_keys.cpp
src/shell/capture/item_read.cpp
src/shell/actions/action_registry.cpp
src/shell/actions/design_view_actions.cpp
src/shell/actions/bank_actions.cpp
src/shell/actions/prune_action.cpp
src/ingest.cpp
src/bank_book.cpp
src/owned_manifest.cpp
src/drag_out_win.cpp
src/instrument_drop_win.cpp
src/action_bar.cpp
src/footer_bar.cpp
src/overflow_menu.cpp
src/mode_enable.cpp
src/tooltip.cpp
src/card_meta.cpp
src/card_drag.cpp
src/usage_scan.cpp
src/core/model/bank_book.cpp
src/core/model/bank_book_json.cpp
src/core/model/owned_manifest.cpp
src/shell/actions/drag_out_win.cpp
src/shell/actions/instrument_drop_win.cpp
src/core/ui/action_bar.cpp
src/core/ui/footer_bar.cpp
src/core/ui/overflow_menu.cpp
src/core/ui/mode_enable.cpp
src/core/ui/tooltip.cpp
src/core/ui/card_meta.cpp
src/core/ui/card_drag.cpp
src/shell/persist/usage_scan.cpp
)
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage)
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync bridge_marshal sample_usage)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or
# "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels'
@@ -1142,16 +1247,30 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
# --- 5b) The VST3 module (loadable .vst3 DLL). -------------------------------
add_library(reasampler_vst MODULE
src/vst/vst_entry.cpp
src/vst/reasampler_processor.cpp
src/vst/reasampler_editor.cpp
src/vst/reasampler_embed.cpp
src/vst/reaper_bridge.cpp
src/shell/instrument/vst_entry.cpp
# The processor family (Q-W2v, T4-12): lifecycle + process() whole (T4-29), with
# component-state I/O and the off-thread reload/publish family in sibling TUs.
src/shell/instrument/reasampler_processor.cpp
src/shell/instrument/processor_state.cpp
src/shell/instrument/processor_reload.cpp
# The editor family (Q-W2v, T4-11): eight face-axis TUs — session/bridge state,
# param plumbing, paint x2 (Sample | Browse+Zone), input x2 (same axis), platform;
# the eighth (editor_layout) hoisted PURE into core/instrument/ui/editor_geometry
# + browser_scroll (T2-06). Shared internals: editor_internal.h (no TU).
src/shell/instrument/editor_session.cpp
src/shell/instrument/editor_controls.cpp
src/shell/instrument/editor_paint_sample.cpp
src/shell/instrument/editor_paint_browse_zone.cpp
src/shell/instrument/editor_input_sample.cpp
src/shell/instrument/editor_input_browse_zone.cpp
src/shell/instrument/editor_platform.cpp
src/shell/instrument/reasampler_embed.cpp
src/shell/instrument/reaper_bridge.cpp
# The Phase L (L1) draw kit — the ONE source of drawing the editor + embed shells
# now consume (L3). Compiled into the MODULE (not a static lib) for the same reason
# bank_panel does: it is the only kit TU touching LICE, and its cached-font engine
# (LICE_CachedFont) needs the LICE_SRC TUs below linked into this artifact.
src/draw_kit.cpp
src/shell/panel/draw_kit.cpp
# SDK module entry — compiled into the module (not the static lib) so the
# InitDll/ExitDll dll exports survive the link (see vst3_sdk note above).
${VST3_SDK}/public.sdk/source/main/dllmain.cpp
@@ -1160,10 +1279,10 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
)
# editor_geometry + bridge_marshal: the pure spike helpers. sample_map (S4): the pure
# bank->keymap mapping + state (de)ser the processor drives off the audio thread;
# linking it pulls its pure deps (bank_book, wav_trim, sampler_core, bank_model,
# linking it pulls its pure deps (bank_book, wav_codec, sampler_core, bank_model,
# peaks) transitively. capture_paths: the shared M4 path resolution (resolveBankFile /
# projectDirOfRpp) the bridge + processor use. Its PUBLIC include dirs (src, src/vst)
# give the shell TUs their headers (ext_keys.h, bank_book.h, sampler_core.h, ...).
# projectDirOfRpp) the bridge + processor use. Its PUBLIC include dir (src)
# gives the shell TUs their headers (ext_keys.h, bank_book.h, sampler_core.h, ...).
# embed_strip (S6): the pure inline-strip layout + hit-test the embed shell marshals
# into; it links editor_geometry transitively (shared Rect).
# app_version: ext_keys.h's channel-derived namespace accessor (V4) delegates to it, so
@@ -1198,13 +1317,15 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
# sample_usage (pS-usage): the usage-record wire + publish plan the processor's
# reloadInstrument publishes through the bridge (the one sanctioned VST-side write).
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
sample_map capture_paths embed_strip app_version capture_browser keyboard_strip
sample_map component_state_io capture_paths embed_strip app_version capture_browser keyboard_strip
waveform_view bank_sync browser_scroll note_entry param_slider
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
knob_deck curve_popup master_gain sample_usage)
knob_deck curve_popup master_gain sample_usage file_bytes)
# SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge;
# WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC.
target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC})
# src: the Q-W1 rooted include convention ("core/..." / "shell/..."). src/vst is GONE
# (Q-W2v): the two former god TUs live split under shell/instrument/.
target_include_directories(reasampler_vst PRIVATE src ${SDK_INC} ${WDL_INC})
# A .vst3 is a DLL with a .vst3 extension and no lib-prefix. OUTPUT_NAME is the on-disk
# product name, channel-forked (S18): reasampler_9000.vst3 (stable, byte-identical to
# pre-S18) / reasampler_9000_beta.vst3 (beta) — driven by REASAMPLER_VST_OUTPUT_NAME set
+373
View File
@@ -2883,3 +2883,376 @@ now covered by the same mechanism.
- [x] `reasampler_processor`: `reloadInstrument` decodes from `SampleRefs_` (bank-free); `SampleRefs` table added to `ComponentState` v10; heal timer + poll-to-play removed; preview summing removed; `PreviewCard` member removed.
- [x] `sample_map`: `SampleRefs` type + `SampleRefEntry` struct; `refreshRefsFromBank`, `retainRefs`, `resolvePerformanceFromRefs` (decodes directly from refs, no bank blob required); `kComponentStateVersion` bumped to 10; pre-v10 lift to empty refs.
- [x] `ComponentState` v10 serialization round-trip: new `sampleRefs` field; pre-v10 blobs migrate on load.
---
## Q-W0 fix-now remediations — closes Q-W0 (2026-07-28)
> **Merged to `phase-q` 2026-07-28 (commit `546927e`).** Closes the Q-W0 sub-gate — Q-W0
> (pre-restructure functional + DSP quality audit) is now fully closed. Part of the phase-q
> integration that later reached 60/60 green with Q-W1.
**Goal:** Land the six Daniel-approved fix-now remediations from the Q-W0 audit triage, plus
seven review riders surfaced during their review, before any structural Q-wave begins.
**Verify:** Each remediation lands with its module's CTest target green; the audible DSP fixes
carry a stated before/after listening check.
- [x] **T1-01** — linked-lag stereo SOLA + follower self-heal fallback.
- [x] **T1-03** — playable-span prime bound.
- [x] **T1-09** — declick dead-state removal.
- [x] **T2-01a** — provenance wire-cursor hardening backport.
- [x] **T3-01** — rate-derived gain ramp.
- [x] **T3-03** — rate-derived fade ceiling.
- [x] Seven additional review riders landed in the same merge (not individually itemized in
this entry).
**Notes/decisions:**
- Landing these six fix-nows (plus the review riders) closes Q-W0 entirely — audit, triage,
sign-off, and remediation are all complete — and opens Q-W1.
---
## Q-W1 — safe opener: `core/json` extraction + directory/namespace layout (2026-07-29)
> **Merged to `phase-q` 2026-07-29. Integrated suite 60/60 green.** First structural wave of
> Phase Q; unblocked by the Q-W0 fix-now remediations closing 2026-07-28.
**Goal:** The zero-god-module-risk opener. Extract a pure `core/json` module (parser +
serializer) and delete the five hand-rolled JSON decoders — the four `Parser`s in `bank_model`
/ `bank_book` / `view_mode_model` / `owned_manifest` plus `tail_control`'s fifth decoder; impose
the settled `core/`/`shell/`/`app/` directory layout + sub-namespaces on the clean pure libs,
clean shells, and clean VST pure libs — pure relocation, no logic change.
**Verify:** CTest green at every commit (60/60). The five duplicate JSON decoders are gone,
replaced by one `core/json` consumed by all five former consumers; round-trip serialization is
byte-identical to before. Every relocated clean module compiles and its test executable passes
unmoved. No REAPER type crosses into any `core/` file.
- [x] `core/json` extracted (`json::Reader`/`json::Writer`); `bank_model`, `bank_book`,
`view_mode_model`, `owned_manifest`, and `tail_control` rewired onto it; the five hand-rolled
JSON decoders deleted.
- [x] Wire-`Cursor` family collapsed into `core/wire` — one hardened survivor codec.
- [x] Shared `readFileBytes` pure helper added; linked by both artifacts.
- [x] `slot_map` extracted from `bank_book`, with its own `slot_map_tests`.
- [x] ~50 clean modules relocated into `core/{model,view,capture,audio,ui,reclaim,version,json,
util,wire}/`, `core/instrument/{engine,map,ui}/`, `shell/{capture,panel,view,persist,actions,
instrument}/`; `main.cpp` moved to `app/`. Sub-namespaces applied to every relocated clean
module.
- [x] Rect unification: one concrete `ui::Rect` + `contains()` + per-role aliases — the
XYWH-vs-LTRB fork retired, `footer_bar.h`'s "NAME NOTE" collision workaround gone.
- [x] `clamp01` deduplicated.
- [x] Naming riders: survivor JSON parser minted as `json::Reader`/`json::Writer`; `BankIndex`
renamed to `BankModel` (verified by `bank_model_tests`).
- [x] `reasampler_uid.h` relocated to `core/wire/`.
- [x] Interim `core/namespaces.h` shim added for the six not-yet-split god TUs; each downstream
split wave (Q-W2 onward) retires its own includes of it as that module splits.
**Notes/decisions:**
- **Riders explicitly skipped/deferred:** T4-22 (`hitIndex` hit-test template) — not trivial,
deferred as an opportunistic follow-on once the rect unification is in use downstream; T4-06
(`view_mode_model` planner split) — optional, deferred; T4-09 (`view_lanes` split) —
deferred (in scope only if a later wave touches `view.cpp` anyway).
- **Open residual — `bank_book.cpp` still 737 LOC.** The serialize/deserialize seam is
identified but blocked on a `nameKey` linkage design decision, escalated to Daniel and
**pending** as of 2026-07-29. Downstream waves touching `bank_book` should check this
residual before assuming the split is finished.
---
## Q-W2 — split `bank_panel.cpp` (the biggest extension god-module — 3459 LOC at the Q-W0 census) (2026-07-29)
> **Merged to `phase-q` 2026-07-29 (merge of `pq-w2-panel`). Integrated suite 61/61 green,
> reviewed-approved.**
**Goal:** Split the largest extension god-module (8+ responsibilities) along the audit's named
seams — eight TUs (Q-5 SETTLED with the T4-01 reshape, Daniel 2026-07-28): `panel_render` /
`panel_thumbnails` / `panel_audition` / `panel_input` / `panel_bank_ops` / `panel_window` +
`panel_layout` (toolbar/footer/menu rects + row/cluster builders + region geometry glue) +
`panel_drag` (the card-drag/hover state machine — it already has a pure mirror, `card_drag`) —
without the two new seams, `panel_render` (~700) and `panel_input` (~800) would ship over the
~600 ceiling on day one. Split the fat `bank_panel.h` alongside (Interface Segregation).
Preserve the audition hot path as a direct call-through, never virtual. `panel_bank_ops` becomes
the single home for the bank-CRUD verbs that W4 will dedupe `actions.cpp` against. CONTEXT.md
§Phase Q (bank_panel split seams; hot-path audition guardrail). See
`docs/product/code-organization.md` §2.1, §5.
**Verify:** CTest green at every commit. Each seam is its own TU under `shell/panel/` and every
TU lands under the ~600-line ceiling (the Q-5 acceptance bar); the panel draws, thumbnails,
auditions, handles input, does bank ops, and manages its window exactly as before (no behavior
change — verify in DAW that the panel is visually and interactively unchanged). Audition/preview
call path stays a direct call-through (no virtual dispatch, no added header→TU indirection on
the preview path). The ~20-function public API is now segmented across the split headers.
- [x] Split rendering (`draw*`/`paint*`) → `panel_render`; thumbnail compute+cache →
`panel_thumbnails`; toolbar/footer/menu rect + row/cluster builders + region geometry →
`panel_layout` (new seam, T4-01).
- [x] Split the audio audition/preview engine → `panel_audition` — direct call-through, not
virtual; preview idle path unchanged.
- [x] Split input handling (mouse/key/wheel) + new-content detection → `panel_input`; window
lifecycle + OS drag-out/drop-target → `panel_window`; the card-drag/hover state machine →
`panel_drag` (new seam, T4-01). Per-mouse-move work stays plain free-function calls (T4-28).
- [x] Extract bank-CRUD verbs → `panel_bank_ops` (the future single owner; W4 dedupes
`actions.cpp` against it). Split `bank_panel.h` into per-seam headers (I).
- [ ] Verify in DAW: panel unchanged; CTest green; no hot-path indirection added; all eight TUs
under the ~600 ceiling. — **PENDING**: in-DAW panel-parity verification not yet performed on
`phase-q` (deferred by design).
**Notes/decisions:**
- **Recorded ceiling overages (reviewer-endorsed, preserved as a durable record per CONTEXT's
"silent overshoot is not legitimate" rule):** `panel_input.cpp` 636, `panel_render.cpp` 613,
`panel_state.h` 608 — overage is comment volume; non-comment lines are ~322369 per file; no
honest seam remains; bisection was rejected.
- **Review note for the Q-W4 planning record:** `panel_bank_ops`'s verbs still embed
prompts/panel-state nudges — Q-W4's dedupe needs promptless inner verbs (`renameBank(id,name)`
etc.), not a call-site swap; `promptText`/`mintBankId` are byte-identical twins with
`actions.cpp` and are the cheapest first dedupe.
- ~50 TU-private helpers wrapped in anonymous namespaces (review follow-up landed in the same
merge).
---
## Q-W2v — split the VST god-modules (2026-07-29)
> **Merged to `phase-q` 2026-07-29 (merge of `pq-w2v-vst`). Integrated suite 61/61 green,
> reviewed-approved.**
**Goal:** Close the audit's structural scope gap: the VST artifact's god-modules had no owning
wave, and `reasampler_editor.cpp` (3065 LOC) is the largest file in the repo. Split the editor
into eight TUs along the Sample/Browse/Zone face axis (T4-11): `editor_session` /
`editor_controls` / `editor_layout` (pure-candidate hoist into the existing pure homes —
`editor_geometry` is the named owner; this discharges T2-06's stranded-layout-math finding) /
`editor_paint_sample` / `editor_paint_browse_zone` / `editor_input_sample` /
`editor_input_browse_zone` / `editor_platform`. Split `reasampler_processor.cpp` (1164 LOC) into
three TUs (T4-12): `processor_state` / `processor_reload` / lifecycle+`process()` kept whole.
Split `sample_map` into resolution core vs the `component_state_io` binary codec + matching
header split (T4-13 ≡ T2-07 — the codec grows every envelope bump; the extension stops linking
the whole voice engine to serialize one preset blob). `sampler_core.cpp` stays whole (968 LOC) —
a DOCUMENTED hot-path exception to the ~600 ceiling (T4-14/T4-27: envelope `tick()`s run
per-voice-per-sample; same-TU definition is what lets the compiler inline the stack, no LTO in
the build; a by-class split is the exact heuristic-(3) dispatch blowout); its header splits into
`zone_params.h` + `sampler_core.h`. The `core/wire` LE byte-codec template (`putLE`/`readLE`,
T4-20) lands here with its biggest consumer. CONTEXT.md §Phase Q (VST split seams; `sampler_core`
exception).
**Verify:** CTest green at every commit. Editor and processor behave identically in DAW (visual
+ interactive parity across all three faces; `process()` audio unchanged). Every new TU lands
under the ~600-line ceiling except the one documented `sampler_core.cpp` exception. `process()`
+ its per-block helpers stay one TU; the atomic-pointer-swap reload pattern gains no virtual seam
(T4-29); no dispatch-stack blowout anywhere (heuristic 3).
- [x] Split `reasampler_editor.cpp` → the eight face-axis TUs; hoist `editor_layout`'s pure
geometry into the existing pure homes (`editor_geometry` — discharges T2-06).
- [x] Split `reasampler_processor.cpp` → `processor_state` / `processor_reload` /
lifecycle+`process()` whole; no virtual seam on the atomic-swap pattern (T4-29).
- [x] Split `sample_map` → resolution core + `component_state_io` codec (+ header split); the
extension's preset-blob path stops linking the voice engine (T4-13 ≡ T2-07).
- [x] `sampler_core`: split `zone_params.h` out of the header; TU stays whole — documented
exception (T4-14/T4-27), recorded in the wave brief so nobody "fixes" it later.
- [x] Land the `core/wire` LE byte-codec template (`putLE`/`readLE`, T4-20) with
`component_state_io`; other consumers rewire opportunistically.
- [x] Rider: adopt the pure `ThumbnailKey` on the VST editor side (T2-10).
- [ ] Verify in DAW: editor + processor unchanged; CTest green; ceiling met (one documented
exception); no added dispatch. — **PENDING**: in-DAW editor/processor-parity verification not
yet performed on `phase-q` (deferred by design).
**Notes/decisions:**
- Golden full-blob v11 fixture pins the `component_state_io` codec bytes.
- The `src/vst/` directory is gone — all VST sources now live under the Q-W1 `core/instrument/`
and new `shell/instrument/` layout.
- **Deferred/known:** `component_state_io.h` still includes `sample_map.h`→`sampler_core.h`
transitively (T2-07's header half — future work); the `engine` namespace is deferred
(`sampler_core` stays flat `reasampler`); capture-side LE rewires are left for the capture
family.
---
## Q-W3 — split `main.cpp` (hoist orchestration; leave main = pointers + entry + dispatch) (2026-07-29)
> **Merged to `phase-q` 2026-07-29 (merge of `pq-w3-main`). Integrated suite 61/61 green,
> reviewed-approved.**
**Goal:** Reduce `main.cpp` (1897 LOC at the Q-W0 census) to its actual job — API pointers +
`ReaperPluginEntry` + dispatch — by hoisting four TUs (T4-02 reshape, SETTLED with Q-5, Daniel
2026-07-28 — the planned three left `capture_orchestrator` at ~885, over the ceiling):
`capture_orchestrator` (`RunCapture` / `captureAndIndexOne` / `renderOffline` / single-capture +
realtime/insert action bodies — lands ~450), `capture_batch` (the batch family +
`RunRecaptureFromSource` + the two RAII selection guards — recapture is planner-driven like batch
and shares the guard machinery), `scope_resolve` (`resolveRange`/`resolveRazorRange`/
`collectSelectedTracks` + provenance assembly inputs), and `realtime_lifecycle` (the
realtime-capture state machine + globals). `FxBypassGuard` moves out but stays a stack RAII
object (precision-critical); the realtime idle tick stays a single pointer test. Q-W0 riders
owned by this wave (all SETTLED 2026-07-28): delete `ICaptureBackend` (T4-26 — one deriver, zero
polymorphic call sites; `OfflineRenderBackend` becomes concrete; the CLAUDE.md/CONTEXT "two
backends behind one interface" correction rides this wave's own commit, not earlier); the shared
`stampCaptureSample` capture-epilogue dedupe (T2-09); the `capture_realtime_finalize` split
riding the Q-9 rename (T4-08); the `makeUniqueTag` per-session monotonic-counter fix (T1-11); and
the WAV/RIFF consolidation (audit §4e) — one pure `wav_codec` owner (walker + layout + build +
patch), absorbing `ingest.cpp`'s pure WAV build helpers (T2-08 / T4-23 / T4-10). CONTEXT.md
§Phase Q (main split seams; FxBypassGuard + realtime-tick guardrails). See
`docs/product/code-organization.md` §2.1, §3.
**Verify:** CTest green at every commit. Capture (offline + realtime + batch + recapture) behaves
identically in DAW; the null test still nulls, bit-identical repeats still match (the precision
invariants `FxBypassGuard` protects are unchanged); capture ≠ placement holds (no hoisted `Run*`
path gains an `InsertMedia` call). The realtime idle fast-path is still a single pointer test.
`main.cpp` is now pointers + entry + dispatch only. The four hoisted TUs + `wav_codec` land under
the ~600 ceiling; the WAV/RIFF layout has one pure owner (the dedup-by-hash and null-test
invariants now rest on one implementation); `ICaptureBackend` is gone with no behavior change and
the CLAUDE.md/CONTEXT description is corrected in the same commit.
- [x] Hoist capture orchestration → `capture_orchestrator` (`shell/capture/`); keep
`FxBypassGuard` a stack RAII object as it moves (precision-invariant-critical).
- [x] Hoist the batch family + `RunRecaptureFromSource` + the two RAII selection guards →
`capture_batch` (fourth hoist, T4-02) so `capture_orchestrator` lands ~450.
- [x] Hoist scope/source resolution + provenance assembly inputs → `scope_resolve`.
- [x] Hoist the realtime-capture lifecycle state machine + globals → `realtime_lifecycle`; idle
tick stays a single pointer test.
- [x] Leave `main.cpp` = API-pointer ownership + `ReaperPluginEntry` + dispatch; move to `app/`.
- [x] Naming rider (Q-9 — SETTLED, Daniel 2026-07-28: yes): align the `capture_realtime` (shell)
/ `realtime_record` (pure) word-order inversion to the house shell↔core convention — the pure
module takes the stem `capture_realtime`, the shell takes the suffix (`drag_out`↔`drag_out_win`
is the model). Split `capture_realtime_finalize` (async lifecycle vs file-side finalize) in the
same surgery (T4-08). No rename on a file this wave isn't already touching (Q-7).
- [x] Delete `ICaptureBackend` (T4-26): `OfflineRenderBackend` becomes concrete; correct the
CLAUDE.md/CONTEXT "two backends behind one interface" description in the same commit.
- [x] Dedupe the capture-stamp epilogue → shared `stampCaptureSample` (T2-09 — the divergent bits
stay in the realtime caller); fix `makeUniqueTag` with a per-session monotonic counter, both
call sites (T1-11 — same-second batch captures currently collide silently).
- [x] WAV/RIFF consolidation rider (audit §4e — SETTLED, Daniel 2026-07-28): one pure `wav_codec`
owner (chunk walker + layout + build + patch), absorbing `ingest.cpp`'s pure WAV/PCM build
(T4-10 — the ingest shell drops to ~500 and the WAV build gains a test target).
- [ ] Verify in DAW: null test nulls, bit-identical repeats match, capture≠placement holds; CTest
green; no realtime-tick branch-shape change. — **PENDING**: in-DAW null-test /
bit-identical-repeats verification not yet performed on `phase-q` (deferred by design).
**Notes/decisions:**
- `wav_codec_tests` replaces `wav_trim_tests`; `capture_realtime_tests` replaces
`realtime_record_tests`.
- **Known open:** `wav_trim.h`'s transitional forwarding shim still has three live includers
(`sample_map.h`, `editor_session.cpp`, `processor_reload.cpp`) — repoint-and-retire is a named
follow-up; `ingest.cpp` trimmed to 567 LOC but keeps the `namespaces.h` shim (`ingest` + `view`
remain the shim's unowned consumers).
---
## Q-W4 — split `actions.cpp` + dedupe bank verbs against `panel_bank_ops` (2026-07-29)
> **Merged to `phase-q` 2026-07-29 (merge of `pq-w4-actions`). Integrated suite 61/61 green,
> reviewed-approved.**
**Goal:** Split the two unrelated command-id families in one TU (1016 LOC at the Q-W0 census —
T4-03: the planned seams still land sub-600, no reshape) into
`design_view_actions` / `bank_actions` / `prune_action`, and **dedupe** `actions.cpp`'s own
`promptText`/`mintBankId` and bank verbs against the `panel_bank_ops` single-owner established in
Q-W2. `prune_action` keeps the `doBankPruneFolder` deletion authority contract intact (routes to
`persist`'s `prune_fs` after W5). CONTEXT.md §Phase Q (actions split seams; bank-verb dedupe).
See `docs/product/code-organization.md` §2.1, §2.4.
**Verify:** CTest green at every commit. Every action fires identically in DAW (Design View
family; multi-bank create/rename/reorder/delete/evacuate/activate/move/copy/remove; prune). The
bank-CRUD verbs have **one** implementation home (no `bank_panel`/`actions` duplication). Each
bank verb still wraps its mutation in one batched undo point; the prune action still writes no
ext state and opens no undo point. Command-id strings are **unchanged** (FOREVER-STABLE
contract — a reorg must not touch a shipped command id).
**Depends on:** Q-W2 (`panel_bank_ops` is the dedupe target). Independent of Q-W3.
- [x] Split `actions.cpp` (1019 LOC) → `design_view_actions` (toggle/activate/tag/untag/
showBoth/moveItems), `bank_actions` (bank CRUD family), `prune_action` (`doBankPruneFolder` —
the single file-deletion action), plus a fourth shared `action_registry` TU under
`shell/actions/`.
- [x] Dedupe `actions.cpp`'s `promptText`/`mintBankId` + bank verbs against `panel_bank_ops`
(one owner); no command-id string changed. Bank verbs reshaped to **promptless inner verbs**
(one mutation home, two UX skins — panel and actions each keep their exact prior UX);
`promptText` renamed `promptBankName`; `persistBankOp`/`persistBook` gain null-session guards.
- [x] `prune_action` verified a clean deletion-authority isolate (no `Undo_*`, no ext-state
writes). Command-id suffixes/display phrases verified byte-identical in review.
- [ ] Verify in DAW: all action families fire unchanged; one bank op = one Ctrl-Z; prune still
no-undo/no-ext-state; CTest green. — **PENDING**: in-DAW verification not yet performed on
`phase-q` (deferred by design).
**Notes/decisions:**
- **Review 🟡 (resolved in Q-W6):** two session pointers / a null-session-as-model-rejection
misreport (unreachable today) — resolved by Q-W6's `bank_ops` lift.
---
## Q-W5 — split `persist.cpp` (isolate the single file-deletion authority into `prune_fs`) (2026-07-29)
> **Merged to `phase-q` 2026-07-29 (merge of `pq-w5-persist`). Integrated suite 61/61 green,
> reviewed-approved.**
**Goal:** Split `persist.cpp` (852 LOC at the Q-W0 census — T4-04: seams unchanged; the
pS-usage growth landed exactly where this wave isolates it; 5 responsibilities) into `session`
(lifecycle+poll, `BeginLoadProjectState` reload hook), `ext_state_io` (the ext-state ↔ JSON
serialization bridge + GUID minting + folder relocation), and **`prune_fs`** (prune scanning +
`deleteOrphanFile` via `SHFileOperationW`). The split **concentrates** the byte-deleting
authority into one obvious module — it must never spread it. **Q-W0 rider (T2-04, SETTLED
2026-07-28):** generalize the `GetProjExtState` grow-loop retry policy into `bridge_marshal`'s
pure decode home (or its `core/` successor) and rewire all three hand-rolled copies —
`usage_scan`'s prune-safety-adjacent copy included. CONTEXT.md §Phase Q (persist split seams;
deletion-authority isolation). See `docs/product/code-organization.md` §2.1, §7.
**Verify:** CTest green at every commit. Session save/load/undo-reload, ext-state round-trip,
folder relocation, and prune deletion all behave identically in DAW. **File deletion lives in
exactly one module (`prune_fs`)** — the single-file-deletion-authority invariant is *improved*
(concentrated), never diluted. Relative-paths-only persistence is unchanged.
**Depends on:** Q-W1. Best after Q-W4 (so `prune_action` routes cleanly to `prune_fs`), but
independently landable.
- [x] Split `persist.cpp` (853 LOC) → `session` (lifecycle/poll + `projectconfig` reload hook),
`ext_state_io` (serialization bridge + GUID minting + folder relocation), under `shell/persist/`
+ `persist_internal.h`.
- [x] Isolate prune scanning + `deleteOrphanFile` (`SHFileOperationW`) → **`prune_fs`** — the
deletion authority concentrated in exactly one anonymous-namespace function in `prune_fs.cpp`,
verified tree-wide; the prune fail-safe chain stays byte-intact.
- [x] Dedupe the `GetProjExtState` grow-loop ×3 (T2-04): unified as a header-only template, all
three copies rewired (`usage_scan`'s start cap raised 4KB→64KB, allocation-only, verified
equivalent); the grow-loop gains a defensive NUL.
- [x] Rider: the Q-W1 `bank_book_json` residual lands via a private static `nameKey`
(Daniel-approved option a) — `bank_book.cpp` is now ~462 LOC.
- [ ] Verify in DAW: save/load/undo-reload/relocation/prune unchanged; deletion authority is one
module; relative-paths-only holds; CTest green. — **PENDING**: in-DAW verification not yet
performed on `phase-q` (deferred by design).
**Notes/decisions:**
- `persist.h` is kept as a compat umbrella for parallel safety across the in-flight waves
(retired in Q-W6); deletion-authority wording is scoped precisely in headers.
---
## Q-W6 — OCP registration-table + residual fat-header (I) splits (2026-07-29)
> **Merged to `phase-q` 2026-07-29. Integrated suite 61/61 green, reviewed-approved.**
**Goal:** Close the last SOLID wart: replace the ~350-line hand-written **non-table** action
registration blocks (now isolated in `app/main.cpp` after Q-W3) with a **registration table**, so
adding an action edits one place, not four parallel ones (OCP). Split any remaining fat headers
(`capture.h`/`persist.h`) not already resolved by their TU splits (I). (Q-W0: no reshape —
T4-02 notes the ~385-line registration residue left in `app/main.cpp` after Q-W3 shrinks
further under the table.) CONTEXT.md §Phase Q (OCP
registration-table). See `docs/product/code-organization.md` §2.3, §6 (Q-6).
**Verify:** CTest green at every commit. Every action still registers, appears in the Actions
list, and fires via `hookcommand` exactly as before; command-id + display strings unchanged
(FOREVER-STABLE, per-channel); unload still mirror-unregisters everything. Adding a hypothetical
new action now touches the table only (demonstrated in review, not shipped). Remaining fat
headers are segmented.
**Depends on:** Q-W3 (registration code must be isolated first). Sequenced last; the most
droppable point if the phase needs narrowing (Q-6).
- [x] Converted the hand-written `Register("command_id"/"gaccel"/"hookcommand")` blocks to a
data-driven `ActionTableRow` registration table (flat function-pointer dispatch, no
`std::function`/virtual); unload mirror-unregisters from the same table; `main.cpp` shrinks
653→404. Capture rows derive their suffix+phrase from the pure `captureActionTable()` (the
parallel-list risk is gone by construction). FOREVER-STABLE suffixes/phrases/retired-ids
verified byte-identical row-by-row in review.
- [x] Split residual fat headers: `persist.h` umbrella retired (13 callers repointed);
`capture.h`'s realtime seam moved to `capture_realtime_shell.h`; the `wav_trim.h` shim + its
INTERFACE target deleted.
- [x] Phase-end cleanup riders: `bankOp*` verbs + `persistBankOp` lifted to new `shell/bank_ops`
taking `ReaSamplerSession&` (dissolves the Q-W4 🟡 review note); **`core/namespaces.h`
DELETED** (the interim Q-W1 shim's contract fulfilled — ~26 includers rewired); the grow-loop
rehomed to `core/wire/ext_state_read.h`; a stale-comment sweep (`persist.cpp`/`bank_panel.cpp`
refs); CLAUDE.md's persist/bank_book/actions/wav_codec bullets corrected in-wave.
- [ ] Verify: all actions register/fire/unregister unchanged; command-id strings untouched; CTest
green. — **PENDING**: in-DAW verification not yet performed on `phase-q` (deferred by design).
**Notes/decisions:**
- **Review-noted follow-on (not landed, deferred):** extending the table pattern to the
design_view/bank/ingest families' hand-registration; `channelIdFor`'s shared string-store scan
is correct-by-prefix-disjointness — a suffix-keyed map would make it structural, but isn't
required; `view_mode_model.h` (748 LOC) remains the largest header (T4-06's planner split
stays optional/deferred).
+4 -2
View File
@@ -25,8 +25,10 @@ Pure (no REAPER types, fully unit-tested):
format, so this is simpler, testable, and dependency-free.
REAPER-facing:
- `capture` — the `ICaptureBackend` interface plus `OfflineRenderBackend` and
`RealtimeRecordBackend`. Input: a `CaptureRequest` (capture scope — item or
- `capture` — two CONCRETE backends, `OfflineRenderBackend` (synchronous) and
`RealtimeRecordBackend` (async begin/tick/abort); no shared interface (the
former `ICaptureBackend` was deleted in Q-W3 — T4-26: one deriver, zero
polymorphic call sites). Input: a `CaptureRequest` (capture scope — item or
track, time range, tail, SR/bit-depth/channels, output path). Output: a finished
file + a populated `Sample` handed to `bank_model`. Capture is always wet; the FX
*scope* (not a wet/dry dial) is the control — the pure `render_settings` module
+183 -40
View File
@@ -2301,7 +2301,10 @@ shell maps that pure result to a cursor. No cue logic in the shell; the shell on
> findings report — a functional-correctness/algorithm-quality complement to the SOLID/naming audit
> below. **Q-W1 is gated on Q-W0's triage being complete and Daniel signing off on each finding's
> disposition** (fix-now vs. document-and-defer). Spec: §"The pre-restructure audit wave (Q-W0)"
> below.
> below. **STATUS (2026-07-28): Q-W0 COMPLETE and SIGNED OFF — all 59 dispositions approved; the
> Q-W1 sub-gate is satisfied once the six approved fix-now remediations land (in flight on
> `pq-w0-fixes`). The audit's plan reshape is folded into this spec: Q-W2 6→8 seams, NEW wave
> Q-W2v (parallel with Q-W2), Q-W3 3→4 hoists + riders, Q-W5 + the ext-state-loop dedupe.**
## What it is
@@ -2330,6 +2333,21 @@ reinvented wheel or a numerically-fragile DSP path should be eliminated or consc
tree. Bringing the code "into the realm of something I can stand to look at" is not only a matter of
shape; it is also a matter of the code being *functionally sound*.
**STATUS (2026-07-28): Q-W0 is COMPLETE — audit run, triage done, sign-off given.** Four parallel
tracks (T1 DSP, T2 architecture, T3 env-coupled constants, T4 sizing + placement), **59
findings**; report: `docs/product/code-quality-audit.md`, appendices:
`docs/product/audit-notes/q-w0-t{1..4}-*.md`. Daniel approved **every disposition as proposed** on
2026-07-28. Six findings are fix-now *in Q-W0***T1-01** (linked-lag stereo splice alignment),
**T1-03** (Preserve prime bound + immediate `freezeTail()`), **T1-09** (`declickR_` dead-state
removal, riding the `sampler_core` edits), **T2-01(a)** (provenance wire-cursor hardening
backport), **T3-01** (gain-ramp seconds), **T3-03** (fade-ceiling seconds) — in flight on branch
`pq-w0-fixes`; **the Q-W1 sub-gate is satisfied once they land** (each with its module's CTest
target green; the audible DSP fixes with a stated before/after listening check). All other
fix-nows are assigned to the wave that already opens their file and are recorded in the wave specs
below. **The Q-11 question is answered:** the correlation-aligned SOLA pitch engine is **sound —
no technique replacement (phase-vocoder / WSOLA) warranted**; every pitch finding is a bounded
in-technique fix or a documented operating limit.
**Audit scope — the named surfaces:**
1. **DSP / audio, close eye on pitch.** Assess *algorithm quality* — correctness, artifacts,
@@ -2395,10 +2413,11 @@ the subsystem map off the folders. ReaSampler adopts the *pattern* (directory =
adapted to its own most load-bearing invariant — the pure/shell split — as the top level (see
below). Vital is GPLv3; the borrowed artifact is the **structural pattern**, not code.
## Settled decisions (Q-1 settled; Q-2..Q-6 recommended — see `docs/product/code-organization.md` §6)
## Settled decisions (Q-1/Q-10/Q-11 settled 2026-07-27; Q-5/Q-6/Q-8/Q-9 + the audit's §4 forks settled 2026-07-28 — REC history kept; see `docs/product/code-organization.md` §6 and `docs/product/code-quality-audit.md` §4)
- **Q-1 — namespace letter. SETTLED: `Q` (Quality).** Point-id family `Q1..Qn`, wave prefixes
`Q-W0` (the pre-restructure audit) then `Q-W1..Q-W6` (the structural reorg). `O` (Organization)
`Q-W0` (the pre-restructure audit) then `Q-W1..Q-W6` (the structural reorg; + **`Q-W2v`**, the
VST god-module wave added at the Q-W0 sign-off, 2026-07-28). `O` (Organization)
was set aside: the glyph reads ambiguously against zero in
point ids, and "Organization" undersells a phase measured against a *quality* bar.
- **Q-2 — JSON extraction in scope + first. REC: yes.** The 4× duplicated `Parser` is the largest
@@ -2413,17 +2432,43 @@ below). Vital is GPLv3; the borrowed artifact is the **structural pattern**, not
`audio`/`ui`/`reclaim`/`version`/`json`. Directory and namespace agree; a symbol's home is
unambiguous from either.
- **Q-5 — god-module split granularity. REC: to the audit's named seams, no finer.** Well-factored,
not atomized.
- **Q-6 — OCP registration-table. REC: in scope, last (most droppable if narrowing).**
not atomized. **SETTLED (Daniel, 2026-07-28): seams-by-responsibility with the Q-W0 T4 seam
lists adopted** — `bank_panel` 6→8 seams (+ `panel_layout`, `panel_drag`; T4-01),
`capture_orchestrator` further split with `capture_batch` (T4-02) — **and the ~600-line file
ceiling is an acceptance criterion on every split wave**: seams are the method, the ceiling is
the bar; arbitrary bisection to hit the number is rejected. One documented exception:
`sampler_core.cpp` stays whole (T4-14/T4-27).
- **Q-6 — OCP registration-table. REC: in scope, last (most droppable if narrowing).** **SETTLED
(Daniel, 2026-07-28): in scope, last wave, as planned.**
- **Q-7 — naming rides the relocation waves, no dedicated naming wave. REC: yes** (forced once
Q-3/Q-4 settle — a rename is near-free during relocation, near-pure-churn standalone).
- **Q-8 — class/module renames beyond the free namespace fix. REC: fix the two that actively
mislead** — `BankIndex``BankModel` (the `bank_model.h`/`BankIndex` file↔class word-mismatch)
and the unified JSON parser → `json::Reader`/`json::Writer` (or `json::Parser`) — **leave the
merely-quirky** (`Book`/`Bank`/`Index`, `Sample`/`AudioSample`, `MinMax`, `KitBox`). Daniel's
to call.
to call. **SETTLED (Daniel, 2026-07-28): both renames**`BankIndex``BankModel` (W1) and the
JSON parser minted as `json::Reader`/`json::Writer` (W1). From the audit, additionally:
**`ICaptureBackend` is deleted in Q-W3** (T4-26 — one deriver, zero polymorphic call sites; the
CLAUDE.md/CONTEXT "two backends behind one interface" correction **rides Q-W3's own commit**,
not earlier).
- **Q-9 — align the `capture_realtime` (shell) / `realtime_record` (pure) word-order inversion.
REC: yes, during Q-W3** (a free rider — W3 already hoists the realtime lifecycle).
REC: yes, during Q-W3** (a free rider — W3 already hoists the realtime lifecycle). **SETTLED
(Daniel, 2026-07-28): yes** — the pure module takes the stem `capture_realtime`, the shell
takes the suffix (the `drag_out``drag_out_win` model), during W3.
- **Audit §4a — VST placement. SETTLED (Daniel, 2026-07-28): T4-18**`src/vst/` integrates into
the single `core/`/`shell/` top split as `core/instrument/{engine,map,ui}` +
`shell/instrument/`. One rule, no special case: a file's directory says whether it may touch a
*host* type (REAPER **or** VST3 SDK); the artifact boundary is a link-graph fact the sources
already straddle. The T4-19 artifact-first subtree was set aside. Directory map below updated.
- **Audit §4e — WAV/RIFF consolidation moment. SETTLED (Daniel, 2026-07-28): a named rider on
Q-W3** — one pure **`wav_codec`** owner (chunk walker + layout + build + patch), absorbing the
T4-10 ingest extraction (T2-08 / T4-23 / T4-10). The dedup-by-hash / null-test maintenance
surface gets exactly one implementation.
- **Audit §4f — Q-W2v scheduling. SETTLED (Daniel, 2026-07-28): parallel with Q-W2** (different
artifact, zero file overlap); the serial "Q-W7" alternative was set aside.
- **Audit §4b/§4c/§4d — the Q-W0 fix-now set. SETTLED (Daniel, 2026-07-28): all approved
fix-now** — T1-01 + T1-03 (with T1-09 riding), T2-01(a), T3-01 + T3-03; in flight on
`pq-w0-fixes`; landing them closes Q-W0 and opens Q-W1.
## The directory + namespace map (Q-3 / Q-4)
@@ -2434,24 +2479,42 @@ directories.
- `core/model/` (`::model`) — `bank_model`, `bank_book`, `owned_manifest`, `provenance`
- `core/view/` (`::view`) — `view_mode_model`, `view_tree`, `lane_keys`, `mode_switch`
- `core/capture/` (`::capture`) — `render_settings`, `batch_capture`, `tail_control`,
`capture_paths`, `wav_trim`, `insert_plan`
`capture_paths`, `wav_trim`, `insert_plan`; **post-Q-W3** `wav_codec` (the one pure WAV/RIFF
owner — audit §4e rider)
- `core/audio/` (`::audio`) — `peaks`
- `core/ui/` (`::ui`) — `theme`, `component_geometry`, `bank_grid`, `tab_strip`, `action_buttons`,
`prune_button`
- `core/reclaim/` (`::reclaim`) — `prune_reconcile`
- `core/version/` (`::version`) — `app_version`
- `core/json/` (`::json`) — **NEW** — extracted parser/serializer (replaces the 4 duplicate
`Parser`s)
- `core/json/` (`::json`) — **NEW** — extracted parser/serializer, minted as
`json::Reader`/`json::Writer` (replaces the 5 duplicate hand-rolled decoders — the four
`Parser`s + `tail_control`'s, T2-02)
- `core/instrument/`**T4-18 SETTLED (Daniel, 2026-07-28)** — the VST artifact's pure side
joins the one top split (directory = "may it touch a host type", REAPER *or* VST3 SDK):
- `core/instrument/engine/``sampler_core`, `pitch_shift`, `velocity_curve`, `master_gain`
- `core/instrument/map/``sample_map` (+ `component_state_io` post-Q-W2v), `bank_sync`,
`bridge_marshal`, `note_entry`, `trigger_seam`
- `core/instrument/ui/``editor_geometry`, `keyboard_strip`, `waveform_view`,
`capture_browser`, `browser_scroll`, `param_slider`, `knob_deck`, `curve_popup`,
`envelope_overlay`, `envelope_edit`, `embed_strip`
**`shell/` (REAPER-facing — subdir by subsystem, namespace as house style prefers):**
- `shell/capture/``capture`, `capture_realtime`, `provenance_shell`, `track_guid`, `item_read`;
**post-Q-W3** `capture_orchestrator`, `scope_resolve`, `realtime_lifecycle`
**post-Q-W3** `capture_orchestrator`, `capture_batch`, `scope_resolve`, `realtime_lifecycle`,
`capture_realtime_finalize` (with the Q-9 stem/suffix rename)
- `shell/panel/``draw_kit`; **post-Q-W2** `panel_render`, `panel_thumbnails`,
`panel_audition`, `panel_input`, `panel_bank_ops`, `panel_window` (from `bank_panel`)
`panel_audition`, `panel_input`, `panel_layout`, `panel_drag`, `panel_bank_ops`,
`panel_window` (from `bank_panel` — eight seams, T4-01)
- `shell/view/``view`
- `shell/persist/`**post-Q-W5** `session`, `ext_state_io`, `prune_fs` (from `persist`)
- `shell/actions/``drag_out_win`; **post-Q-W4** `design_view_actions`, `bank_actions`,
`prune_action` (from `actions`)
- `shell/instrument/``reaper_bridge`, `reasampler_embed`, `vst_entry`, `reasampler_vst.h` /
`reasampler_uid.h`; **post-Q-W2v** the editor TUs (`editor_session` / `editor_controls` /
`editor_paint_sample` / `editor_paint_browse_zone` / `editor_input_sample` /
`editor_input_browse_zone` / `editor_platform``editor_layout` hoists *pure*
`core/instrument/ui/`, discharging T2-06) and the processor TUs (`processor_state` /
`processor_reload` / lifecycle+`process()`)
**`app/`:** `main.cpp` (post-Q-W3: API-pointer ownership + `ReaperPluginEntry` + dispatch only).
@@ -2459,37 +2522,83 @@ directories.
unified `Parser` (`::json`) must not collide once flattened into granular namespaces; resolve by
subsystem home.
## The god-module split seams (Q-5)
## The god-module split seams (Q-5 — SETTLED 2026-07-28: T4 seam lists adopted; ~600 ceiling is the bar)
Split each to the audit-validated seams, no finer:
Split each to the audit-validated seams, no finer — **and every shipped TU lands under the
~600-line ceiling** (the Q-5 settlement: seams are the method, the ceiling is the bar; arbitrary
bisection to hit the number is rejected; the one documented exception is `sampler_core.cpp`).
LOC figures updated to the Q-W0 T4 census (2026-07-28):
- **`bank_panel.cpp` (2424 LOC → `shell/panel/`, Q-W2):** `panel_render` (draw/paint) /
`panel_thumbnails` (compute+cache) / `panel_audition` (preview engine — **hot path, direct
call-through**) / `panel_input` (mouse/key/wheel + new-content detection) / `panel_bank_ops`
(bank CRUD — the single owner W4 dedupes against) / `panel_window` (lifecycle + OS
drag-out/drop-target). Split the fat `bank_panel.h` per seam (I).
- **`main.cpp` (1762 LOC → hoist to `shell/capture/`, Q-W3):** `capture_orchestrator`
(`RunCapture`/`captureAndIndexOne`/`renderOffline`/batch/recapture/realtime `Run*`) /
`scope_resolve` (range/razor/track resolution + provenance assembly inputs) /
`realtime_lifecycle` (state machine + globals + selection guards). `FxBypassGuard` moves out
but **stays stack RAII** (precision-critical). `main.cpp``app/`, reduced to pointers + entry
+ dispatch.
- **`actions.cpp` (981 LOC → `shell/actions/`, Q-W4):** `design_view_actions` /
- **`bank_panel.cpp` (3459 LOC → `shell/panel/`, Q-W2 — eight seams, T4-01):** `panel_render`
(draw/paint) / `panel_thumbnails` (compute+cache) / `panel_audition` (preview engine — **hot
path, direct call-through**) / `panel_input` (mouse/key/wheel + new-content detection) /
**`panel_layout`** (toolbar/footer/menu rects + row/cluster builders + region geometry — new
seam) / **`panel_drag`** (the card-drag/hover state machine, pure mirror `card_drag` — new
seam) / `panel_bank_ops` (bank CRUD — the single owner W4 dedupes against) / `panel_window`
(lifecycle + OS drag-out/drop-target). Without the two new seams, `panel_render` (~700) and
`panel_input` (~800) would ship over the ceiling. Split the fat `bank_panel.h` per seam (I).
- **`main.cpp` (1897 LOC → hoist to `shell/capture/`, Q-W3 — four hoists, T4-02):**
`capture_orchestrator` (`RunCapture`/`captureAndIndexOne`/`renderOffline`/single-capture +
realtime/insert action bodies — lands ~450) / **`capture_batch`** (batch family +
`RunRecaptureFromSource` + the two RAII selection guards — new hoist; recapture is
planner-driven like batch and shares the guard machinery) / `scope_resolve` (range/razor/track
resolution + provenance assembly inputs) / `realtime_lifecycle` (state machine + globals).
`FxBypassGuard` moves out but **stays stack RAII** (precision-critical). `main.cpp``app/`,
reduced to pointers + entry + dispatch. **Wave riders (SETTLED 2026-07-28):** delete
`ICaptureBackend` + correct the CLAUDE.md/CONTEXT description in the same commit (T4-26);
shared `stampCaptureSample` epilogue dedupe (T2-09); `capture_realtime_finalize` split with
the Q-9 rename (T4-08); `makeUniqueTag` per-session monotonic counter (T1-11); the pure
`wav_codec` consolidation (audit §4e).
- **`actions.cpp` (1016 LOC → `shell/actions/`, Q-W4 — T4-03: seams unchanged, still
sub-600):** `design_view_actions` /
`bank_actions` / `prune_action` (`doBankPruneFolder` — the single file-deletion action). Dedupe
`promptText`/`mintBankId` + bank verbs against `panel_bank_ops`.
- **`persist.cpp` (766 LOC → `shell/persist/`, Q-W5):** `session` (lifecycle/poll +
- **`persist.cpp` (852 LOC → `shell/persist/`, Q-W5 — T4-04: seams unchanged; the pS-usage
growth landed exactly where this wave isolates it):** `session` (lifecycle/poll +
`BeginLoadProjectState` reload hook) / `ext_state_io` (serialization bridge + GUID minting +
folder relocation) / **`prune_fs`** (prune scan + `deleteOrphanFile` via `SHFileOperationW`
the isolated single file-deletion authority).
the isolated single file-deletion authority). **Wave rider (T2-04):** the `GetProjExtState`
grow-loop ×3 dedupes onto `bridge_marshal`'s generalized retry policy — `usage_scan`'s
prune-safety-adjacent copy included.
- **`reasampler_editor.cpp` (3065 LOC — the largest file in the repo → `shell/instrument/`,
Q-W2v — eight TUs, T4-11; split axis = the Sample/Browse/Zone face structure):**
`editor_session` / `editor_controls` / `editor_layout` (**pure-candidate hoist** →
`core/instrument/ui/`, `editor_geometry` the named owner — discharges T2-06's
stranded-layout-math finding) / `editor_paint_sample` / `editor_paint_browse_zone` /
`editor_input_sample` / `editor_input_browse_zone` / `editor_platform`. Rider: adopt the pure
`ThumbnailKey` on the VST side while the editor is open (T2-10).
- **`reasampler_processor.cpp` (1164 LOC → `shell/instrument/`, Q-W2v — three TUs, T4-12):**
`processor_state` / `processor_reload` / lifecycle+`process()` kept whole. **No virtual seam
on the atomic-pointer-swap reload pattern (T4-29).**
- **`sample_map` (970 LOC + 708-line header → Q-W2v, T4-13 ≡ T2-07):** resolution core vs the
**`component_state_io`** binary ComponentState codec (+ matching header split) — the codec
grows every envelope bump (v6→v11 in one quarter); the extension stops linking the whole voice
engine to serialize one preset blob. The `core/wire` LE byte-codec template
(`putLE`/`readLE`, T4-20) lands with it.
- **`sampler_core.cpp` (968 LOC + 762-line header — Q-W2v, T4-14/T4-27): the TU stays WHOLE —
the documented hot-path exception to the ~600 ceiling.** Envelope `tick()`s run
per-voice-per-sample; same-TU definition is what lets the compiler inline the stack (no LTO in
the build); a by-class split is the exact heuristic-(3) dispatch blowout. The header splits
into `zone_params.h` + `sampler_core.h`. Recorded here so nobody "fixes" it later.
## The JSON extraction (Q-2 / Q-W1)
Extract one pure **`core/json`** (`::json`): parser (`parseString`/`parseInt`/`parseKey`/
Extract one pure **`core/json`** (`::json` — minted as **`json::Reader`/`json::Writer`**, Q-8
SETTLED 2026-07-28): parser (`parseString`/`parseInt`/`parseKey`/
`skipValue` + escape) + serialize/emit helpers. Rewire `bank_model`, `bank_book`,
`view_mode_model`, `owned_manifest` onto it and **delete their four hand-rolled `Parser`s**.
`view_mode_model`, `owned_manifest`, **and `tail_control` (T2-02 — the fifth hand-rolled decoder
the §2 audit undercounted)** onto it and **delete all five hand-rolled decoders**.
Round-trip output must be **byte-identical** to before — this is a structural dedupe, not a format
change. Off all hot paths (serialization runs at save/load, never per frame) — safe to abstract
freely.
freely. **W1 siblings (from the Q-W0 sign-off):** the shared `readFileBytes` pure helper
(T2-03); the length-prefixed wire-`Cursor` collapse into one shared wire codec beside
`core/json`, consumed by `provenance` / `assignment_request` / `sample_usage` /
`parseBankGeneration` (T2-01(b) — the structural half; the hardening backport lands in Q-W0);
the **concrete** `ui::Rect` unification + `contains()` + per-role aliases, retiring the
XYWH-vs-LTRB fork — NOT a template (T2-05 ≡ T4-21), with the `clamp01` rider (T4-24) and the
`hitIndex` template only as an opportunistic follow-on (T4-22); the `slot_map` extraction
(T4-05) and optional `view_mode_model` planner split (T4-06) riding files W1 already opens; the
relocation scope grows to the ~20 clean VST pure libs under `core/instrument/` (T4-18).
## The OCP registration-table (Q-6 / Q-W6)
@@ -2515,7 +2624,7 @@ focus. The audit (2026-07-27) is grep-verified; the load-bearing findings:
one `json::Parser` in Q-W1; the shared pure-UI rect types `FooterRect` / `ButtonRect` (defined in
`prune_button.h`, reused by `footer_bar.h` under an explicit hand-collision "NAME NOTE") get one
`ui::` owner; `Sample` (`model::`) vs `AudioSample` (`audio::`) de-collide by home.
- **Genuine renames (Q-8/Q-9 — Daniel's call):** `BankIndex``BankModel` (the `bank_model.h`
- **Genuine renames (Q-8/Q-9 — SETTLED, Daniel 2026-07-28: all land):** `BankIndex``BankModel` (the `bank_model.h`
file↔class word-mismatch — the worst legibility wart, rec: rename the class so the model family
reads `BankModel`/`BankBook`/`ViewModeModel`); the unified JSON parser named `json::Reader`/
`json::Writer` at W1 mint; align `capture_realtime`(shell)/`realtime_record`(pure) to the house
@@ -2550,6 +2659,25 @@ the following are acceptance criteria on every point:
**Net:** every recommended split falls on a cold path or preserves call/inline shape on the two
hot ones. *A split that would add a hot-path indirection is out of scope — rework it or drop it.*
## Structural heuristics (Daniel, 2026-07-28 — acceptance criteria phase-wide)
Three heuristics postdate the original framing and bind every wave. They **generalize** the
three-hot-path performance guardrail above — they do not replace it:
1. **More directories is a must; more files is good; ~600-line file ceiling.** SRP applies to
namespaces, encapsulation, and file organization alike. The ceiling is the *bar*, the audit's
named seams are the *method*: a file landing over ~600 needs a responsibility seam, not an
arbitrary bisection (bisection-to-hit-the-number is rejected). A documented hot-path
exception (`sampler_core.cpp`, T4-14/T4-27) is legitimate; silent overshoot is not.
2. **Templates are the right tool for compile-time dedup — use them where earned.** The LE
byte-codec `putLE`/`readLE` (T4-20) is earned: compile-time dispatch, zero runtime cost, off
the hot paths. The rect family is NOT (T4-21): the types differ in name only, so one
**concrete** `ui::Rect` — a template there would model nothing.
3. **SOLID is great, but saved CPU is better.** No dispatch-stack blowouts *anywhere* — not just
the three named hot paths; prefer static polymorphism where the types are compile-time-known.
The T4-27 warning is the canonical case: a by-class `sampler_core` split would put virtual
envelope `tick()`s on the per-voice-per-sample path — exactly the blowout this forbids.
## The GATE (load-bearing — Phase Q is last)
Phase Q is **gated on the tree being otherwise quiescent.** Daniel's plain readiness target:
@@ -2583,16 +2711,27 @@ every commit), a property only an *incremental* reorg uses. Risk-ordered:
pre-restructure audit wave (Q-W0)"). Produces a written, triaged findings report; runs **first**
and **gates Q-W1** — no structural point begins until the triage closes and Daniel signs off on
every disposition. Fix-now findings are remediated here or folded into the wave that opens the
file; the report may add/reshape downstream Q-W1..Q-W6 points before they start.
- **Q-W1** — safe opener: `core/json` extract (delete 4 `Parser`s) + impose the directory/
file; the report may add/reshape downstream Q-W1..Q-W6 points before they start. **COMPLETE
and signed off 2026-07-28; the sub-gate closes when the six fix-now remediations land
(`pq-w0-fixes`).**
- **Q-W1** — safe opener: `core/json` extract (delete the 5 hand-rolled decoders — T2-02 adds
`tail_control`) + impose the directory/
namespace layout on the 30 clean pure libs + clean shells (pure relocation, no logic change).
All later waves assume this layout. **Carries the naming collision fixes + the model-class
renames (Q-8), which are free during this relocation.**
- **Q-W2..Q-W5** — the four god-module splits, one per wave, risk-ordered (`bank_panel`
`main.cpp``actions.cpp``persist.cpp`). Q-W4 depends on Q-W2 (`panel_bank_ops` dedupe
target); Q-W5 best after Q-W4 (`prune_action``prune_fs` routing); otherwise parallel-safe.
renames (Q-8), which are free during this relocation.** **Scope grown by Q-W0 (2026-07-28):**
+ `readFileBytes` (T2-03), the wire-`Cursor` codec (T2-01(b)), the concrete `ui::Rect`
unification (T2-05 ≡ T4-21) + `clamp01` (T4-24), the `slot_map`/planner riders (T4-05/T4-06),
and the ~20 clean VST pure libs under `core/instrument/` (T4-18).
- **Q-W2..Q-W5 (+ Q-W2v)** — the god-module splits, risk-ordered (`bank_panel`
`main.cpp``actions.cpp``persist.cpp`), plus **Q-W2v** (NEW, added at the Q-W0 sign-off:
the VST god-modules — editor eight TUs / processor three TUs / `component_state_io`;
`sampler_core` TU whole, the documented exception), which **runs parallel with Q-W2**
(different artifact, zero file overlap — audit §4f SETTLED). Q-W4 depends on Q-W2
(`panel_bank_ops` dedupe target); Q-W5 best after Q-W4 (`prune_action``prune_fs` routing);
otherwise parallel-safe.
**Q-W2 carries the `panel_*` names; Q-W3 carries the `capture_realtime`/`realtime_record`
word-order fix (Q-9).**
word-order fix (Q-9) + the settled riders (`ICaptureBackend` deletion + doc correction,
`wav_codec`, `stampCaptureSample` dedupe, T1-11, `capture_realtime_finalize`).**
- **Q-W6** — OCP registration-table + residual fat-header (I) splits. Depends on Q-W3 (registration
code isolated first). Sequenced last; most droppable if narrowing.
- **Naming (Q-7): no dedicated wave** — every rename rides the wave already relocating/splitting
@@ -2624,7 +2763,9 @@ every commit), a property only an *incremental* reorg uses. Risk-ordered:
realtime-tick — ever (a hard acceptance bar, not advice).
- **No design-level (D-letter SOLID) rework.** `main`/`bank_panel` depending on concrete capture
backends is a low-priority Dependency-Inversion concern — **out of scope** (a design change, not
a reorg). Phase Q reorganizes; it does not re-architect interfaces.
a reorg). Phase Q reorganizes; it does not re-architect interfaces. (Deleting the *dead*
`ICaptureBackend` abstraction in Q-W3 is the opposite move — removing a false interface with
one deriver and zero polymorphic call sites, T4-26 — and is in scope.)
- **No `peaks` data-ownership change.** `peaks` forcing a whole-file `std::vector<float>` copy on
the thumbnail path is noted but **not touched** — reworking it risks the hot path.
- **No big-bang commit.** Every wave is independently landable and CTest-green; reject a change set
@@ -2632,5 +2773,7 @@ every commit), a property only an *incremental* reorg uses. Risk-ordered:
- **Do not begin before the GATE.** Re-confirm the tree is quiescent (Phase S + L + D2 merged/closed;
M9 abandoned) before any Q point. **And do not begin any structural point (Q-W1+) before the Q-W0
sub-gate:** the audit's triage is complete and Daniel has signed off on every finding's disposition.
(Sign-off given 2026-07-28; the sub-gate now closes when the six fix-now remediations land on
`pq-w0-fixes`.)
- **Verify** the CMake `src/` path updates and the SWELL/LICE surfaces still resolve after
relocation, as the existing build already requires.
+246 -158
View File
@@ -220,8 +220,21 @@ panel adopts knob deck + curve popup, `param_slider` slider rows retired on that
> downstream Q-W1..Q-W6 points; fixes that Q-W0 classifies fix-now are remediated in Q-W0 (or folded
> into the wave that already touches the file), **not** deferred silently into the structural waves.
>
> **Q-W0 SIGN-OFF: COMPLETE (Daniel, 2026-07-28).** The audit ran as four parallel tracks (T1 DSP,
> T2 architecture, T3 env-coupled constants, T4 sizing/placement — **59 findings**; report
> `docs/product/code-quality-audit.md`, appendices `docs/product/audit-notes/`), and **all 59
> findings' dispositions are approved as proposed.** The Q-W1 sub-gate is satisfied **once the six
> approved fix-now remediations land** (in flight on branch `pq-w0-fixes`, Q-W0-scoped): T1-01,
> T1-03, T1-09, T2-01(a), T3-01, T3-03. The audit's §3 plan reshape is **folded into the waves
> below** (Q-W2 6→8 seams; NEW wave **Q-W2v** parallel with Q-W2; Q-W3 3→4 hoists + riders; Q-W5
> + the ext-state-loop dedupe), and its §4 decision list is settled — see the settlement block
> below. The Q-11 question is answered by the audit: the SOLA pitch engine is **sound — no
> technique replacement warranted**; every pitch finding is a bounded in-technique fix or a
> documented operating limit.
>
> **Settled (Q-1, this-doc):** the phase is **`Q` (Quality)**; point-id family `Q1..Qn`, wave
> prefixes `Q-W0` (the pre-restructure audit) then `Q-W1..Q-W6` (the structural reorg).
> prefixes `Q-W0` (the pre-restructure audit) then `Q-W1..Q-W6` (the structural reorg; **+
> `Q-W2v`**, the VST god-module wave added at the Q-W0 sign-off, 2026-07-28).
> **Settled (Q-10/Q-11, Daniel 2026-07-27):** Q-10 audit-report home = a **committed doc**
> (`docs/product/code-quality-audit.md`, not a tracked issue list); Q-11 pitch-remediation depth =
> **defer to findings** (default document-and-defer; weigh a bounded OLA fix before a technique
@@ -238,6 +251,25 @@ panel adopts knob deck + curve popup, `param_slider` slider rows retired on that
> `BankModel`; the JSON `Parser``json::Reader`/`Writer`), leave the merely-quirky (rec);
> Q-9 align the `capture_realtime`/`realtime_record` shell↔core word order during W3 (rec: yes).**
>
> **SETTLED (Daniel, 2026-07-28 — with the Q-W0 sign-off; the REC record above kept as history):**
> **Q-5 SETTLED** — split to **seams-by-responsibility with the T4 seam lists adopted**
> (`bank_panel` 6→8 seams adding `panel_layout` + `panel_drag`, T4-01; `capture_orchestrator`
> further split with `capture_batch`, T4-02), and the **~600-line file ceiling is an acceptance
> criterion on every split wave** — seams are the method, the ceiling is the bar; arbitrary
> bisection to hit the number is rejected. **Q-6 SETTLED: in scope, last wave, as planned.**
> **Q-8 SETTLED: both renames**`BankIndex``BankModel` (W1) and the JSON parser minted as
> `json::Reader`/`json::Writer` (W1); additionally from the audit, **`ICaptureBackend` is deleted
> in Q-W3** (T4-26 — one deriver, zero polymorphic call sites; the CLAUDE.md/CONTEXT "two
> backends behind one interface" correction **rides Q-W3 itself**, recorded as a rider — the docs
> are not edited before that wave). **Q-9 SETTLED: yes** — align to stem `capture_realtime`,
> shell suffixed, during W3. **VST placement (audit §4a) SETTLED: T4-18**`src/vst/` integrates
> into the single `core/`/`shell/` top split as `core/instrument/{engine,map,ui}` +
> `shell/instrument/` (Q-3 directory map updated in CONTEXT.md §Phase Q). **WAV/RIFF
> consolidation (audit §4e) SETTLED:** a named rider on **Q-W3** — one pure **`wav_codec`** owner
> (walker + layout + build + patch), absorbing the T4-10 ingest extraction. **Q-W2v scheduling
> (audit §4f) SETTLED: parallel with Q-W2** (different artifact, zero file overlap; the serial
> "Q-W7" alternative set aside).
>
> **HARD CONSTRAINT — performance (see CONTEXT.md §Phase Q, `docs/product/code-organization.md`
> §3).** The reorg must cost **zero runtime.** On the three hot paths — `peaks` envelope
> compute, audition/preview, the realtime-capture tick — **no added virtual dispatch, no
@@ -247,6 +279,21 @@ panel adopts knob deck + curve popup, `param_slider` slider rows retired on that
> an acceptance criterion on every point: *a split that would add a hot-path indirection is out
> of scope — rework it or drop it.*
>
> **STRUCTURAL HEURISTICS (Daniel, 2026-07-28 — acceptance criteria on every wave; these
> *generalize* the three-hot-path guardrail above, they do not replace it):**
> (1) **More directories is a must, more files is good, ~600-line file ceiling** — SRP applies to
> namespaces, encapsulation, and file organization alike. The ceiling is the *bar*, the audit's
> named seams are the *method*: a file landing over ~600 needs a responsibility seam, not an
> arbitrary bisection; a documented hot-path exception (`sampler_core.cpp`, T4-14/T4-27) is
> legitimate, silent overshoot is not.
> (2) **Templates are the right tool for compile-time dedup — use them where earned** (the LE
> byte codec `putLE`/`readLE`, T4-20), not for name-only unification (the rect family is one
> **concrete** `ui::Rect`, NOT a template — T4-21's ruling).
> (3) **SOLID is great but saved CPU is better** — no dispatch-stack blowouts *anywhere*, not
> just the three named hot paths; prefer static polymorphism where types are compile-time-known
> (T4-27's warning is the canonical case: a by-class `sampler_core` split would put virtual
> envelope `tick()`s on the per-voice-per-sample path).
>
> **NAMING dimension (added 2026-07-27; grep-verified audit in `docs/product/code-organization.md`
> §2b).** Beyond giving symbols a directory + namespace *home* (Q-3/Q-4), Phase Q also gives
> poorly/inconsistently-named symbols a consistent *name*, against the same Vital bar. The audit
@@ -265,9 +312,25 @@ panel adopts knob deck + curve popup, `param_slider` slider rows retired on that
> per-module static-lib + per-module test-executable seams already draw the module boundaries;
> a file move + namespace change is mechanically verifiable — `ctest --test-dir build` is green
> or it isn't. **Green-CTest-at-every-point is an acceptance criterion.** Big-bang is rejected;
> the reorg is risk-ordered waves (W1 safe opener → W2W5 god-module splits → W6 OCP finish).
> the reorg is risk-ordered waves (W1 safe opener → W2/W2vW5 god-module splits → W6 OCP finish).
>
> **PHASE STATUS (2026-07-29): all seven waves (Q-W0..Q-W6 incl. Q-W2v) are structurally
> COMPLETE.** Remaining before the phase closes and merges to `dev`: (1) Daniel's in-DAW
> verification batch — the full deferred list across all waves (panel parity, editor/processor
> parity, stereo Preserve listening, null test, bit-identical repeats, capture flows, action
> families, one-op-one-Ctrl-Z, prune fail-safes, save/load/relocation) — now unblocked since the
> tree is stable; (2) the phase-close CLAUDE.md architecture refresh (module map still describes
> some pre-Q homes); (3) the phase-q → dev merge on Daniel's sign-off. See `COMPLETED.md` for
> each wave's full landed narrative.
## Q-W0 — pre-restructure functional + DSP quality audit (runs FIRST; gates Q-W1)
**STATUS (2026-07-29): audit COMPLETE, triage COMPLETE, sign-off COMPLETE, fix-now
remediations LANDED — Q-W0 is fully closed.** The findings report is committed
(`docs/product/code-quality-audit.md`; track appendices in `docs/product/audit-notes/` — T1
DSP, T2 architecture, T3 env-constants, T4 sizing/placement; 59 findings). Daniel approved
every disposition 2026-07-28. The six approved fix-now remediations plus seven review riders
landed 2026-07-28 (merge `546927e`) — see `COMPLETED.md`. **Q-W1 has since landed on top of
this closure** (see `COMPLETED.md`).
**Goal:** Before a single structural point moves, perform a **thorough static/functional audit** of
the codebase and produce a **written, triaged findings report**. This is the *functional-correctness
and algorithm-quality* complement to the grep-verified SOLID/naming audit that already grounds
@@ -322,165 +385,166 @@ before/after listening or null check. **The gate to Q-W1 is: triage complete + D
are a decision, not an omission.
- [ ] **Sign-off gate.** Daniel reviews the triage and signs off on each disposition. Q-W1 does not
begin until this is done; fold any new/reshaped downstream points the audit surfaces into
Q-W1..Q-W6 before starting them.
Q-W1..Q-W6 before starting them. **DONE (Daniel, 2026-07-28): all 59 dispositions approved as
proposed; the §3 plan reshape and §4 decisions are folded into Q-W1..Q-W6 + Q-W2v below.**
## Q-W1 — safe opener: extract `core/json` + impose the directory/namespace layout on clean modules
**Goal:** The zero-god-module-risk opener. Two moves: (1) extract a pure **`core/json`** module
(parser + serializer) and **delete the four hand-rolled `Parser`s** in `bank_model` /
`bank_book` / `view_mode_model` / `owned_manifest` (the single largest DRY+SRP violation, and
entirely off the hot paths); (2) impose the settled `core/`/`shell/`/`app/` directory layout +
sub-namespaces (`reasampler::model`/`view`/`capture`/`audio`/`ui`/`reclaim`/`version`/`json`) on
the **30 clean pure libs + the clean shells that need no splitting** — pure relocation, no logic
change. Proves the wave discipline (relocate + encapsulate, CTest-green) before any god-module
surgery. CONTEXT.md §Phase Q (json extraction; directory + namespace map).
**Verify:** CTest green at every commit. The four duplicate `Parser`s are gone, replaced by one
`core/json` consumed by all four models; round-trip serialization is byte-identical to before
(no format change — a *structural* dedupe, not a behavior change). Every relocated clean module
compiles and its test executable passes unmoved. `Sample` (model) vs `AudioSample` (audio) vs
unified `Parser` (json) do not collide once sub-namespaced. No REAPER type crosses into any
`core/` file; the CMake pure/shell enforcement still holds.
**Depends on:** the GATE (tree quiescent) **and Q-W0 closed** (audit triaged + Daniel signed off;
any fix-now findings the audit assigned to Q-W1 folded in). First structural wave.
- [ ] Extract `core/json` (pure parser + serializer: parseString/parseInt/parseKey/skipValue +
escape, plus emit helpers); unify under `reasampler::json`; guard the `Parser` name against
cross-lib collision. Off all hot paths — safe to abstract freely.
- [ ] Rewire `bank_model`, `bank_book`, `view_mode_model`, `owned_manifest` onto `core/json`;
**delete the four duplicate `Parser`s.** Round-trip output byte-identical (dedupe, not
reformat).
- [ ] Relocate the 30 clean pure libs into `core/{model,view,capture,audio,ui,reclaim,version,
json}/` and the clean shells into `shell/{capture,panel,view,persist,actions}/`; move
`main.cpp` to `app/`. Update `CMakeLists.txt` `src/` paths only (no target-graph change).
- [ ] Apply sub-namespaces matching the directories on every relocated *clean* module (the
god-modules re-namespace their own new TUs as they split, W2W5). Resolve `Sample`/
`AudioSample`/`Parser` homes. **This alone resolves the naming *collisions*** (§2b.2): the
shared pure-UI rect types (`FooterRect`/`ButtonRect`/`Selection`/`CellRect`) get one `ui::`
owner — retire the hand-collision "NAME NOTE" in `footer_bar.h`.
- [ ] **Naming riders (Q-8, if settled):** rename the survivor JSON parser to `json::Parser`
(or `json::Reader`/`json::Writer`); if Daniel takes the `BankIndex``BankModel` rename, land
it here (mechanical class rename, verified by `bank_model_tests`). No rename on a file this
wave isn't already relocating (Q-7).
- [ ] Confirm CTest green + no hot-path change: `peaks`/audition/realtime-tick untouched by this
wave (pure relocation of clean modules; `peaks` stays a free function).
> **Landed on `phase-q` (2026-07-29). Integrated suite 60/60 green.** `core/json`
> (`json::Reader`/`json::Writer`) extracted; the five hand-rolled JSON decoders (incl.
> `tail_control`'s) deleted; the wire-`Cursor` family collapsed into `core/wire`; the shared
> `readFileBytes` helper added; ~50 clean modules relocated into `core/{model,view,capture,
> audio,ui,reclaim,version,json,util,wire}/`, `core/instrument/{engine,map,ui}/`,
> `shell/{capture,panel,view,persist,actions,instrument}/`, `app/main.cpp`; sub-namespaces
> applied; one concrete `ui::Rect` + aliases (LTRB fork + `footer_bar` NAME NOTE retired);
> `slot_map` extracted from `bank_book`; `clamp01` deduped; `BankIndex``BankModel`;
> `reasampler_uid.h` relocated to `core/wire/`. See `COMPLETED.md` for the full narrative.
>
> **Skipped/deferred riders:** T4-22 (`hitIndex` hit-test template) — not trivial, deferred as
> an opportunistic follow-on once the rect unification is in use downstream; T4-06
> (`view_mode_model` planner split) — optional, deferred; T4-09 (`view_lanes` split) —
> deferred (in scope only if a later wave touches `view.cpp` anyway).
>
> **Open residual — `bank_book.cpp` still 737 LOC.** The serialize/deserialize seam is
> identified but blocked on a `nameKey` linkage design decision, escalated to Daniel and
> **pending** as of 2026-07-29. Downstream waves touching `bank_book` should check this
> residual before assuming the split is finished.
>
> An interim `core/namespaces.h` shim covers the six not-yet-split god TUs; each downstream
> split wave (Q-W2 onward) retires its own includes of it as that module splits.
## Q-W2 — split `bank_panel.cpp` (the biggest god-module, 2424 LOC)
**Goal:** Split the largest god-module (8+ responsibilities) along the audit's named seams:
`panel_render` / `panel_thumbnails` / `panel_audition` / `panel_input` / `panel_bank_ops` /
`panel_window`. Split the fat `bank_panel.h` alongside (Interface Segregation). **Preserve the
audition hot path as a direct call-through, never virtual.** `panel_bank_ops` becomes the single
home for the bank-CRUD verbs that W4 will dedupe `actions.cpp` against. CONTEXT.md §Phase Q
(bank_panel split seams; hot-path audition guardrail). See `docs/product/code-organization.md`
§2.1, §5.
**Verify:** CTest green at every commit. Each seam is its own TU under `shell/panel/`; the panel
draws, thumbnails, auditions, handles input, does bank ops, and manages its window exactly as
before (no behavior change — verify in DAW that the panel is visually and interactively
unchanged). Audition/preview call path stays a **direct call-through** (no virtual dispatch, no
added header→TU indirection on the preview path). The ~20-function public API is now segmented
across the split headers.
**Depends on:** Q-W1 (directory/namespace layout established). Independently landable.
## Q-W2 — split `bank_panel.cpp` (the biggest extension god-module — 3459 LOC at the Q-W0 census)
- [ ] Split rendering (`draw*`/`paint*`) → `panel_render`; thumbnail compute+cache →
`panel_thumbnails`.
- [ ] Split the audio audition/preview engine → `panel_audition` — **direct call-through, not
virtual; preview idle path unchanged.**
- [ ] Split input handling (mouse/key/wheel) + new-content detection → `panel_input`; window
lifecycle + OS drag-out/drop-target → `panel_window`.
- [ ] Extract bank-CRUD verbs → `panel_bank_ops` (the future single owner; W4 dedupes
`actions.cpp` against it). Split `bank_panel.h` into per-seam headers (I).
- [ ] Verify in DAW: panel unchanged; CTest green; no hot-path indirection added.
> **Landed on `phase-q` (2026-07-29, merge of `pq-w2-panel`). Integrated suite 61/61 green,
> reviewed-approved.** `bank_panel.cpp` (3459 LOC) split into eight TUs under `shell/panel/`:
> `panel_render` / `panel_thumbnails` / `panel_audition` / `panel_input` / `panel_bank_ops` /
> `panel_window` / `panel_layout` / `panel_drag`, plus per-seam public headers and internal
> `panel_state.h`; audition stays a direct call-through; the one-bank-op-one-undo invariant is
> preserved; ~50 TU-private helpers wrapped in anonymous namespaces (a review follow-up). See
> `COMPLETED.md` for the full narrative.
>
> **Recorded ceiling overages (reviewer-endorsed, preserved as a durable record per CONTEXT's
> "silent overshoot is not legitimate" rule):** `panel_input.cpp` 636, `panel_render.cpp` 613,
> `panel_state.h` 608 — the overage is comment volume; non-comment lines are ~322369 per file;
> no honest seam remains; bisection was rejected.
>
> **Review note for the Q-W4 planning record:** `panel_bank_ops`'s verbs still embed
> prompts/panel-state nudges — Q-W4's dedupe needs promptless inner verbs (`renameBank(id,name)`
> etc.), not a call-site swap; `promptText`/`mintBankId` are byte-identical twins with
> `actions.cpp` and are the cheapest first dedupe.
>
> **In-DAW verification (panel parity) is PENDING on `phase-q`** — deferred by design, not yet
> performed.
## Q-W2v — split the VST god-modules (NEW wave — Q-W0 T4 §1.5; runs parallel with Q-W2)
> **Landed on `phase-q` (2026-07-29, merge of `pq-w2v-vst`). Integrated suite 61/61 green,
> reviewed-approved.** `reasampler_editor.cpp` (3084 LOC, the largest file in the repo) split
> into eight face-axis TUs under `shell/instrument/`, with pure layout hoisted into
> `core/instrument/ui/editor_geometry` (discharges T2-06, newly tested); `reasampler_processor.cpp`
> split into `processor_state` / `processor_reload` / lifecycle+`process()` kept whole (no
> virtual seam, T4-29); `sample_map` split into a resolution core + `component_state_io` codec
> (the extension preset path no longer links the voice engine — link-proven; T4-13 ≡ T2-07);
> `sampler_core.cpp` stays whole with the documented hot-path exception comment (T4-14/T4-27);
> `zone_params.h` split out; `core/wire/bytes.h` (`putLE`/`ByteReader`) lands (T4-20);
> `ThumbnailKey` adopted (T2-10); a golden full-blob v11 fixture pins the codec bytes. The
> `src/vst/` directory is gone. See `COMPLETED.md` for the full narrative.
>
> **Deferred/known:** `component_state_io.h` still includes `sample_map.h``sampler_core.h`
> transitively (T2-07's header half — future work); the `engine` namespace is deferred
> (`sampler_core` stays flat `reasampler`); capture-side LE rewires are left for the capture
> family.
>
> **In-DAW verification (editor/processor parity) is PENDING on `phase-q`** — deferred by
> design, not yet performed.
## Q-W3 — split `main.cpp` (hoist orchestration; leave main = pointers + entry + dispatch)
**Goal:** Reduce `main.cpp` (1762 LOC) to its actual job — API pointers + `ReaperPluginEntry` +
dispatch (~the owns-pointers ~120 lines) — by hoisting: `capture_orchestrator` (`RunCapture` /
`captureAndIndexOne` / `renderOffline` / batch/recapture/realtime `Run*`), `scope_resolve`
(`resolveRange`/`resolveRazorRange`/`collectSelectedTracks` + provenance assembly inputs), and
`realtime_lifecycle` (the realtime-capture state machine + globals + selection guards).
**`FxBypassGuard` moves out but stays a stack RAII object (precision-critical); the realtime idle
tick stays a single pointer test.** CONTEXT.md §Phase Q (main split seams; FxBypassGuard +
realtime-tick guardrails). See `docs/product/code-organization.md` §2.1, §3.
**Verify:** CTest green at every commit. Capture (offline + realtime + batch + recapture) behaves
identically in DAW; the null test still nulls, bit-identical repeats still match (the precision
invariants `FxBypassGuard` protects are unchanged); capture ≠ placement holds (no hoisted `Run*`
path gains an `InsertMedia` call). The realtime idle fast-path is still a single pointer test.
`main.cpp` is now pointers + entry + dispatch only.
**Depends on:** Q-W1. Independent of Q-W2.
- [ ] Hoist capture orchestration → `capture_orchestrator` (`shell/capture/`); keep
`FxBypassGuard` a **stack RAII** object as it moves (precision-invariant-critical).
- [ ] Hoist scope/source resolution + provenance assembly inputs → `scope_resolve`.
- [ ] Hoist the realtime-capture lifecycle state machine + globals + the two RAII selection
guards → `realtime_lifecycle`; **idle tick stays a single pointer test.**
- [ ] Leave `main.cpp` = API-pointer ownership + `ReaperPluginEntry` + dispatch; move to `app/`.
- [ ] **Naming rider (Q-9, if settled):** align the `capture_realtime` (shell) / `realtime_record`
(pure) word-order inversion to the house shell↔core convention (rec: stem `capture_realtime`,
shell suffixed) — a free rider since W3 already hoists the realtime lifecycle. No rename on a
file this wave isn't already touching (Q-7).
- [ ] Verify in DAW: null test nulls, bit-identical repeats match, capture≠placement holds;
CTest green; no realtime-tick branch-shape change.
> **Landed on `phase-q` (2026-07-29, merge of `pq-w3-main`). Integrated suite 61/61 green,
> reviewed-approved.** `app/main.cpp` reduced 1897 → 653 LOC (pointers + entry + dispatch; the
> remaining bulk is the registration residue Q-W6 dissolves) via four hoists into
> `shell/capture/`: `capture_orchestrator`, `capture_batch`, `scope_resolve`,
> `realtime_lifecycle`; `FxBypassGuard` moved intact as a stack RAII object; the realtime idle
> tick stays a single pointer test; `ICaptureBackend` deleted (T4-26) with the
> CLAUDE.md/CONTEXT-ARCHIVE corrections landed in the same commit; the Q-9 rename done (pure
> `core/capture/capture_realtime`, shell `capture_realtime_shell` + `capture_realtime_finalize`
> split, T4-08); `stampCaptureSample` dedupe (T2-09, divergent time-sig behavior preserved via
> caller arg); `makeUniqueTag` gains a per-session monotonic counter (T1-11 behavior fix — stems
> now `<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`
**Goal:** Split the two unrelated command-id families in one TU (981 LOC) into
`design_view_actions` / `bank_actions` / `prune_action`, and **dedupe** `actions.cpp`'s own
`promptText`/`mintBankId` and bank verbs against the `panel_bank_ops` single-owner established in
Q-W2. `prune_action` keeps the `doBankPruneFolder` deletion authority contract intact (routes to
`persist`'s `prune_fs` after W5). CONTEXT.md §Phase Q (actions split seams; bank-verb dedupe).
See `docs/product/code-organization.md` §2.1, §2.4.
**Verify:** CTest green at every commit. Every action fires identically in DAW (Design View
family; multi-bank create/rename/reorder/delete/evacuate/activate/move/copy/remove; prune). The
bank-CRUD verbs have **one** implementation home (no `bank_panel`/`actions` duplication). Each
bank verb still wraps its mutation in one batched undo point; the prune action still writes no
ext state and opens no undo point. Command-id strings are **unchanged** (FOREVER-STABLE
contract — a reorg must not touch a shipped command id).
**Depends on:** Q-W2 (`panel_bank_ops` is the dedupe target). Independent of Q-W3.
- [ ] Split → `design_view_actions` (toggle/activate/tag/untag/showBoth/moveItems),
`bank_actions` (bank CRUD family), `prune_action` (`doBankPruneFolder` — the single
file-deletion action).
- [ ] Dedupe `actions.cpp`'s `promptText`/`mintBankId` + bank verbs against `panel_bank_ops`
(one owner); do **not** change any command-id string.
- [ ] Verify in DAW: all action families fire unchanged; one bank op = one Ctrl-Z; prune still
no-undo/no-ext-state; CTest green.
> **Landed on `phase-q` (2026-07-29, merge of `pq-w4-actions`). Integrated suite 61/61 green,
> reviewed-approved.** `actions.cpp` (1019 LOC) split into `design_view_actions` / `bank_actions`
> / `prune_action`, plus a fourth shared `action_registry` TU, all under `shell/actions/`;
> `promptText`/`mintBankId` deduped against `panel_bank_ops`; bank verbs reshaped to promptless
> inner verbs (one mutation home, two UX skins — panel and actions each keep their exact prior
> UX); command-id suffixes/display phrases verified byte-identical in review; `prune_action`
> stays a clean deletion-authority isolate (no `Undo_*`, no ext-state writes);
> `persistBankOp`/`persistBook` gain null-session guards; `promptText` renamed `promptBankName`.
> See `COMPLETED.md` for the full narrative.
>
> **Review note (🟡, resolved in Q-W6):** two session pointers / a null-session-as-model-rejection
> misreport (unreachable today) was resolved by Q-W6's `bank_ops` lift.
>
> **In-DAW verification (action families, one-op-one-Ctrl-Z, prune fail-safes) is PENDING on
> `phase-q`** — deferred by design, not yet performed.
## Q-W5 — split `persist.cpp` (isolate the single file-deletion authority into `prune_fs`)
**Goal:** Split `persist.cpp` (766 LOC, 5 responsibilities) into `session` (lifecycle+poll,
`BeginLoadProjectState` reload hook), `ext_state_io` (the ext-state ↔ JSON serialization bridge +
GUID minting + folder relocation), and **`prune_fs`** (prune scanning + `deleteOrphanFile` via
`SHFileOperationW`). The split **concentrates** the byte-deleting authority into one obvious
module — it must never spread it. CONTEXT.md §Phase Q (persist split seams; deletion-authority
isolation). See `docs/product/code-organization.md` §2.1, §7.
**Verify:** CTest green at every commit. Session save/load/undo-reload, ext-state round-trip,
folder relocation, and prune deletion all behave identically in DAW. **File deletion lives in
exactly one module (`prune_fs`)** — the single-file-deletion-authority invariant is *improved*
(concentrated), never diluted. Relative-paths-only persistence is unchanged.
**Depends on:** Q-W1. Best after Q-W4 (so `prune_action` routes cleanly to `prune_fs`), but
independently landable.
- [ ] Split → `session` (lifecycle/poll + `projectconfig` reload hook), `ext_state_io`
(serialization bridge + GUID minting + folder relocation).
- [ ] Isolate prune scanning + `deleteOrphanFile` (`SHFileOperationW`) → **`prune_fs`** — the
one file-deletion module; nothing else may delete bytes.
- [ ] Verify in DAW: save/load/undo-reload/relocation/prune unchanged; deletion authority is one
module; relative-paths-only holds; CTest green.
> **Landed on `phase-q` (2026-07-29, merge of `pq-w5-persist`). Integrated suite 61/61 green,
> reviewed-approved.** `persist.cpp` (853 LOC) split into `session` / `ext_state_io` / `prune_fs`
> under `shell/persist/` + `persist_internal.h`; the file-deletion authority is concentrated —
> `SHFileOperationW`/orphan-remove lives in exactly one anonymous-namespace function in
> `prune_fs.cpp`, verified tree-wide; the prune fail-safe chain stays byte-intact. T2-04's
> `GetProjExtState` grow-loop is unified as a header-only template, with all three hand-rolled
> copies rewired (`usage_scan`'s start cap raised 4KB→64KB, allocation-only, verified
> equivalent). The Q-W1 `bank_book_json` residual lands via a private static `nameKey`
> (Daniel-approved option a) — `bank_book.cpp` is now ~462 LOC. `persist.h` is kept as a compat
> umbrella for parallel safety (retired in Q-W6); deletion-authority wording is scoped precisely
> in headers; the grow-loop gains a defensive NUL. See `COMPLETED.md` for the full narrative.
>
> **In-DAW verification (save/load/undo-reload, ext-state round-trip, folder relocation, prune
> deletion) is PENDING on `phase-q`** — deferred by design, not yet performed.
## Q-W6 — OCP registration-table + residual fat-header (I) splits
**Goal:** Close the last SOLID wart: replace the ~350-line hand-written **non-table** action
registration blocks (now isolated in `app/main.cpp` after Q-W3) with a **registration table**, so
adding an action edits one place, not four parallel ones (OCP). Split any remaining fat headers
(`capture.h`/`persist.h`) not already resolved by their TU splits (I). CONTEXT.md §Phase Q (OCP
registration-table). See `docs/product/code-organization.md` §2.3, §6 (Q-6).
**Verify:** CTest green at every commit. Every action still registers, appears in the Actions
list, and fires via `hookcommand` exactly as before; command-id + display strings unchanged
(FOREVER-STABLE, per-channel); unload still mirror-unregisters everything. Adding a hypothetical
new action now touches the table only (demonstrated in review, not shipped). Remaining fat
headers are segmented.
**Depends on:** Q-W3 (registration code must be isolated first). Sequenced last; the most
droppable point if the phase needs narrowing (Q-6).
- [ ] Convert the hand-written `Register("command_id"/"gaccel"/"hookcommand")` blocks to a
data-driven registration table; unload mirror-unregisters from the same table.
- [ ] Split residual fat headers (`capture.h`/`persist.h` and any other) alongside their TUs (I).
- [ ] Verify: all actions register/fire/unregister unchanged; command-id strings untouched; CTest
green.
> **Landed on `phase-q` (2026-07-29). Integrated suite 61/61 green, reviewed-approved.** Action
> registration/gaccel/hookcommand-dispatch/mirror-unregister all iterate one `ActionTableRow`
> table (flat function-pointer dispatch, no `std::function`/virtual); adding a new action now
> touches one table row only; `main.cpp` shrinks 653→404. FOREVER-STABLE suffixes/phrases/
> retired-ids verified byte-identical row-by-row in review; capture rows derive their
> suffix+phrase from the pure `captureActionTable()` (the parallel-list risk is gone by
> construction). See `COMPLETED.md` for the full narrative.
>
> **Phase-end cleanup riders (landed in this wave):** `bankOp*` verbs + `persistBankOp` lifted to
> new `shell/bank_ops` taking `ReaSamplerSession&` (dissolves the Q-W4 🟡 review note);
> `persist.h` umbrella retired (13 callers repointed); `capture.h`'s realtime seam moved to
> `capture_realtime_shell.h`; the `wav_trim.h` shim + its INTERFACE target deleted;
> **`core/namespaces.h` DELETED** (the interim Q-W1 shim's contract fulfilled — ~26 includers
> rewired); the grow-loop rehomed to `core/wire/ext_state_read.h`; a stale-comment sweep
> (`persist.cpp`/`bank_panel.cpp` refs); CLAUDE.md's persist/bank_book/actions/wav_codec bullets
> corrected in-wave.
>
> **Review-noted follow-on (not landed, deferred):** extending the table pattern to the
> design_view/bank/ingest families' hand-registration; `channelIdFor`'s shared string-store scan
> is correct-by-prefix-disjointness — a suffix-keyed map would make it structural, but isn't
> required; `view_mode_model.h` (748 LOC) remains the largest header (T4-06's planner split
> stays optional/deferred).
>
> **In-DAW verification (all action families, registration/fire/unregister parity) is PENDING on
> `phase-q`** — deferred by design, not yet performed.
## Phase Q — sequencing
```
@@ -488,27 +552,51 @@ GATE: Phase S + Phase L L3 merged to dev (D2 complete, M9 abandoned) — tree qu
("when Phase S and L3 are finished" — L1/L2/L3/L4L7 all landed — GATE SATISFIED)
Q-W0 (pre-restructure functional + DSP quality audit — findings report + triage)
── SUB-GATE: triage complete + Daniel signed off on every disposition ──
(fix-now findings remediated/assigned; downstream Q-W1..W6 reshaped as needed)
Q-W1 (safe opener: core/json extract + directory/namespace layout on clean modules)
├─► Q-W2 (split bank_panel) ──► Q-W4 (split actions + dedupe bank verbs vs panel_bank_ops)
├─► Q-W3 (split main.cpp; hoist orchestration) ──► Q-W6 (OCP registration-table + I splits)
└─► Q-W5 (split persist; isolate prune_fs) [best after Q-W4]
Q-W0 (audit + triage + report — COMPLETE; all 59 dispositions signed off 2026-07-28;
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 is the **entry point** — the functional/DSP audit runs FIRST and gates Q-W1 (no structural
point begins until its triage closes and Daniel signs off). W1 is then the safe, high-leverage
structural opener (all later waves assume the layout it establishes). The four god-module splits
(W2W5) are risk-ordered and mostly parallel-safe; W4 depends on W2's `panel_bank_ops`, W6 depends
on W3's isolated registration code. Big-bang is rejected — every wave is independently landable and
CTest-green.
Q-W0 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`.)
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>&`;
+1673
View File
File diff suppressed because it is too large Load Diff
+279
View File
@@ -0,0 +1,279 @@
# Q-W0 Track 1 — DSP / audio algorithm-quality audit (findings)
Static analysis only; no code changed. Surfaces per the track brief: `src/vst/pitch_shift.{h,cpp}`
(highest priority — GA correlation-aligned SOLA rewrite + GA2 prime + GA3 freeze, never audited),
`src/vst/sampler_core.{h,cpp}`, `src/peaks.*`, `src/wav_trim.*`, the capture/tail paths
(`src/capture.cpp`, `src/capture_realtime.cpp`, `src/realtime_record.h`), `src/vst/master_gain.*`,
`src/vst/velocity_curve.*`.
Dispositions follow Q-11 (SETTLED): default document-and-defer; a bounded SOLA fix is weighed
before any technique replacement; technique replacement is Daniel's decision at triage. Note on
"assigned wave": **no structural wave Q-W1..Q-W6 opens the `src/vst/` DSP files** (they target
`core/json`, `bank_panel`, `main`, `actions`, `persist`, registration tables) — so any DSP finding
triaged fix-now is remediated in Q-W0 itself or folded in as a new point before Q-W1 begins.
I cannot listen; every artifact below is stated as mechanism + predicted audible consequence.
Perceptual materiality is Daniel's call.
---
## Overall verdict on the pitch engine (Q-11 framing)
The correlation-aligned SOLA in `pitch_shift` is **not a reinvented wheel in the pejorative
sense** — single-tap SOLA with normalized cross-correlation splice alignment, parabolic sub-sample
peak refinement, and amplitude-complementary raised-cosine fades *is* an established time-domain
technique family (SOLA/TD-PSOLA lineage), and the implementation is unusually well-defended:
normalized (not raw) correlation, ratio-scaled fade lengths with a drain-headroom derivation,
prime-with-real-content onset, frozen-writer tail, filled-span clamping, and double-before-int64
clamps at the overflow-prone spots. The RT discipline holds throughout: `process()` does no
allocation, no locks; the splice burst is bounded and fires once per splice cadence, not per frame.
**No technique replacement (phase-vocoder / WSOLA) is warranted on this evidence.** The findings
below are bounded-fix candidates and documented limits within the existing approach, exactly the
Q-11 escalation ladder's first rung.
---
## Findings
### T1-01 — Stereo Preserve: per-channel independent splice alignment decorrelates L/R
- **Location:** `src/vst/sampler_core.cpp` `Voice::advanceFrame` (Preserve branch, ~577589) +
`src/vst/pitch_shift.cpp` `PitchShifter::splice`.
- **Mechanism:** a stereo Preserve voice owns two `PitchShifter`s, each running its **own**
correlation search on its own channel's PCM. `bestLag + frac` differ per channel at every splice,
so after the first splice the two read taps sit at different ring positions — an inter-channel
time offset of up to ±`maxLag` (= window/4 ≈ **12.5 ms** at the 50 ms window), re-drawn at every
splice (cadence ≈ window/|ratio1| frames). The splice *schedules* also diverge (delay drift
depends on tap position), so L and R fade at different times.
- **Predicted artifact:** on genuinely stereo captures played through Preserve off-root: stereo
image wander / widening that changes at the splice cadence, and comb-filter coloration on any
mono sum. Correlated stereo content (the common case for a captured bus) is the worst case.
Dual-mono (mono sample in stereo bus) is unaffected — the code correctly mirrors one shifter.
- **Severity:** **High** (Preserve is the product-default pitch engine and the output bus is
permanently stereo with channel mode auto-defaulting from the capture — this hits the flagship
path on stereo material).
- **Disposition proposal:** this is the strongest candidate for a **bounded SOLA fix** (Q-11 rung
1): link the channels — run the correlation search once (on the L+R mid signal, or on L as
master) and apply the same `bestLag + frac` and splice schedule to both channels. Standard
practice for stereo SOLA. It reshapes the `PitchShifter` seam slightly (splice decision must be
computable once and applied to two rings — e.g. a lag-provider hook or a two-channel shifter),
but no technique change and no new dependency. Recommend **fix-now in Q-W0** pending Daniel's
triage call; if deferred, record it as the known stereo-Preserve limitation.
### T1-02 — Ratio slew mid-fade can drain the outgoing tap past the writer
- **Location:** `src/vst/pitch_shift.cpp` `splice()` fade-length cap (~301311) + `process()` tap
advance (~360366).
- **Mechanism:** `fadeLen_` is capped from the drain headroom **at splice time** using the
then-current `ratio_`. The pitch envelope legitimately slews the ratio per frame
(`setShiftRatio` mid-fade). A ratio that **rises** after the splice (pitch-env attack toward a
positive peak, or attack from a negative dip back to base) drains tap B faster than the cap
assumed; the code comment claims the 2-frame margin covers "any realistic per-frame bias", but
the margin is absolute, not slew-proportional: e.g. a splice at ratio ≈ 1 sets
`fadeLen_ = window/4` with no cap (drainRate ≈ 0), and a pitch-env attack ramping to +24 st
(ratio 4) within those ~12 ms drains tap B ≈ 3·window/4 — far past the `dLow` ≈ window/4
headroom. Tap B laps the parked/advancing writer and reads ring-length-stale content at up to
~half fade gain.
- **Predicted artifact:** a periodic click/garble burst at the splice cadence during fast upward
pitch-envelope ramps on Preserve voices. Only reachable with the AD pitch envelope enabled and
steep; base transpositions (constant ratio) are correctly covered by the existing cap.
- **Severity:** Med.
- **Disposition proposal:** document-and-defer (needs pitch-env + Preserve + steep attack to
trigger), with a cheap bounded fix noted for whenever the file is opened: re-tighten `fadeLen_`
when the ratio increases mid-fade (the `freezeTail()` re-anchor block is the exact pattern to
reuse), or clamp tap B's delay to ≥ 2 during a fade.
### T1-03 — Preserve prime ignores the Trigger play-end bound (short-span rings hold cut content)
- **Location:** `src/vst/sampler_core.cpp` `Voice::start` prime block (~375400) vs. the GA3
`feedBound` in `advanceFrame` (~568572).
- **Mechanism:** the per-frame feed treats `playEnd_` (Trigger) / `frameCount` as source
exhaustion and freezes the writer so "no padding ever enters the ring" (GA3). But `start()`
primes a **full window** from `pcm[q]` bounded only by `frameCount` — not by `playEnd_` — and
pads with zeros past the sample end while declaring the whole window `filled_`. Two
consequences for spans shorter than the 50 ms window: (a) a Trigger zone's ring holds real PCM
**past the user's chosen stop**, which an up-shifted tap can reach and play (transposed) before
`readPos_ ≥ playEnd_` frees the voice; (b) a sample shorter than the window gets zero padding
inside the ring as declared-valid history, so splices can land in silence — a bounded re-entry
of exactly the burst/gap onset artifact GA2/GA3 eliminated, scoped to sub-50 ms material (short
drum one-shots are realistic content).
- **Severity:** Med (bounded to short spans / short samples in Preserve; inaudible for spans ≥ one
window).
- **Disposition proposal:** bounded fix candidate — prime `min(window, span-to-feedBound)` frames
and call `freezeTail()` immediately after prime when the span is shorter than a window (the GA3
machinery then recycles the real short tail, which is its designed behavior). Small and
contained in `Voice::start`. Recommend fix-now in Q-W0 if Daniel agrees the short-one-shot case
matters; else document-and-defer with this note as the record.
### T1-04 — No sustain-loop crossfade (hard loop seam)
- **Location:** `src/vst/sampler_core.cpp` `Voice::advanceFrame` loop wrap (~493498, 610612).
- **Mechanism:** the sustain loop wraps by subtracting the loop length (phase-preserving) and the
interpolation partner wraps `i1 → loop.start`, giving one-sample continuity only. There is no
crossfade region: unless the user's loop points sit at amplitude/slope-matched positions, every
loop pass produces a step discontinuity — a click at the loop rate. `waveform_view`'s
zero-crossing snap on the loop markers mitigates but does not remove it (zero crossings with
mismatched slopes still click). Established samplers crossfade the loop seam (equal-power over a
user- or fixed-length region).
- **Severity:** Med (musically prominent when it hits, fully user-avoidable with careful loop
placement).
- **Disposition proposal:** document-and-defer — a loop-crossfade is a *feature* (needs a
crossfade-length parameter and UI surface), not a bug fix; wrong scope for a reorg phase. Record
as a known limitation beside the zone-loop spec.
### T1-05 — Linear interpolation + no band-limiting on repitch (both engines)
- **Location:** `src/vst/sampler_core.cpp` Varispeed read (~607624); `src/vst/pitch_shift.cpp`
`readTap` (~172185).
- **Mechanism:** all fractional reads are first-order (linear). Linear interpolation's frequency
response rolls off highs and leaks imaging sidebands (the interpolation image spectrum is
attenuated only ~12 dB/oct); Varispeed up-shifts additionally alias (reading faster than 1× with
no pre-filter folds source content above the post-shift Nyquist back into band). This is classic
hardware-sampler behavior — often accepted, sometimes desired — and both engines share it
consistently.
- **Severity:** Low (quality ceiling, not a defect; deterministic and stable).
- **Disposition proposal:** document-and-defer as a recorded trade-off. If a quality bump is ever
wanted, a 4-point cubic Hermite read is a drop-in bounded upgrade at both call sites (no
structural change); band-limited varispeed is a much bigger lift and not recommended.
### T1-06 — Correlation search: coarse step 4 can mis-lock on very high fundamentals; maxLag bounds alignment to ≥ ~80 Hz
- **Location:** `src/vst/pitch_shift.cpp` `splice()` search loops (~240257) and `configure()`
geometry (~6872).
- **Mechanism:** two documented-by-construction limits. (a) The coarse search samples the
correlation every 4 lags and refines ±3 around the coarse best — full integer coverage only
*near* the coarse winner. For content whose correlation oscillates with period < ~8 samples
(fundamentals above ~5.5 kHz at 44.1k), the coarse grid can alias and lock a non-optimal region;
the splice then lands up to half a period misaligned. (b) `maxLag = window/4` (~12.5 ms) cannot
span a full period below ~80 Hz, so deep-bass fundamentals cannot be period-aligned and splices
degrade toward unaligned OLA there. Both are inherent range/cost trades every SOLA makes; the
in-code comment already states (b).
- **Severity:** Low (edge content: pure tones > 5 kHz, fundamentals < 80 Hz).
- **Disposition proposal:** document-and-defer; record both bounds as the engine's stated
operating range. No change recommended — widening either costs splice-burst CPU linearly.
### T1-07 — `splice()` up-jump clamp comment contradicts the code (margin direction)
- **Location:** `src/vst/pitch_shift.cpp` ~196210.
- **Mechanism:** the comment derives the "tight cap" as `filled_ - d - maxLag_ - 2`, then says the
code's `- 1` is "one sample of conservative margin" — but `-1` permits a *larger* jump than
`-2`, i.e. the code is *less* restrictive than the comment's own derivation; the sentence has
the direction backwards. Re-deriving: the deepest probe is the parabola's outer lag at
`d + jump + maxLag + 1` (the interpolator's `i1 = i0 + 1` read-ahead moves *younger*, not
deeper), so the code's `-1` is exactly tight and the comment's `-2` double-counts the
interpolator. No out-of-range read either way; the comment is wrong, not the code.
- **Severity:** Low (doc-only; misleads the next maintainer of a safety-critical clamp).
- **Disposition proposal:** fix-now (comment rewrite, zero behavior change) — fold into whichever
Q-W0 remediation touches `pitch_shift`; if none does, a standalone one-line doc fix in Q-W0.
### T1-08 — Linear-in-amplitude ADSR decay/release segments
- **Location:** `src/vst/sampler_core.cpp` `AdsrEnvelope::tick` (~131170).
- **Mechanism:** decay and release ramp linearly in amplitude. Constant-slope amplitude is
constant-dB-rate nowhere: a long release spends most of its wall-clock at perceptually loud
levels then collapses abruptly (in dB terms the curve is logarithmic-late). Classic samplers use
exponential (constant-ratio) segments for decay/release. The evaluator itself is correct and
well-tested (release-from-current-level, hold-0 byte-compat re-dispatch are both right).
- **Severity:** Low (character, not correctness; the perceptual judgment is Daniel's).
- **Disposition proposal:** document-and-defer. An exponential-segment option is a contained
evaluator change but alters every existing instrument's envelope feel — a product decision, not
a Q-W0 cleanup.
### T1-09 — Takeover-declick: `declickR_` is dead state
- **Location:** `src/vst/sampler_core.cpp` (~598599, 639646, 515518).
- **Mechanism:** both channels deliberately share one blend weight (`declickL_` — commented), but
`declickR_` is still seeded and decayed every frame and never read for output. Dead state that
invites a future L/R-weight divergence bug. The blend itself audits **clean**: `out' =
(1w)·out + w·ref` is a convex combination for w ∈ [0,1], so `|out'| ≤ max(|out|,|ref|)` — the
rev-2 boundedness claim is mathematically sound, the boundary-frame identity holds, and the
ring-out path on voice end is handled (the peer-path symmetry is present).
- **Severity:** Low (hygiene; no audio effect).
- **Disposition proposal:** fix-now-trivial (delete the field or rename the shared weight) — fold
into any Q-W0 edit of `sampler_core`; not worth its own change otherwise.
### T1-10 — `planWavTruncate` silently drops chunks located after `data`
- **Location:** `src/wav_trim.cpp` `planWavTruncate` (~151), `capture_realtime.cpp`
`trimAutoTailInPlace`.
- **Mechanism:** the plan truncates the file at `dataByteOffset + keptDataBytes`. Any RIFF chunk
REAPER wrote *after* the data chunk (bext/iXML/smpl orderings vary by writer) is discarded; the
RIFF size is patched consistently so the result is a valid WAV, but metadata is lost without a
trace. The PCM and the trim boundary math themselves audit clean (file-rate-authoritative frame
math, scan confined to the tail region, -72 dB threshold single-sourced from
`kAutoTrimThresholdDb`, one-frame-past-last-audible per spec, no-trim fallbacks total).
- **Severity:** Low (metadata only; audio unaffected; trim is a convenience path).
- **Disposition proposal:** document-and-defer — note the behavior in the header's FORMAT
ASSUMPTION block when the file is next touched. Preserving trailing chunks would complicate the
single-truncating-write design for no audio benefit.
### T1-11 — `makeUniqueTag` has one-second resolution (collision window)
- **Location:** `src/capture.cpp` (~224227) and `src/capture_realtime.cpp` (~120123).
- **Mechanism:** the uniqueness tag is `std::time(nullptr)` — 1 s resolution. Two captures of the
same `baseName` within the same wall-clock second derive the same file stem: the offline path
would overwrite the first render's file and mint two Samples with colliding ids. Reachable in
practice via `batch_capture` driving several short renders back-to-back. The realtime path
can't self-collide (transport exclusivity) but shares the pattern. DSP-adjacent rather than
DSP; recorded here because the capture paths are this track's surface — Track 2 may claim it.
- **Severity:** Low-Med (silent data loss on collision; narrow window).
- **Disposition proposal:** fix-now candidate, trivial: append a per-session monotonic counter to
the tag (both call sites). Belongs wherever Track 2/triage routes capture-path hygiene; Q-W3
(main/orchestration split) is the nearest wave that opens the extension capture flow, else Q-W0.
---
## Surfaces that came back clean
- **`src/peaks.*` — clean.** The bin partition `[b·frames/binCount, (b+1)·frames/binCount)` is
exact integer math, remainder-distributing, no dropped tail; overflow guarded; short-buffer
clamped; per-channel with no fold (invariant honored). `columnMinMax` mirrors the partition with
64-bit products and the enclosing-bin fallback. `lastFrameAboveThreshold` scans backward with a
correct strictly-greater test and no wrap hazard.
- **`src/wav_trim.*` — clean** except T1-10 (metadata note). Chunk walk is bounds-checked and
total; even-byte padding honored; extensible-format float discrimination via the SubFormat GUID
leading tag is correct; LE reads via `memcpy` (no aliasing UB); truncate plan never grows.
- **`src/capture.cpp` (offline) — clean** from the algorithm-quality lens except T1-11. Exact
unrounded bounds, dither forced off (bit-identical repeats), float32-only with ground-truth
format blob, surgical trim-end normalize only in Auto, full snapshot/restore RAII. The
precision-invariant plumbing is disciplined.
- **`src/capture_realtime.cpp` + `src/realtime_record.h` — clean** except T1-11 (shared) and the
already-in-code DAW-verify flags (take/frame-0 alignment assumption for the trim; abort()'s
best-effort finalize racing the flush — both explicitly documented in place, correctly scoped).
The record state machine's decisions are pure and ceiling-bounded; the trim is best-effort and
never eats the range body.
- **`src/vst/master_gain.*` — clean.** Taper endpoints single-sourced; norm-0 true-zero detent
with the finite 60 dB floor; unity at ≈ 0.714 as documented; inverse collapses sub-floor values
to the detent (documented); non-finite input clamped. The dB↔linear math is correct.
- **`src/vst/velocity_curve.*` — clean.** The FritschCarlson tangent is the standard
weighted-harmonic-mean form (w₁ = 2h₂ + h₁, w₂ = h₂ + 2h₁), which bounds m ≤ 3·min(d₁,d₂) —
monotonicity and no-overshoot inside [0,1] hold as claimed; sign-change/flat neighbors pin to 0;
zero-span steps and coincident-X knots are handled; deserialize repairs the invariant
defensively. `eval` once per note-on keeps it off the per-frame path.
- **`sampler_core` voice/steal/mono machinery — clean** (beyond the findings above): the steal
policy is deterministic and as documented; the mono held-stack has correct range guards against
uint8 aliasing, order-preserving removal, per-note velocity for retrigger fallback, and CC 123
as its only reset path; `soundingNote()` correctly excludes ring-out tails from the Preserve cap
and legato predicates; the two-tier panic semantics are right; both render overloads share one
summation discipline with no allocation; envelope/keymap resolution honors the rate-free-seconds
invariant (frames resolved at keymap build against the live rate — the prior frame-domain
incident is not repeated here). The per-frame `std::pow` when the pitch envelope is active is
bounded and acceptable.
- **`pitch_shift` core machinery — clean** (beyond the findings above): the safe-band geometry,
filled-span clamps, normalized correlation, parabolic sub-sample refinement, complementary
raised-cosine fade (correct for phase-aligned content; the 6 dB midpoint on uncorrelated
content is a documented, benign trade), `freezeTail`'s fade re-anchor continuity, and the
down-shift ring-lap margin all audit sound. Down-shift writer-lap is unreachable for any ratio
above ≈ 109 st; up-shift fade drain is covered to +24 st and beyond by the ratio-scaled cap
(T1-02 is the slew case only).
---
## Summary table
| ID | Surface | Finding | Severity | Disposition proposal |
|-------|----------------------------------|------------------------------------------------------------|----------|---------------------------------------------------|
| T1-01 | pitch_shift + sampler_core | Stereo Preserve: independent L/R splice alignment | High | Bounded SOLA fix (linked lag) — recommend fix-now in Q-W0; Daniel's call |
| T1-02 | pitch_shift | Ratio slew mid-fade can lap the outgoing tap | Med | Document-and-defer; bounded re-cap noted |
| T1-03 | sampler_core (Preserve prime) | Prime ignores Trigger playEnd / pads short samples | Med | Bounded fix candidate in Q-W0; else defer w/ note |
| T1-04 | sampler_core (loop) | No sustain-loop crossfade (hard seam) | Med | Document-and-defer (feature, not reorg scope) |
| T1-05 | sampler_core + pitch_shift | Linear interp, no band-limiting on repitch | Low | Document-and-defer (recorded trade-off) |
| T1-06 | pitch_shift | Coarse-search HF mis-lock; ≥ ~80 Hz alignment bound | Low | Document-and-defer (stated operating range) |
| T1-07 | pitch_shift | maxJump clamp comment contradicts code | Low | Fix-now (comment-only), in Q-W0 |
| T1-08 | sampler_core (ADSR) | Linear-amplitude decay/release segments | Low | Document-and-defer (product decision) |
| T1-09 | sampler_core (declick) | `declickR_` dead state | Low | Fix-now-trivial, fold into any Q-W0 edit |
| T1-10 | wav_trim | Truncate drops post-`data` chunks (metadata) | Low | Document-and-defer (header note) |
| T1-11 | capture.cpp + capture_realtime | 1 s-resolution unique tag → batch collision window | Low-Med | Fix-now candidate (monotonic counter); route at triage |
Clean surfaces: `peaks`, `master_gain`, `velocity_curve` (fully); `wav_trim`, offline + realtime
capture paths, and the non-flagged machinery of `sampler_core` / `pitch_shift` (clean with the
noted exceptions above).
@@ -0,0 +1,333 @@
# Q-W0 Track 2 — architecture-smell audit (functional lens)
Static analysis of the whole `src/` tree (extension + `src/vst/`), 2026-07-28, branch
`pq-w0-audit`. Complement to the grep-verified SOLID audit (§2) and naming audit (§2b) in
`docs/product/code-organization.md` — this track reports the **functional** smells those did not
target: duplicated *algorithms* (not merely duplicated responsibilities), reinvented wheels,
poor abstractions, and leaky pure/shell boundaries. Findings already catalogued there (the four
god-modules, the 4× JSON `Parser`, fat headers, `promptText`/`mintBankId` duplication, namespace
flatness, naming families) are **not restated**; where a finding below touches the same file it
is because the functional mechanism is new.
Every claim below was verified by grep/read of the actual tree. Line numbers are as of this
audit's snapshot. Wave assignments reference PLAN.md §Q-W1..Q-W6.
---
## Findings
### T2-01 — 3× copy-pasted length-prefixed wire `Cursor`, with security-hardening drift
**Location:** `src/provenance.cpp:56137`, `src/assignment_request.cpp:25121`,
`src/sample_usage.cpp:2589` (plus a fourth sibling: `src/vst/bank_sync.cpp:1128`
`parseBankGeneration` re-rolls the same guarded decimal accumulate).
**Mechanism.** The `<len>':'<bytes>` ext-state wire idiom ("one grammar across every ext-state
seam", per sample_usage's own comment) is implemented as three near-identical `putField` +
`Cursor` copies — and they have **drifted on the hardening**. The two newer copies
(`assignment_request`, `sample_usage`) carry a 20-digit length cap and an overflow guard
(`len > (SIZE_MAX - digit) / 10 → fail`) plus the subtraction-first bounds check
(`len > s_.size() - start`). The oldest copy (`provenance.cpp:6582`) has **neither**: a crafted
long digit run wraps `len` silently, and the additive bounds check `start + len > s_.size()`
can itself wrap, letting a wrapped length pass. Downstream, `parseFingerprint`
(`provenance.cpp:197199`) calls `r.trackGuids.reserve(guidCount)` on an **unbounded** count
parsed by the equally unguarded `fieldSizeT` — a corrupt/crafted `Sample.provenance` string in
the bank JSON can drive `reserve(huge)` into `std::length_error`/`bad_alloc` thrown through the
shell. (`sample_usage` fixed exactly this with its `count > wire.size()/4 + 1` sanity bound,
`sample_usage.cpp:121`; the fix was never backported.) `std::string::assign` clamping keeps the
wrap short of UB, but the parse-integrity promise ("never UB, never a partial value") is upheld
in two copies and eroded in the third — the textbook cost of a duplicated algorithm.
**Severity:** High (the drift already produced a concrete robustness gap on a persisted,
user-editable input; the class of bug will recur with every new wire seam).
**Disposition:** **fix-now, split:** (a) backport the hardened `field()` + a count sanity bound
to `provenance.cpp` **in Q-W0** — small, pure, existing `provenance_tests` covers round-trip and
malformed-input paths; (b) the structural collapse (one shared `wire` codec module beside
`core/json`, all three seams + `parseBankGeneration` consuming it) belongs to **Q-W1**, which is
already the serialization-extraction wave. Rationale: the hazard is cheap to close now; the
dedup is a relocation-adjacent move that should ride the wave already creating `core/`.
### T2-02 — a FIFTH hand-rolled JSON decoder the §2 audit did not count
**Location:** `src/tail_control.cpp:88130` (`valueAfterKey` + `deserializeTailSetting`).
**Mechanism.** The catalogued DRY violation is "JSON `Parser` duplicated 4×" (`bank_model`,
`bank_book`, `view_mode_model`, `owned_manifest`). `tail_control` carries a fifth, structurally
different JSON decode: a substring-scan reader (`json.find("\"key\"")` → skip ws → parse token).
It is correct for the flat single-object payload it reads (the file argues this honestly), but
it is a fifth place JSON-reading behavior is defined, with different tolerance semantics (a key
found inside a *string value* would match — impossible today only because the writer is its own
sole producer). If Q-W1 extracts `core/json` from the four `Parser`s and misses this site, the
"one JSON path" goal is silently not achieved.
**Severity:** Med (no live bug; a completeness gap in the already-planned fix).
**Disposition:** **fix-now, folded into Q-W1** — add `tail_control` to the Q-W1 consumer list
explicitly. Rationale: zero extra cost when `core/json` lands; a stray fifth decoder afterward
would be a defect of the wave.
### T2-03 — `readFileBytes` hand-rolled five times, both sides of the artifact split
**Location:** `src/capture.cpp:232`, `src/capture_realtime.cpp:329` (as `readAllBytes`),
`src/ingest.cpp:86` (comment admits: "of capture.cpp's readFileBytes"),
`src/vst/reasampler_processor.cpp:72`, and inline in `src/vst/reasampler_editor.cpp:749756`.
**Mechanism.** The identical ifstream-binary-ate/tellg/read whole-file loader exists five times
(two spellings, one anonymous inline). Well past extract-on-third-occurrence, and the copies
already disagree cosmetically (name, empty-on-failure comment placement) — the next divergence
will be behavioral (e.g. one copy gaining a size ceiling the others lack).
**Severity:** Med.
**Disposition:** **fix-now, folded into Q-W1** — a trivial pure `readFileBytes` helper in the
`core/` utility home Q-W1 creates; both CMake targets link it. Rationale: five occurrences of a
ten-line function is pure debt with a zero-risk fix, but creating its home is exactly Q-W1's
job — doing it days earlier in the flat tree would just move the file twice.
### T2-04 — the growing `GetProjExtState` read loop, three copies, pure half only half-used
**Location:** `src/persist.cpp:140157` (`getProjExtStateString`),
`src/vst/reaper_bridge.cpp:93107` (self-described "mirrors persist.cpp's growing strategy"),
`src/usage_scan.cpp:168181` (self-described "the persist.cpp idiom").
**Mechanism.** The grow-buffer-until-it-fits retry loop over `GetProjExtState` is implemented
three times, in three TUs, on both sides of the split. The fiddly part — interpreting the int
return against the filled buffer — is *already extracted pure* as
`bridge_marshal::decodeGetProjExtState`, but only `reaper_bridge` consumes it; `persist` and
`usage_scan` interpret `rv` inline with their own conventions (persist: `rv <= 0` → absent;
bridge: return AND non-empty buffer). The absent-vs-truncated-vs-empty semantics are precisely
the kind of edge that drifts when defined thrice. `usage_scan`'s copy is prune-safety-adjacent
(an unreadable usage record must abort the prune) — its read loop deserves the tested pure
decode, not an inline reimplementation.
**Severity:** Med.
**Disposition:** **fix-now, assigned to the downstream wave that opens `persist`**
(Q-W4 per the current wave map; whichever wave splits `persist.cpp` is the moment). Generalize
the retry policy (next-capacity/done decision) into `bridge_marshal` (or its `core/` successor)
and make all three loops consume it. Rationale: touching persist's session machinery outside
its own wave risks the highest-traffic shell for a dedup that has no live bug today.
### T2-05 — 19 rect structs + ~15 inline point-in-rect predicates across the pure UI family
**Location (structs):** `action_bar.h:61`, `bank_grid.h:23`, `card_drag.h:109`,
`component_geometry.h:28,53,106`, `drag_out.h:40`, `footer_bar.h:41`, `mode_switch.h:21,35`,
`overflow_menu.h:23,39`, `prune_button.h:32,46`, `tab_strip.h:24,51`, `tooltip.h:20`,
`vst/editor_geometry.h:19`, `vst/velocity_curve.h:124`.
**Location (predicates):** inline half-open `px >= r.x && px < r.x + r.width && …` re-typed in
`action_bar.cpp`, `bank_grid.cpp`, `card_drag.cpp` (×2), `component_geometry.cpp` (×2),
`drag_out.cpp`, `footer_bar.cpp`, `mode_switch.cpp`, `overflow_menu.cpp`, `prune_button.cpp`,
`tab_strip.cpp` (×2), `bank_panel.cpp:1049`, plus `vst/editor_geometry.cpp:20`.
**Mechanism.** §2b.2 catalogued the *naming/collision* half of this (the `footer_bar.h` "NAME
NOTE" hand-checking smell). The functional half is uncatalogued: nineteen structurally identical
axis-aligned `{x, y, w, h}` record types, each with its own hand-typed containment predicate.
Every new pure-UI module re-mints both. This is Daniel's heuristic (b) verbatim: N near-identical
concrete implementations that one shared type collapses at compile time — one `ui::Rect` + one
`contains(Rect, x, y)` free function (both already exist in embryo as `vst/editor_geometry`'s
`Rect`/`contains`), with per-module aliases or thin wrappers only where a struct carries extra
fields (e.g. `TabRect::index`). Zero runtime cost; deletes ~15 chances for the next half-open/
closed-interval inconsistency to slip in.
**Severity:** Med.
**Disposition:** **fix-now, folded into Q-W2** (the ui/ relocation wave) — collapsing the type
zoo is nearly free precisely when every one of these files is being moved and re-namespaced;
doing it pre-reorg would churn 19 headers twice. Rationale: same-moment-as-relocation is the
stated principle for renames (§2b intro); it holds identically for type unification.
### T2-06 — pure-computable layout math stranded in the VST editor shell (the §2 scope gap)
**Location:** `src/vst/reasampler_editor.cpp``SampleFaceLayout` (~line 922) and its builder,
`ClusterLayout` (~line 964), `zonesStripArea`/`noteEntryFieldsArea`/`noteEntryFieldRect`/
`zonesControlPanel`/`zonesDeckArea`/`zonesCurveButton` (lines 9921042), the channel-toggle
segment rects (~line 1065), banner rect math (~line 1195), among ~49 inline geometry
computations across the 3,065-LOC TU.
**Mechanism.** The codebase's own grammar homes exactly this class of math in pure modules
(`editor_geometry`, `knob_deck`, `curve_popup`, `capture_browser`, …), yet the editor shell has
accreted a second, untested layout layer: whole named layout structs and pure `Rect → Rect`
functions that take only ints and rects, compiled into the one TU that cannot be unit-tested
without a host window. This is the mirrored form of the pure/shell leak (algorithm math living
untestable in a shell). Note also the audit-scope gap this exposes: §2's god-module catalogue
covered the extension tree only — `reasampler_editor.cpp` (3,065 LOC) and
`reasampler_processor.cpp` (1,164 LOC) repeat the bank_panel pattern on the VST side and appear
in no existing finding.
**Severity:** Med (no correctness bug found in the stranded math; the cost is untestability and
the growth trajectory — the editor gained ~500 LOC/phase through r11).
**Disposition:** **document-and-defer, with a named reshape:** Q-W0 should surface a downstream
point (the wave that opens `src/vst/`, or a new one) hoisting the Sample-face/Zone-panel layout
into the existing pure homes (`editor_geometry` is the natural owner). Rationale: a hoist is a
behavior-preserving mechanical move best done under the reorg's test discipline, not pre-reorg;
but it must be a recorded point or the layer keeps growing.
### T2-07 — the extension links the entire voice engine to serialize one preset blob
**Location:** `CMakeLists.txt:383385` (`instrument_drop` → PUBLIC `sample_map`);
`src/instrument_drop.cpp` includes `vst/sample_map.h`, which pulls `sampler_core.h`
`pitch_shift.h` + `velocity_curve.h`.
**Mechanism.** `instrument_drop` (extension side) deliberately reuses
`sample_map::serializeComponentState` so the `.vstpreset` payload and the instrument's own
reader cannot drift — the right DRY call, explicitly documented in CMake. But the shared writer
lives *inside* the module that also owns zone resolution, WAV decode plumbing, and (via header
fan-in) the whole voice engine — so `reaper_reasampler` compiles and links `sampler_core`,
`pitch_shift`, and `velocity_curve` object code it never executes. The abstraction is right; its
*granularity* is wrong: the ComponentState codec is not separable from the engine stack today.
**Severity:** Low (dead weight in the binary and a misleading dependency edge; no runtime cost —
heuristic (c) is about call chains, which this does not add).
**Disposition:** **document-and-defer to Q-W1/Q-W2 module-homing:** when serialization gets its
`core/` home, split a `component_state` codec module (types + serialize/deserialize only) out of
`sample_map`; both artifacts link the codec, only the VST links the engine. Rationale: purely
structural, zero behavior change, and exactly the kind of module-boundary decision the reorg
waves exist to make once, deliberately.
### T2-08 — WAV/RIFF byte-format knowledge spread across four modules, two chunk walkers
**Location:** `src/wav_trim.cpp` (canonical parse: `parseWavLayout`/`extractFloatFrames`),
`src/capture_paths.cpp:31115` (a second, independent RIFF chunk walker for content hashing),
`src/ingest.cpp:108160` (hand-built 32f WAV writer), `src/capture_realtime.cpp:423` (in-place
RIFF/data size patch).
**Mechanism.** The tree is disciplined about *decoding* ("no third WAV reader" — sample_map,
editor, processor all route through `wav_trim`), but RIFF *container* knowledge is still minted
per site: `capture_paths` walks chunks with its own tag/size/pad-byte logic to hash `fmt `+`data`
while skipping metadata; `wav_trim` walks the same container shape for layout; `ingest` writes
headers by hand; `capture_realtime` patches sizes by offset. Four places know the RIFF framing
rules (even-byte padding, chunk-header arithmetic); a drift in any one (e.g. pad-byte handling)
would desynchronize hashing from decoding — the dedup-by-hash and null-test invariants both sit
on this.
**Severity:** Low (all four are currently correct against each other by inspection; the smell is
the maintenance surface, not a live divergence).
**Disposition:** **document-and-defer** — consolidate into a `core/wav` home (walker + layout +
writer + patch) when the reorg assigns module homes. Rationale: pre-reorg consolidation churns
the capture hot path (§3 guardrail) for no functional gain; the reorg wave that relocates
`wav_trim` is the natural moment.
### T2-09 — the two capture backends' Sample-stamping epilogue is copy-paste with silent divergences
**Location:** `src/capture.cpp:490537` vs `src/capture_realtime.cpp:505540`.
**Mechanism.** The finished-capture metadata stamp — `trackGuids`, `channelCount`, `sampleRate`,
`Master_GetTempo`, the `TimeMap_GetTimeSigAtTime` block, the WAV-aware `hashWavContent` content
hash (comment block duplicated verbatim, ~10 lines), `createdTimestamp` — is written twice, once
per backend. The copies have already diverged in quiet ways: offline passes `proj = nullptr`
(active project) to `TimeMap_GetTimeSigAtTime` while realtime pins `st.proj_`; the sampleRate
fallback logic differs in shape; realtime overrides `lengthSeconds` post-hoc. Some divergence is
semantic (realtime's tail-trim length), but the shared stamp is one concept — a future field
(e.g. a new provenance stamp) must currently be added in two places, and the time-sig
active-project vs pinned-project asymmetry is exactly the kind of drift that produces a
wrong-project stamp during a background-project capture.
**Severity:** Med.
**Disposition:** **fix-now, folded into Q-W3** (the wave already hoisting capture orchestration
out of `main.cpp` / right-sizing `capture.h`). Extract a `stampCaptureSample(Sample&, const
CaptureRequest&, ReaProject*)` shared helper; the divergent bits (length override) stay in the
realtime caller. Rationale: the fix touches both backend TUs, which Q-W3 opens anyway; doing it
there keeps one review of the precision-invariant-adjacent code.
### T2-10 — the two thumbnail pipelines' cache-invalidation strategies have drifted
**Location:** `src/bank_panel.cpp:422483` (extension: `computeThumbnail` + pure
`ThumbnailKey{id, width, generation}` via `bank_grid::thumbnailKeyString`) vs
`src/vst/reasampler_editor.cpp:715789` (VST: `monoPcmFor` keyed by bare `sampleId`,
`thumbnailFor` keyed by ad-hoc `sampleId + "|" + binCount`, invalidated by wholesale
`clear()` at lines 159160/894).
**Mechanism.** The dock panel and the VST browser render the same thumbnails through the shared
`peaks::computeEnvelope`, but the caching layer around it was re-designed independently on each
side: the extension bakes the bank generation into a *pure, tested* key type; the editor
hand-concats a string key with no generation and relies on call-site `clear()`s (bank-refresh,
resize). Both are correct **today** — but correctness on the editor side is distributed across
remembering every clear site, and the bin-clamp guard comment ("computeEnvelope pads binCount >
frameCount…") is duplicated verbatim in both TUs (`bank_panel.cpp:462`,
`reasampler_editor.cpp:780`), marking the copied design. A future refresh path that forgets the
clear shows stale waveforms with no test to catch it.
**Severity:** Low.
**Disposition:** **document-and-defer** — when T2-06's layout hoist opens the editor, adopt the
pure `ThumbnailKey` (or a shared `thumb_cache` helper) on the VST side. Rationale: no live bug;
unifying cache policy is a natural rider on the editor wave, pointless as standalone churn.
### T2-11 — ComponentState v1→v11 deserialize chain: sound, but the legacy branches triplicate the shared read
**Location:** `src/vst/sample_map.cpp:775945` (`deserializeComponentState`).
**Mechanism.** Audited the full lift chain for functional soundness: the bounded `ByteReader`
latches on truncation, every version's tail fields carry per-field corrupt fallbacks
(previewVelocity → mid default, voiceCount → default-not-clamp, gain → unity, refs → keep-parsed
prefix), and the strict-prefix envelope discipline is honest. **No correctness finding.** The
smell is shape: the v3, v4, and v5 branches each re-implement the mode-byte → marker → idLen/id
→ zones read sequence that the v6+ shared path also implements (three near-copies of the same
cursor walk, lines 806841 vs 851+), and each new envelope version adds another
`version >= kꞏꞏꞏV*Version` stanza to a function already ~170 lines long.
**Severity:** Low.
**Disposition:** **document-and-defer, explicitly.** The legacy branches are frozen back-compat
contract code with saved-project blobs as their only callers; rewriting them into a table-driven
lift risks the one thing they must never break, for zero user-visible gain. Record the pattern
so the *next* envelope bump (v12) prefers extending the shared path over minting another branch.
(The unbounded-suffix version-constant naming is §2b territory; not restated.)
---
## Surfaces checked and found clean
Recorded per the wave's no-silent-omission rule; each was read/grepped this audit.
- **Pure-module include hygiene, both trees.** Every module CLAUDE.md claims pure was scanned
for REAPER/SWELL/WDL/LICE/VST3-SDK includes: all clean, `src/` and `src/vst/` both. The one
grep hit in `pitch_shift.h` is a comment (the S16 WDL-exclusion note), not an include. The one
cross-tree include (`instrument_drop``vst/sample_map.h`) is pure-to-pure — see T2-07 for
the granularity concern; it is not a boundary violation.
- **`bank_sync`** — the generation/consume decision rules are pure, explicit, and exhaustively
commented (rules 14); `parseBankGeneration` is overflow-guarded (its duplication is rolled
into T2-01's family, not a separate defect).
- **`bridge_marshal`** — one honest job, done pure, with the S1 string-scan JSON reader
documented as retired (verified: no second JSON parser on the VST side; `sample_map` routes
through `BankBook::deserialize`).
- **The realtime record lifecycle***not* an implicit state machine: `RecordPhase` is an
explicit enum with pure per-tick transitions in `realtime_record.h`; `main.cpp` holds only the
handle + project pointer. (Its *residence* in main.cpp is catalogued §2.1; nothing functional
to add.)
- **Project-identity transitions**`classifyProjectTransition` is pure (capture_paths), the
shell passes `sameProjectObject` as a bool to keep it so; the GUID-primary layering is
decision-tabled in one place.
- **`usage_scan`** — every decision delegated to pure `sample_usage`; container recursion is
depth-bounded with a protect-on-truncation fail-safe; the `std::function` parm-getter
indirection is prune-scan-cold (heuristic (c) satisfied — no hot-path chain).
- **Exception boundaries** — the three `catch (...)` sites (`bank_book.cpp:794` stol guard,
`instrument_drop_win.cpp:74` REAPER-callback boundary, `render_settings.cpp:180` stod guard)
are all documented, narrow, and non-swallowing in intent (each converts to an explicit
failure value). No silent error swallowing found.
- **`FxBypassGuard` (main.cpp) vs `view.cpp` park/restore** — both are snapshot-mutate-restore
over track flags and *look* like a dedup candidate; they are deliberately not one. Different
flag sets, different invariants (precision-neutralization vs Design-View parking), different
failure postures. Duplication of shape, not of concept — correctly left separate.
- **Path resolution**`capture_paths::resolveBankFile` is the single resolver on both sides
of the split (panel, insert, drag_out, editor, processor). No parallel path logic.
- **WAV decode on the play path**`wav_trim` is genuinely the only decoder (T2-08 concerns
the *container* knowledge spread, not a second decoder).
- **Draw layer** — the VST editor/embed compile the same `draw_kit`/`theme`/`component_geometry`
the extension uses (verified in CMake + includes); no parallel draw vocabulary grew on the
VST side.
- **Interface cost audit (heuristic (c))**`ICaptureBackend` is the tree's only virtual
interface; two real implementations, dispatched once per capture (cold). No hot-path virtual
or std::function chain found in `sampler_core`/`pitch_shift`/`sample_map` (all static calls).
No interface-with-one-implementation found anywhere.
- **Boolean-parameter proliferation** — swept `src/` headers for multi-bool signatures; the only
hit is `bank_grid::applyClick(…, bool ctrl, bool shift, …)`, which mirrors physical modifier
keys and reads fine at call sites. Not a finding.
## Cross-checks against the §2/§2b audits (gaps noted, not restated)
- §2's evidence base is scoped to the extension tree ("45 files / ~19,800 LOC"); the full tree
is now ~39,000 LOC. The VST shells repeat the god-module pattern uncatalogued
(`reasampler_editor.cpp` 3,065 LOC, `reasampler_processor.cpp` 1,164 LOC) — carried here as
T2-06's scope note so the reorg waves size the `src/vst/` work realistically.
- §2.1's "4× JSON Parser" undercounts by one — T2-02 (`tail_control`).
- §2b.2's shared-rect naming hazard has an uncatalogued functional twin — T2-05.
## Summary table
| ID | Finding | Severity | Disposition | Where |
|-------|---------------------------------------------------------------|----------|-------------------|--------------|
| T2-01 | Wire `Cursor` ×3 with hardening drift; provenance unguarded | High | fix-now (split) | Q-W0 backport + Q-W1 dedup |
| T2-02 | Fifth JSON decoder in `tail_control` | Med | fix-now | Q-W1 |
| T2-03 | `readFileBytes` ×5 across both artifacts | Med | fix-now | Q-W1 |
| T2-04 | Growing ext-state read loop ×3; pure decode half-adopted | Med | fix-now | persist's wave (Q-W4) |
| T2-05 | 19 rect structs + ~15 inline point-in-rect predicates | Med | fix-now | Q-W2 |
| T2-06 | Layout math stranded in VST editor shell (+§2 scope gap) | Med | document-and-defer (named reshape) | src/vst wave |
| T2-07 | Extension links voice engine to share the preset serializer | Low | document-and-defer | Q-W1/Q-W2 homing |
| T2-08 | RIFF container knowledge in 4 modules / 2 chunk walkers | Low | document-and-defer | core/wav homing |
| T2-09 | Capture backends' Sample-stamp epilogue copy-paste w/ drift | Med | fix-now | Q-W3 |
| T2-10 | Thumbnail cache-invalidation strategies drifted across split | Low | document-and-defer | editor wave |
| T2-11 | ComponentState legacy lift branches triplicate the shared read | Low | document-and-defer | (pattern note for v12) |
@@ -0,0 +1,220 @@
# Q-W0 Track 3 — env-coupled-constant domain-modeling audit
Static analysis, 2026-07-28, branch `pq-w0-audit`. Scope: any value stored in an
environment-coupled domain — frames, sample rate, DPI, pixels, tick cadence — that should be
stored **rate-free / device-free and resolved at the point of use** (PLAN.md §Q-W0 env-coupled
bullet; `docs/product/code-organization.md` §2c.3; the load-bearing `sample_map` seconds
invariant). Findings are domain-modeling calls, not "rescale by rate" patches. The judgment bar
applied: a finding requires (a) an env-coupled *stored* domain AND (b) an environment that can
actually change under it. Frame counts computed transiently from seconds at the use site are
correct and are not reported.
Waves referenced for disposition: Q-W1..Q-W6 open `bank_panel.cpp`, `main.cpp`, `actions.cpp`,
`persist.cpp`, and relocate the clean pure libs — **no downstream wave opens
`reasampler_processor.cpp` / `sampler_core.h` / `reasampler_editor.cpp` for logic change**, so
fix-now findings in those files must be remediated in Q-W0 itself.
---
## Findings
### T3-01 — master-gain ramp step is a frame-domain constant anchored to 48 kHz
- **Location:** `src/vst/reasampler_processor.cpp:46-51` (`kGainRampRate = 1.0f / 960.0f`,
`kGainRampSnap`), applied per-sample at `:1069-1085` (stereo) and `:1114-1126` (mono).
- **Stored vs. correct domain:** stored as a **per-sample linear step**`960` is literally
20 ms × 48 000 Hz, and the comment says so ("960 samples @ 48 kHz ≈ 20 ms"). The intended
quantity is a **wall-clock ramp time** (~20 ms); the correct model is a seconds/ms constant
with the per-sample step derived from `sampleRate_` at `setupProcessing` — exactly the
pattern the same file already uses two paragraphs away for `kPreserveWindowMs`
(`:618-623`, `:733-735`).
- **What breaks when the environment shifts:** the ramp's wall-clock length halves at 96 kHz
(~10 ms) and quarters at 192 kHz (~5 ms); at 44.1 kHz it stretches to ~21.8 ms. The FB1
"no zipper" contract degrades silently as the host rate rises. Not persisted, so no on-disk
breakage — but it is a live hardcoded-rate assumption in `src/`, which Daniel's standing
ruling forbids.
- **Severity:** Med.
- **Disposition:** **fix-now, remediated in Q-W0.** Rationale: trivial, isolated,
behavior-identical at 48 kHz; no downstream wave opens this file, so deferring means keeping
a named violation of the no-hardcoded-rate ruling through the whole reorg. Store
`kGainRampSeconds = 0.020`, derive the step from the live rate where `sampleRate_` is set.
### T3-02 — takeover-declick decay is a per-frame coefficient (documented deliberate)
- **Location:** `src/vst/sampler_core.h:409-410` (`kDeclickDecay = 0.95`,
`kDeclickFloor = 1e-4`), applied per output frame in `sampler_core.cpp:514-519`, `:643-645`.
- **Stored vs. correct domain:** a per-output-frame exponential coefficient; the implied
wall-clock decay-to-floor is ~4.2 ms at 44.1 kHz and ~1.9 ms at 96 kHz. The strict
domain-model form would be a time constant in seconds resolved to a coefficient at engine
build.
- **What breaks when the environment shifts:** the takeover-blend residue fades ~2× faster at
96 kHz. Audibly negligible for a declick micro-ramp — and the in-code comment
(`sampler_core.h:397-401`) **already documents this as a deliberate per-frame DSP micro-ramp,
"not a stored wall-clock quantity"**, with the 44.196 kHz variance stated and accepted.
- **Severity:** Low.
- **Disposition:** **document-and-defer.** Rationale: the coupling is already an explicit,
written, bounded design decision in the code; converting it buys no audible improvement.
Triage should ratify the in-code note as the record.
### T3-03 — Trigger-fade UI throw ceiling hardcodes 2 s × 44 100 as `88200.0` frames
- **Location:** `src/vst/reasampler_editor.cpp:395`
(`constexpr double kFadeMaxFrames = 88200.0`), used by `controlValue`/the commit path to
normalize the Trigger fade-in/out knobs.
- **Stored vs. correct domain:** the fade **storage** domain (int64 SOURCE frames, persisted in
the zones payload) is settled and correct — a source-timeline fact, invariant under project-
rate change (PLAN.md §S15). The *UI ceiling*, however, encodes a wall-clock intent ("2-second
max fade throw") as a frame count at an assumed 44.1 kHz source. `88200` is a rate-derived
literal in `src/`, brushing the no-hardcoded-rate ruling even though it never touches disk.
- **What breaks when the environment shifts:** the environment here is the **source file's
rate**: a 96 kHz capture's maximum fade throw is ~0.92 s; a 22.05 kHz file gets 4 s. The knob's
full-scale meaning silently varies per loaded sample.
- **Severity:** Low (UI-only, not persisted, comment flags it as a "build-time residual — one
place to retune").
- **Disposition:** **fix-now, remediated in Q-W0.** Rationale: small and contained — replace
with `kFadeMaxSeconds = 2.0` resolved against the loaded source's rate at the two normalize
sites (the editor already threads `frameCount + rate` through the pack/unpack path,
`envelope_edit.cpp:148`); storage domain unchanged. If triage prefers zero UI-feel change,
the fallback is document-and-defer with the comment amended to name the 44.1 k assumption.
### T3-04 — drop-hint banner duration stored in sync-timer ticks
- **Location:** `src/vst/reasampler_editor.cpp:2920-2923` (`dropHintTicks_ = 6`), decayed in
`onSyncTimer` (`:244-245`); field at `reasampler_editor.h:457`.
- **Stored vs. correct domain:** a wall-clock intent ("a few seconds of banner") stored as a
**count of `kSyncTimerIntervalMs` ticks** (6 × 500 ms). Correct model: a duration in ms,
ticks derived — or a `GetTickCount`-style deadline like the bank panel's tooltip already
uses.
- **What breaks when the environment shifts:** retuning the sync cadence (a plausible perf
tweak — the 500 ms value is itself a tuning constant) silently changes the banner duration.
The comment does state the coupling.
- **Severity:** Low.
- **Disposition:** **document-and-defer.** Rationale: cosmetic, self-documenting at the single
site, and the cadence and hint decay live three lines apart; a fix is fine to fold in
opportunistically if the file is ever opened, but does not justify a Q-W0 edit on its own.
### T3-05 — systemic: no DPI/content-scale support in either UI surface
- **Location:** systemic. VST3 editor: `reasampler_editor.cpp:150-153` (`ViewRect(0,0,840,620)`
default and the size floor at `:816`), all `editor_geometry` / `knob_deck` / `curve_popup` /
`envelope_overlay` px constants (e.g. the 8 px node min-separation, the 28×28 curve button),
cached font sizes in `draw_kit`. Extension side: the LICE-drawn `bank_panel` dock and its
geometry modules. No implementation of VST3's `IPlugViewContentScaleSupport` anywhere in
`src/vst/` (grep: zero hits for content-scale/DPI), no scale factor threaded through the
pure geometry modules.
- **Stored vs. correct domain:** every layout constant is a **physical device pixel** that
silently assumes ~96 DPI. Correct model: logical units × one scale factor resolved at draw
time (the pure geometry modules take widths/heights as parameters already, so a scale factor
threads through cleanly — the constants are centralized, which is the good news).
- **What breaks when the environment shifts:** on a 150200 % Windows display the editor and
dock render physically small (or get bitmap-stretched by the host, blurring text); hit
targets like the 8 px min node separation shrink below comfortable pointer accuracy.
Usability, not correctness — nothing mis-plays and nothing persisted is wrong.
- **Severity:** Med (usability on modern displays; Windows-only product makes high-DPI common).
- **Disposition:** **document-and-defer.** Rationale: a proper UI-scaling pass is a feature
wave of its own (scale plumbing through ~15 geometry modules + font cache + both shells),
far outside Q-W0's remediation budget; deferral should be recorded as a named future phase,
and Q-W1's relocation of the geometry modules should keep the constants centralized so the
eventual scale factor lands in one place.
### T3-06 — legacy v3 zone-payload lift divides by the *current* project rate
- **Location:** `src/vst/sample_map.cpp:601-605` (v3 lift inside `readZonesPayload`), format
note at `sample_map.h:439-456`, `:510-513`.
- **Stored vs. correct domain:** the v3 blobs (Daniel's beta projects) stored wall-clock times
as frames — **the prior incident itself**. The lift converts frames → seconds by dividing by
the live `projectRate` threaded in at read time. That is exact only if the project rate today
equals the rate in effect when the S15/S16 editor wrote the frames; the write-era rate was
never recorded, so a project whose rate changed since lifts skewed times (old/new ratio,
e.g. ~8.8 % for 44.1→48 k).
- **What breaks when the environment shifts:** already broken by construction for
rate-changed-since-write projects; a one-time lift residue, after which v5+ re-saves in
seconds and the skew is frozen in, silently.
- **Severity:** Low (legacy-only, beta-project blobs, envelope-time magnitudes; unrecoverable
in principle — the missing datum was never written).
- **Disposition:** **document-and-defer.** Rationale: no better conversion exists; this is the
documented residue of the incident that motivated the seconds invariant. Worth one sentence
in the code-quality-audit report so the skew is a recorded known, not a mystery bug later.
### T3-07 — SOLA correlation-segment cap of 512 frames (deliberate CPU bound; cross-ref T1)
- **Location:** `src/vst/pitch_shift.cpp:72`
(`corrFrames_ = max(1, min(dLow_ - 1, 512))`; rationale comment at `:64-67`).
- **Stored vs. correct domain:** borderline by design. The quantity being bounded is **work per
splice** (multiply-accumulates), which is genuinely frame-domain — a CPU bound *should* be in
frames. The side effect is that the correlation segment's wall-clock span halves at 96 kHz
(512 frames ≈ 11.6 ms at 44.1 k, ≈ 5.3 ms at 96 k), raising the lowest frequency the
alignment search can lock onto at high rates. All other shifter geometry correctly derives
from `kPreserveWindowMs` resolved at the live rate.
- **What breaks when the environment shifts:** alignment quality for low-frequency content
degrades somewhat at high host rates; no correctness or persistence impact.
- **Severity:** Low.
- **Disposition:** **document-and-defer**, and hand to the T1 DSP audit for the quality call.
Rationale: the frame domain is arguably correct for a compute bound; whether 512 is the right
*number* is an algorithm-quality question (T1's territory), not a domain-modeling one.
---
## Surfaces checked clean
- **`sample_map` v5+ persistence (the reference implementation):** AHDSR + pitch-env times as
SECONDS doubles; no rate constant anywhere in the read/write paths (`kLegacyV3NominalRate`
deliberately does not exist); legacy v3 lift takes the rate as a parameter. Clean.
- **ComponentState envelope v6v11 fields:** channel mode, assign generation, preview velocity,
voice count/mode/trigger, `masterGainLinear` (dimensionless linear), explicit flag,
`SampleRefs` (paths + root/loop/channels intrinsics), `instanceGuid` — all rate-free or
file-fact domains. Clean.
- **Trigger `fadeInFrames`/`fadeOutFrames`/`startPoint`/`SampleLoop.start/end` persisted as
int64 SOURCE frames:** deliberate, settled source-timeline facts (PLAN.md §S15;
`bank_model.h:66-72` documents the loop rationale) — frames *of the file* are invariant under
project-rate change; the file's own rate is stored alongside and resolved at decode. Correct
domain, not a finding.
- **`trigger_seam`:** frames↔fraction with `startFrame` threaded both directions; the overlay's
fraction domain is expressly rate-invariant. Clean.
- **`kPreserveWindowMs` (50 ms):** resolved to frames against the live host rate at both call
sites (`reasampler_processor.cpp:618-623`, `:733-735`) — the correct pattern, cited here as
the model T3-01 should copy.
- **`pitch_shift` internal geometry:** ring length, fade, lag band, delay band all derived from
the rate-resolved `window_`; ratio-scaled live fade length. Clean (T3-07 cap noted above).
- **Tail system:** `TailSetting.manualMs` persisted in **ms**; `kMaxTailSeconds`/`kMaxTailMs`
wall-clock; trim threshold in **dB** with the linear ratio derived
(`render_settings.h:59-86`); the realtime decay scan resolves frames against the **file's own
authoritative rate** (`capture_realtime.cpp:384-408`). Clean.
- **`wav_trim`:** frame counts are parsed file facts and transient truncate plans. Clean.
- **`bank_model` persisted metadata:** source bounds in seconds + PPQ (both stored, each for
its consumer); loudness in dB; `sampleRate`/`channelCount` are *recorded facts about the
file*, not assumptions; capture tempo + meter stamped at capture time deliberately so the
bars.beats read-out is stable under later project meter changes (`card_meta`). Clean.
- **Ext-state wires** (`banks` JSON, view-mode model, owned manifest, `assignment_request`,
`rsusage_*`): no frame-domain values; the rate field in the assign wire is a recorded fact.
Clean.
- **Envelope schematic (`envelope_overlay`/`envelope_edit`):** param-domain px↔seconds scale
derived from the live rect (`gatePxPerSecond`), sample-length-free; editor time-slider
ceiling is `kEnvTimeMaxSeconds = 2.0` (seconds). Clean (pixel constants themselves fall under
the systemic T3-05).
- **Timers:** bank_panel tooltip delay uses `GetTickCount()` ms against `kTooltipDelayMs = 500`
(wall-clock — the pattern T3-04 should copy); editor sync timer is a 500 ms `SetTimer`
interval (ms, not ticks); the new-content detector is an event diff per tick with no
wall-clock meaning encoded in tick counts; `retireIdleDrain` is idleness-driven, not
time-driven. Clean.
- **`peaks` / `waveform_view` / `master_gain` / `velocity_curve` / `keyboard_strip`:** bins and
columns derived from rects at use; dB↔linear taper and curve math dimensionless; key rects
from the passed strip rect. Clean.
## Summary
| ID | Location | Stored domain | Severity | Disposition |
|----|----------|---------------|----------|-------------|
| T3-01 | `reasampler_processor.cpp:46-51` gain-ramp step | per-sample step (20 ms @ 48 k baked in) | Med | **Fix-now (Q-W0)** — store seconds, derive step from `sampleRate_` |
| T3-02 | `sampler_core.h:409-410` declick decay | per-frame coefficient | Low | Document-and-defer — deliberate, already documented in-code |
| T3-03 | `reasampler_editor.cpp:395` fade throw ceiling | 88200 source frames (2 s @ 44.1 k) | Low | **Fix-now (Q-W0)** — seconds ceiling resolved vs. source rate at use |
| T3-04 | `reasampler_editor.cpp:2923` drop-hint duration | sync-timer ticks | Low | Document-and-defer — cosmetic, coupling stated in-code |
| T3-05 | systemic (both UI surfaces) | physical px, ~96 DPI assumed; no content-scale | Med | Document-and-defer — a UI-scaling phase of its own; keep geometry constants centralized through Q-W1 |
| T3-06 | `sample_map.cpp:601-605` v3 legacy lift | frames ÷ *current* project rate | Low | Document-and-defer — unrecoverable legacy residue; record as known skew |
| T3-07 | `pitch_shift.cpp:72` correlation cap | 512 frames (CPU bound) | Low | Document-and-defer — frame domain arguably correct for a compute bound; hand to T1 for the quality call |
Two fix-now findings (T3-01, T3-03), both assigned to **Q-W0 itself** — no downstream wave
opens those files for logic change. Five deferrals, each with a recorded rationale. The
persistence surfaces — the highest-stakes case — are clean: every wall-clock quantity written
to disk since the S12 remediation is in seconds or ms, and every frame-domain persisted value
is a source-file fact whose rate travels with it.
+399
View File
@@ -0,0 +1,399 @@
# Q-W0 Track 4 — structural sizing + placement audit
Date: 2026-07-28 · Branch: `pq-w0-audit` · READ-ONLY static analysis (no build, no code edits)
**Method.** Line counts measured with `wc -l` on the worktree; seams derived from function-definition
skeletons (`grep` for top-level definitions + section markers) plus targeted reads. Acceptance bar =
Daniel's three heuristics: (a) more directories a must, files ≤ ~600 lines, SRP applies to files and
namespaces; (b) templates are good where they dedup at compile time; (c) saved CPU beats abstraction —
no dispatch-stack blowouts, prefer static polymorphism where types are compile-time-known.
**Measured sizes differ from the wave brief in several places** (the tree moved after the brief was
drafted — GA/pS/pS-usage landed): `reasampler_editor.cpp` 3065 (brief said 3035),
`reasampler_processor.cpp` 1164 (1004), `sampler_core.cpp` 968 (1049), `sample_map.cpp` 970 (807),
`sample_map.h` 708 (600), `sampler_core.h` 762 (820), `actions.cpp` 1016 (996), `persist.cpp` 852
(812). All numbers below are the measured ones.
---
## 1. Oversize census + seams
Every `.cpp`/`.h` in `src/` (both sides) over ~600 lines, with the *real* responsibility clusters.
Where a file is genuinely one responsibility, I say so and recommend leaving it.
### 1.1 The four planned splits — do the plan's seams still land sub-600?
**T4-01 — `src/bank_panel.cpp` (3459; plan assumed 2424).**
The plan's six seams (`panel_render` / `panel_thumbnails` / `panel_audition` / `panel_input` /
`panel_bank_ops` / `panel_window`) no longer all land sub-600 at current size. Tally against the
skeleton:
| Planned TU | Functions (line spans) | Est. LOC | Verdict |
|---|---|---|---|
| `panel_thumbnails` | `computeThumbnail`/`thumbnailFor` (420487) | ~130 | fine |
| `panel_render` | `drawCardMeta`/`drawThumbnail` (487547), kit adapters (547570), `drawFooter` (685780), `drawToolbar`/`drawMoreButton`/`drawTooltip` (9921155), `drawRegionGrid`/`drawCardDropTarget`/`drawRegionHeader`/`drawTabStrip`/`paintPanel` (13341646) | ~700 | **over — needs the layout cut below** |
| *(unplanned)* **`panel_layout`** | toolbar/footer/menu rect + row/cluster builders (571684, 785991), split geometry + region rects + L7 slot-order display bridge (11561333) | ~500 | **new TU required** — this is pure-ish geometry glue, distinct from LICE drawing; extracting it puts `panel_render` at ~550 |
| `panel_audition` | `initPreview`/`startAudition`/`stopAudition`/`deinitPreview` (18741965) | ~90 | fine (keep the direct call-through guardrail) |
| `panel_input` | input helpers + `regionAt` (19652010), click routing `handleBanksChromeClick``handleKey`/accelerator (23842710), plus new-content detection (16471874, ~230) | ~800 | **over — needs the drag cut below** |
| *(unplanned)* **`panel_drag`** | `updateDropTarget`/`dropTargetBankId`/`classifyCardDrag`/`applyDragCursor`/`resolveHover`/`updateHover`/`maybeShowTooltip`/`onMouseMove`/`doReorderDrop`/`doReplaceDrop`/`resetDragState`/`onLBtnUp`/`handleRightClick` (27113185) | ~475 | **new TU required** — the card-drag/hover state machine is a cohesive cluster of its own (it already has a pure mirror, `card_drag`); extracting it puts `panel_input` at ~550 |
| `panel_bank_ops` | `promptText`/`mintBankId`/`doCreateBank``removeSamples`/`focusedSelectionIds`/`resolveDragPathsForOs` (20062231) + popup menus (22312384) | ~375 | fine (menus ride with bank_ops or input — either works; they invoke the ops) |
| `panel_window` | `handleDropFiles`/`dlgProc`/`openPanel`/`closePanel` (31853336) + public API (33363459) | ~275 | fine |
**Proposal:** eight TUs, not six — add `panel_layout` and `panel_drag`.
**Severity:** high (it is the biggest file in the repo). **Disposition: reshapes wave Q-W2**
the wave brief must name eight seams, or two of its six TUs ship >600 on day one.
**T4-02 — `src/main.cpp` (1897; plan assumed 1762).**
The plan's three hoists are the right seams, but `capture_orchestrator` as specced lands **~885
lines** — over by half again. Tally: `FxBypassGuard` (627731, ~105), `renderOffline` +
`captureAndIndexOne` + `RunCapture` + `RunCaptureItemAssign` (731909, ~180), batch family
(`ItemSelectionGuard`/`selectOnlyItem`/`RunBatchCaptureItems`/`collectRazorAreas`/
`TrackSelectionGuard`/`RunBatchCaptureRazor`, 9091181, ~270), `RunRecaptureFromSource` (12001398,
~200), realtime + insert actions (13981512, ~115). `scope_resolve` (361590) ≈ 230 ✓;
`realtime_lifecycle` (186360) ≈ 175 ✓; registration/entry residue (15121897) ≈ 385 ✓ (shrinks
further under Q-W6's table).
**Proposal:** split the orchestrator seam once more: `capture_orchestrator` (FxBypassGuard +
single-capture path + realtime/insert action bodies, ~450) and **`capture_batch`** (batch family +
`RunRecaptureFromSource` + the two selection guards, ~470). Recapture is planner-driven like batch
and shares the selection-guard machinery — it belongs with batch, not the single-shot path.
**Severity:** high. **Disposition: reshapes wave Q-W3** — add the fourth TU to the brief.
**T4-03 — `src/actions.cpp` (1016; plan assumed 981).**
Plan's seams still land: `design_view_actions` (59421, ~360), `bank_actions` (4221016 minus prune,
~490), `prune_action` (`doBankPruneFolder` 821916 + registration share, ~130). All sub-600.
**Disposition: no change to Q-W4.**
**T4-04 — `src/persist.cpp` (852; plan assumed 766).**
Plan's seams still land: `ext_state_io` (helpers + `saveToActiveProject` + `writeAssignmentRequest`,
112288, ~180), `prune_fs` (`scanPruneOrphans`/`pruneDryRun`/`pruneOrphanSet`/`deleteOrphanFile`/
`pruneReclaim`, 288539, ~250 — pS-usage growth landed here, exactly where the plan isolates it),
`session` (load/guid/poll/reload, 539852, ~315). All sub-600.
**Disposition: no change to Q-W5.**
### 1.2 Known offenders beyond the planned four — extension side
**T4-05 — `src/bank_book.cpp` (1109) + `bank_book.h` (457).**
Three genuine seams: **`SlotMap`** (27143, ~115 — a self-contained ordered-slot container with its
own serialize at 614), **`BankBook`** registry/CRUD/transfer/slot-reconcile (145545, ~400), and
**JSON serialize + `Parser`** (5471109, ~560). Q-W1 deletes the Parser + `ObjWriter`/`writeEscaped`
copies; what remains of serialization rewired onto `core/json` is ~150.
**Proposal:** after Q-W1, split `slot_map` into its own TU/header pair (it is a distinct type with
its own tests-worthy invariants); `bank_book.cpp` lands ~550. **Severity:** medium.
**Disposition:** fold into Q-W1 (the JSON rewire already opens this file; the `slot_map` file split
is one `git mv`-shaped extraction on top).
**T4-06 — `src/view_mode_model.cpp` (1049) + `view_mode_model.h` (748).**
Four seams: **indexes** (`ModeRegistry`/`MembershipIndex`/`LaneOwnershipIndex`, 29140), **pure
planners** (`autoTagNewContent`/`planItemRetag`/`planLaneMinting`/`makeParkPlan`/`makeRestorePlan`/
`nextModeId`, 143326, ~185), **`ViewModeModel`** state + visibility/toggle planning (332490), and
**JSON serialize + `Parser`** (4941049, ~555). Q-W1 deletes the Parser (~390); remainder ~660.
**Proposal:** split planners (`view_plan.cpp`) from model+indexes (`view_mode_model.cpp`, ~450 after
JSON extraction). The header's 26 structs split the same way: mode/membership/lane types + model
class vs. the plan-record structs (`TrackPlan`/`TogglePlan`/`AutoTag`/`ItemRetagOp`/`LaneMint*`).
**Severity:** medium. **Disposition:** fold into Q-W1 (JSON rewire opens the file; planner split
rides it). If the wave wants to stay minimal, the planner split can defer — post-extraction ~660 is
marginal, not pathological.
**T4-07 — `src/bank_model.cpp` (767).**
Two seams only: the model (`Sample` equality + `BankIndex` verbs, 25145, ~120) and JSON
(`ObjWriter`/`writeSample`/`Parser::parseSample`/`parseIndex`, 148767, ~620). This file is the
poster child for Q-W1: after the extraction it is ~250 total (model + thin serialize using
`core/json`). **Disposition: already owned by Q-W1; no new seam needed.**
**T4-08 — `src/capture_realtime.cpp` (867).**
Two seams: the **async record lifecycle** (`RealtimeCaptureState` snapshot/restore, `begin`/`tick`/
`abort`, 177324 + 569867, ~450) and the **file-side finalize** (`readAllBytes`/`writeU32LE`/
`trimAutoTailInPlace`/`finalizeRecording`, 324566, ~240). The lifecycle is genuinely one
responsibility (the header itself documents why the restore lives on the state object). The finalize
half — WAV byte-patching, decay-scan trim, move-into-bank — is a distinct concern that talks to
`wav_trim`, not to the transport.
**Proposal:** split `capture_realtime_finalize.cpp` (~240); lifecycle TU lands ~600 with the file
banner. Both stay in `shell/capture/`. **Severity:** low-medium. **Disposition:** ride Q-W3 (the
wave already renames this family per the Q-9 naming rider — same-wave file surgery is free).
**T4-09 — `src/view.cpp` (677).**
Two halves: **park/restore + flag application** (`snapshotTrack`/`applyFlags`/`parkFxOffline`/
`restoreFxOffline`/`applyMode`, ~350) and **lane management** (`laneName`/`managedLaneOrdinals`/
`applyLanePlays`/`applyLaneOps`/`readLaneTracks`/`assignItemToLane`/`applyMintPlan`/
`mintManagedLanes`/`reconcileManagedLanes`, ~330). The D2 lane machinery arrived after the file's
original charter and is a separable concern.
**Proposal:** split `view_lanes.cpp`. **Severity:** low (677 is barely over). **Disposition:**
document-and-defer unless Q-W1's relocation is already touching it — then take the free split.
**T4-10 — `src/ingest.cpp` (636).**
Seams: **pure-ish WAV/PCM helpers** (`buildFloat32Wav`/`decodePcmSource` + byte I/O, 86217, ~130 —
see T4-22: `buildFloat32Wav` is a pure function trapped in a shell TU), **`importFileIntoActiveBank`**
(249420, ~170), and the three ingest surfaces + registration (420636). Extracting the pure WAV
build into a testable core module (`wav_write` beside `wav_trim`, or one `wav_codec`) drops the shell
to ~500 and gains a test target.
**Severity:** low-medium. **Disposition:** fix in whichever wave lands the WAV consolidation
(T4-22); the file split itself is a rider.
### 1.3 Known offenders — VST side (entirely absent from the current plan)
**T4-11 — `src/vst/reasampler_editor.cpp` (3065) + `reasampler_editor.h` (539).**
The single biggest unplanned file — it grew past `main.cpp` in the r11 recomposition, *after* the
plan was written. It is now the `bank_panel.cpp` of the VST artifact, with the same god-module
profile. Real seams, from the skeleton:
| Proposed TU | Functions (line spans) | Est. LOC |
|---|---|---|
| `editor_session` | ctor/dtor, `refreshFromBank`/`rebuildVisible`/`onSyncTimer`/`commitAndReload`/`loadSelection`/`upsertPickedOverride`/effective-zone helpers (146400), PCM + thumbnail caches `monoPcmFor`/`thumbnailFor` (719800) | ~420 |
| `editor_controls` | control-value map `controlValue`/`applyControl` (399475), knob-deck plumbing `zoneDeckGroupDescs`/`deckGroupDescs`/`deckControlNorm`/`applyDeckKnob`/`deckValueLabel` (475649), envelope pack/unpack + `commitPickedMarkers` (649719), `applyZoneControl` (25762587) | ~470 |
| `editor_layout` (pure candidate — see T4-23) | anon-ns geometry: `computeSampleBands`/`clusterRects`/zone-panel areas/`channelToggleRects` (9041076), `computeBrowseModal` (17561785), `zoneContentArea` (19171924) | ~250 |
| `editor_paint_sample` | `paint` dispatch (1169), `drawTitleBand`, `paintSample`/`paintEnvelopeOverlay`/`paintVelocityCurve`/`paintKnobDeck`/`paintCurveButton`/`paintCurvePopup`/`paintEmptyState` + `drawKnobFace`/`drawSpectralStrip`/`drawRootMarker` (10761756) | ~590 |
| `editor_paint_browse_zone` | `paintBrowse` (17851917), `paintZone` (19242041) | ~260 |
| `editor_input` | `resolveHover` (20412145), `onMouseDown` (21472576 — a 430-line per-face dispatch), popup/curve mouse (16371735), `onMouseMove`/`onMouseUp`/`onMouseRDown`/`onMouseWheel`/`onSearchChar`/`onFilesDropped` (25872929) | ~880 → **split by face**: `editor_input_sample` (~500: sample-face hit branches + drag state + curve popup) and `editor_input_browse_zone` (~380: browser cards/scroll/search + zone strip/note entry) |
| `editor_platform` | IPlugView overrides `isPlatformTypeSupported`/`canResize`/`checkSizeConstraint`/`attachedToParent`/`removedFromParent`/`onSize`/`invalidate` (800904), `wndProc` + non-Windows stubs (29293065) | ~280 |
Seven-to-eight TUs, all sub-600, each a real cohesive cluster (session/bridge state · param
plumbing · layout · paint × 2 · input × 2 · platform). The face structure (Sample / Browse / Zone)
is the natural input/paint split axis — it mirrors how the code already dispatches.
**Severity: highest of the audit** — this is the largest unowned file in the repo.
**Disposition: reshapes the wave plan — needs a new owning wave** (see §1.5 / summary table).
**T4-12 — `src/vst/reasampler_processor.cpp` (1164) + `reasampler_processor.h` (502).**
Four seams: **VST3 lifecycle + bus boilerplate** (`queryInterface``setupProcessing`,
`setBusArrangements`, 118209 + 492506, ~120), **component-state I/O** (`setState`/`getState` +
`legacyLiftShouldRun`, 209349 + 774904, ~270), **accessors/param setters** (349492, ~140), and
**reload + drain + usage-publish + render** (`reloadInstrument`/`publishUsage`/
`publishBuiltLocked`/`rebuildVoiceEngine`/`retireIdleDrain` 506774 ~270, `process` 9041164 ~260).
**Proposal:** three TUs — `processor_state` (state I/O + accessors, ~410), `processor_reload`
(reload/drain/usage, ~290), `reasampler_processor.cpp` (lifecycle + `process()`, ~400). Keep
`process()` and its block-render helpers in one TU (heuristic c — see T4-27). All are member
functions of one class; partial-class-across-TUs is the same pattern the Q-W2 panel split uses.
**Severity:** medium-high. **Disposition:** new VST wave (see §1.5).
**T4-13 — `src/vst/sample_map.cpp` (970) + `sample_map.h` (708).**
Two clean halves plus a small third: **resolution core** (bank-JSON distill/select/list, SampleRefs
management, PCM downmix/extract/decode, `resolvePlay`, keymap builders, performance-map resolution +
`reconcileSingleCaptureZones`, 14406, ~390) and **binary component-state codec** (`putU32le`/
`putU64le`/`ByteReader`, zones payload put/read, `serializePerformance`/`deserializePerformance`,
`serializeComponentState`/`deserializeComponentState` v3→v11 lift ladder, selection codec,
406970, ~565).
**Proposal:** split `component_state_io.cpp` (the codec, ~565) from `sample_map.cpp` (resolution,
~400). The header splits the same way: wire/state structs (`ComponentState`/`SampleRefEntry`/codec
decls) vs. resolution API (`SelectedSample`/`PerformanceMap`/`ZonePlaySeconds`/resolvers). This is
the same shape as the extension's model-vs-JSON split and directly reduces rebuild fan-out — the
editor and processor both include `sample_map.h` today and recompile on every codec tweak.
**Severity:** medium-high (the codec grows every ComponentState version bump — v6→v11 in one
quarter; it will cross 600 on its own soon). **Disposition:** new VST wave.
**T4-14 — `src/vst/sampler_core.cpp` (968) + `sampler_core.h` (762).**
Contents: pitch math (1530), `Keymap` (3458), `AdsrEnvelope`/`TriggerEnvelope`/`PitchEnvelope`
(62251, ~190), `Voice` (255680 — `advanceFrame` alone is ~200), `VoiceEngine` (680968, ~290).
**This TU is a genuine single responsibility — the realtime voice engine — and it is the hottest
code in the repo.** Every function in it sits on the per-sample render path; the envelope `tick()`s
and `Voice::advanceFrame` benefit from same-TU inlining (no LTO assumption in the build). Splitting
the .cpp along class lines would put per-sample calls across TU boundaries — precisely the
heuristic-(c) violation the phase forbids.
**Proposal:** **leave the TU whole at 968** (documented exception to the 600 bar, justified by the
hot path), but **split the header**, which is where the pain actually is: `zone_params.h` (the enums
+ `AdsrParams`/`TriggerParams`/`PitchEnvParams`/`ZonePlayParams`/`SampleLoop`/`SampleData` — what
`sample_map`, the editor, and the codec actually need, ~250) vs. `sampler_core.h` (Keymap + the
engine classes, ~500). Today every UI TU that reads a param struct recompiles when a `Voice` member
changes. **Severity:** medium. **Disposition:** header split in the new VST wave; TU stays —
recommend recording the exception in the wave brief so nobody "fixes" it later.
**T4-15 — `src/view_mode_model.h` (748)** — covered under T4-06 (splits with its TU).
**T4-16 — `src/vst/sample_map.h` (708)** — covered under T4-13.
**T4-17 — `src/vst/sampler_core.h` (762)** — covered under T4-14.
### 1.4 Borderline (no action, for the record)
`capture.cpp` (549), `reasampler_editor.h` (539 — shrinks when the editor splits move private
helpers into their TUs), `reasampler_processor.h` (502 — same), `bank_book.h` (457), `pitch_shift.cpp`
(371 — single responsibility, hot, leave), `persist.h`/`capture.h` (341/234 — Q-W6 fat-header pass
already owns them). None need action beyond what their TU splits imply.
### 1.5 The structural conclusion for the wave plan
The existing plan splits 4 files, all extension-side. The census says **9 files need splitting and 2
need header-only splits — 5 of them VST-side, which currently have no owning wave.** The VST work is
the same kind and size as Q-W2 (the editor alone ≈ the old bank_panel). Recommendation: add one VST
god-module wave (call it **Q-W2v**, runnable in parallel with Q-W2 — different artifact, zero file
overlap; or sequence after W5 as Q-W7 if Daniel wants serial waves). Q-W1's relocation scope also
grows: the ~20 clean VST pure libs relocate + namespace in W1 alongside the extension's 30.
---
## 2. `src/vst/` placement in the Q-3 directory map
The settled Q-3 map (`core/{model,view,capture,audio,ui,reclaim,version,json}`,
`shell/{capture,panel,view,persist,actions}`, `app/`) covers only the extension. Two viable shapes
for the VST artifact; **this is Daniel's fork to call at triage.**
**T4-18 — Leading recommendation: integrate into the same `core/`/`shell/` top split, with
`instrument/` subsystem dirs beneath.**
```
core/instrument/engine/ sampler_core, pitch_shift, velocity_curve, master_gain
core/instrument/map/ sample_map (+component_state_io), bank_sync, bridge_marshal, note_entry, trigger_seam
core/instrument/ui/ editor_geometry, keyboard_strip, waveform_view, capture_browser,
browser_scroll, param_slider, knob_deck, curve_popup,
envelope_overlay, envelope_edit, embed_strip
shell/instrument/ reaper_bridge, processor TUs, editor TUs, reasampler_embed, vst_entry,
reasampler_vst.h / reasampler_uid.h
```
Rationale:
1. **One rule, no special case.** Q-3's settled reasoning is "top-level by the load-bearing
discipline, because the pure/shell split is the invariant worth making structural." That reasoning
is artifact-agnostic — a file's directory should tell you whether it may touch a *host* type
(REAPER or VST3 SDK), and `shell/instrument/` says exactly that.
2. **The artifact boundary is a link-graph fact, not a source-layout fact — and the sources already
straddle it.** Verified cross-artifact consumers: `sample_map` links `bank_book` + `wav_trim` +
`sampler_core`; the editor includes `draw_kit`/`theme`/`component_geometry`/`capture_paths`/
`peaks`/`wav_trim`/`app_version`/`ext_keys` (extension-side modules); the extension's pure
`instrument_drop` includes `vst/reasampler_uid.h`. An artifact-first subtree would either
duplicate these or still reach across — the boundary it draws is already false.
3. **Namespace map falls out:** `reasampler::instrument::{engine,map,ui}` beside
`reasampler::model` etc. — the Q-4 rule applied uniformly.
4. **CMake impact: path edits only.** Targets, links, and test executables are unchanged; the
VST3-gate (`EXISTS pluginfactory.cpp`) already guards targets, not directories.
Cost to name: the VST3-gated targets stay interleaved through the top-level `CMakeLists.txt` rather
than being isolatable behind one `add_subdirectory`. Mitigable by grouping the instrument targets
into one guarded block (or one `include()`d .cmake file) without moving sources.
**T4-19 — Alternative: parallel artifact-first subtree** — `src/vst/core/{engine,map,ui}` +
`src/vst/shell/`, extension keeps `src/core|shell|app`. Pros: the artifact boundary is visible at
top level; the whole VST tree (sources *and* a dedicated `src/vst/CMakeLists.txt`) can sit behind
one SDK-gated `add_subdirectory`, which is the cleanest possible expression of "this half only
exists on Windows with the submodule slice". Cons: two parallel `core/` trees dilute the "directory
= may it touch a host type" invariant into "check which subtree first"; the shared-module reality
(point 2 above) means the subtree is not actually self-contained — its purity is cosmetic; and the
gated-`add_subdirectory` win is achievable under T4-18 with an `include()` anyway. **Recommend
T4-18; T4-19 is defensible if Daniel weighs artifact legibility above discipline uniformity.**
**Disposition: reshapes Q-W1** (the relocation wave executes whichever shape is chosen).
---
## 3. Template-collapse opportunities
Judged per heuristic (b) — proposed only where duplication is real and the template earns it; two
anti-recommendations included, because a forced template is the worse smell.
**T4-20 — Little-endian byte codec: real template win.**
Five hand-rolled copies, verified: `putU32le`/`putU64le` + `ByteReader` (`vst/sample_map.cpp`
406500), `writeU32LE` (`capture_realtime.cpp` 342), `readU32LE` lambda (`capture_paths.cpp` 49),
`putU32` lambda (`ingest.cpp` 134), `appendU32LE` (`instrument_drop.cpp` 18). One header —
`core/wire/bytes.h` (or beside `core/json`): `template <class T> void putLE(std::vector<uint8_t>&,
T)` / `template <class T> bool readLE(ByteReader&, T&)` with the double↔bits helpers — replaces all
five, compile-time dispatched, zero runtime cost, and gives the ComponentState codec (T4-13) a
tested primitive. Entirely off hot paths (serialization/file I/O only).
**Severity:** medium (each new ComponentState version re-duplicates today). **Disposition:** fix in
the wave that lands `component_state_io` (the biggest consumer); consumers rewire opportunistically.
**T4-21 — Rect family: unify, but with a concrete type, NOT a template.**
Verified 12+ byte-identical `{int x,y,width,height}` structs (`ActionBarRect`/`CellRect`/
`PanelClientRect`/`FooterBarRect`/`HeaderRect`/`SegmentRect`/`MenuBarRect`/`MenuButtonRect`/
`FooterRect`/`ButtonRect`/`TabStripRect`/`KitBox`…) plus a *second grammar* on the VST side
(`editor_geometry`'s `Rect` is LTRB with `left/top/right/bottom` + `height()`). The right tool is
one concrete `ui::Rect` + `contains()` with per-role type aliases (`using ButtonRect = ui::Rect;`)
so call sites keep their semantic names — Q-W1's "one `ui::` owner" note already points here; this
finding extends it: (a) retire the XYWH-vs-LTRB fork by picking one grammar (LTRB has the live
`contains`/`height` users; either works — pick once), and (b) the per-type `contains`/`hitTest*`
one-liners collapse for free. A template rect would model nothing — the types differ in name only.
**Watch:** cross-lib name collisions (extension `Rect` vs vst `Rect`) surface only when both headers
meet in one TU — `sample_map` and the editor are exactly such TUs; the Q-4 sub-namespaces are the fix.
**Severity:** medium. **Disposition:** ride Q-W1 (it is the settled `ui::` unification, widened to
include `editor_geometry::Rect`).
**T4-22 — Linear rect-scan hit-tests: small template, real but modest.**
`hitTestCell` (`bank_grid`), `hitTestSlot` (`card_drag`), and the tab/segment scans are the same
first-rect-containing-point loop over records that carry a rect plus extra fields (`SlotCellRect`
adds `slot`). After T4-21, a single `template <class R> int hitIndex(int px, int py,
span<const R>)` (requiring `r.rect.contains(px,py)` or a rect accessor) collapses them. Earns its
keep only if T4-21 lands first; alone it would be a forced template.
**Severity:** low. **Disposition:** document-and-defer; opportunistic rider on Q-W1.
**T4-23 — WAV build/parse consolidation (dedup, not template).**
`buildFloat32Wav` (ingest, 116179) hand-writes the float32 header that `wav_trim` hand-parses and
`capture_paths`/`capture_realtime` chunk-scan/byte-patch. One pure `wav_codec` (or fold build into
`wav_trim`, renamed) gives one tested owner of the RIFF layout. Concrete functions; nothing to
template. **Severity:** low-medium. **Disposition:** fix-now-sized, but assign to the wave that
opens `ingest.cpp` (T4-10) to avoid a standalone churn commit.
**T4-24 — `clamp01`: dedup with one inline, anti-template.**
Six verified copies (`envelope_overlay`, `master_gain`, `param_slider`, `reasampler_editor`,
`velocity_curve` + `envelope_edit`'s `clamp`). One `constexpr inline double clamp01(double)` in a
shared core header (or just `std::clamp` at call sites). Not a template candidate — `std::clamp`
already is one. **Severity:** trivial. **Disposition:** rider on Q-W1 relocation.
**T4-25 — JSON `Parser`/`ObjWriter`/`writeEscaped`/`intToStr` ×4 — already owned by Q-W1;
confirmed still accurate** (verified in `bank_model`/`bank_book`/`view_mode_model`/
`owned_manifest`; `sample_usage` uses its own `rsusage` k/v wire, *not* a fifth JSON parser — no
scope growth). Concrete class, not a template. **Disposition:** no change.
---
## 4. Indirection audit (heuristic c)
**T4-26 — `ICaptureBackend` is now a dead abstraction: one implementation, zero polymorphic call
sites. The brief's "two implementations — earning its keep" assumption is FALSE at current state.**
Verified: `capture.h` itself documents (lines 139148, the "SEAM CHOICE" comment) that
`RealtimeRecordBackend` **deliberately does not implement** `ICaptureBackend` — it has a bespoke
async `begin/tick/abort` seam. `OfflineRenderBackend` is the sole deriver, and the only
construction site (`main.cpp:738`) instantiates the concrete type; nobody anywhere holds an
`ICaptureBackend*`/`&`. The interface costs a vtable and models nothing.
**Proposal:** delete `ICaptureBackend`; `OfflineRenderBackend` becomes a plain concrete class. Note
CLAUDE.md/CONTEXT still describe the module as "`ICaptureBackend` interface; two backends" — the doc
should be corrected in the same commit. **Severity:** low runtime, medium hygiene (it misleads —
this audit's own brief was misled). **Disposition:** fix in Q-W3 (the wave that rehomes the capture
orchestration and touches every call site).
**T4-27 — Warning to the Q-W2v brief (T4-14): do not split `sampler_core.cpp` along class lines.**
`AdsrEnvelope::tick`/`TriggerEnvelope::amplitudeAt`/`PitchEnvelope::tick` are called per-voice
per-sample from `Voice::advanceFrame`, which is called per-sample from `VoiceEngine::render`.
Same-TU definition is what lets the compiler inline this stack today (no LTO configured). A
by-class TU split converts the hottest inner loop into cross-TU calls — the exact dispatch-stack
blowout heuristic (c) forbids. If a split is ever wanted, the envelopes must move as
header-defined (inline) classes, not to a TU. The engine TU staying whole at 968 is the correct
trade.
**T4-28 — Warning to the Q-W2 brief (reaffirming the plan's own guardrail).** `panel_audition` and
the preview idle path must stay direct call-throughs after the 8-TU split — the plan already says
this; the two *added* TUs (T4-01: `panel_layout`, `panel_drag`) introduce no new risk (layout is
paint-time, drag is input-time), but the split of `onMouseMove` (which calls hover + drag + tooltip)
should keep per-mouse-move work as plain free-function calls, no interface.
**T4-29 — Warning to the processor split (T4-12).** Keep `process()` and any per-block helpers it
calls in one TU. The state/reload/accessor TUs are UI-thread or setup-time — safe to move freely.
The atomic-pointer-swap pattern (`publishBuiltLocked`) must not gain a virtual seam.
**T4-30 — No other gratuitous indirection found (verified, not assumed).** The only extension-side
`virtual` is T4-26. VST-side virtuals are all VST3-SDK-mandated overrides (`SingleComponentEffect`,
`CPluginView`, `IReaperUIEmbedInterface`) — not ours to remove. The layered pure→shell pairs
(`card_drag``bank_panel`, `realtime_record``capture_realtime`, `drag_out``drag_out_win`,
`prune_reconcile``persist`) are the load-bearing discipline, not forwarding waste — each layer
adds the decision/side-effect split, and all are direct calls. `bank_panel`'s ~20-function free-API
is a module boundary, not a dispatch chain; Q-W2's header segmentation thins it.
---
## Summary table — every oversize file → proposed seams → owning wave
| File (LOC) | Proposed TUs/headers | Owning wave |
|---|---|---|
| `bank_panel.cpp` (3459) | 8 TUs: render / **layout (new)** / thumbnails / audition / input / **drag (new)** / bank_ops / window | **Q-W2 (reshaped: 6→8 seams)** |
| `vst/reasampler_editor.cpp` (3065) | 8 TUs: session / controls / layout (pure candidate) / paint_sample / paint_browse_zone / input_sample / input_browse_zone / platform | **NEW wave Q-W2v** |
| `main.cpp` (1897) | orchestrator / **batch+recapture (new)** / scope_resolve / realtime_lifecycle / app-entry residue | **Q-W3 (reshaped: 3→4 hoists)** + Q-W6 |
| `vst/reasampler_processor.cpp` (1164) | processor_state / processor_reload / lifecycle+process (whole) | **NEW wave Q-W2v** |
| `bank_book.cpp` (1109) | slot_map / bank_book / JSON→`core/json` | Q-W1 (+slot_map rider) |
| `view_mode_model.cpp` (1049) + `.h` (748) | indexes+model / planners / JSON→`core/json`; header splits likewise | Q-W1 (+planner rider) |
| `actions.cpp` (1016) | design_view_actions / bank_actions / prune_action (unchanged) | Q-W4 (no change) |
| `vst/sample_map.cpp` (970) + `.h` (708) | sample_map (resolution) / component_state_io (codec); header splits likewise | **NEW wave Q-W2v** |
| `vst/sampler_core.cpp` (968) + `.h` (762) | **TU stays whole (hot-path exception, T4-27)**; header → zone_params.h + sampler_core.h | **NEW wave Q-W2v** (header only) |
| `capture_realtime.cpp` (867) | lifecycle / finalize | Q-W3 (rides the Q-9 rename) |
| `persist.cpp` (852) | session / ext_state_io / prune_fs (unchanged) | Q-W5 (no change) |
| `bank_model.cpp` (767) | model / JSON→`core/json` (unchanged) | Q-W1 (no change) |
| `view.cpp` (677) | apply / lanes | defer, or Q-W1 rider |
| `ingest.cpp` (636) | wav helpers→core / import / surfaces | wave owning T4-23 |
**Wave-plan deltas requested of triage:** (1) Q-W2 grows to 8 seams; (2) Q-W3 grows to 4 hoists +
the `ICaptureBackend` deletion; (3) a **new VST god-module wave (Q-W2v)** owns the editor /
processor / sample_map splits + the sampler_core header split — parallel-safe with Q-W2 (zero file
overlap); (4) Q-W1's relocation scope includes the VST pure libs under the placement shape chosen
at the T4-18/T4-19 fork; (5) the `core/wire/bytes.h` LE-codec template (T4-20) lands with
`component_state_io`.
+378
View File
@@ -0,0 +1,378 @@
# ReaSampler code-quality audit — Q-W0 findings report and triage
Date: 2026-07-28 · Branch: `pq-w0-audit` · Static analysis only; no code changed by the audit.
This is the committed Q-W0 findings report (Q-10 SETTLED: a committed doc beside the SOLID/naming
audit — `docs/product/code-organization.md` §2c.3; deliverable contract in PLAN.md §Q-W0). It
synthesizes four parallel audit tracks; the full track notes remain in the tree as appendices and
are the evidence base for every claim here — this report cites finding IDs and does not restate
mechanisms in full:
- **Track 1 — DSP / audio algorithm quality:** [`audit-notes/q-w0-t1-dsp.md`](audit-notes/q-w0-t1-dsp.md) (T1-01…T1-11)
- **Track 2 — architecture smells (functional lens):** [`audit-notes/q-w0-t2-architecture.md`](audit-notes/q-w0-t2-architecture.md) (T2-01…T2-11)
- **Track 3 — env-coupled-constant domain modeling:** [`audit-notes/q-w0-t3-env-constants.md`](audit-notes/q-w0-t3-env-constants.md) (T3-01…T3-07)
- **Track 4 — structural sizing + placement:** [`audit-notes/q-w0-t4-sizing.md`](audit-notes/q-w0-t4-sizing.md) (T4-01…T4-30)
Dispositions below are **proposals**. Per the Q-W0 sign-off gate, Q-W1 does not begin until Daniel
has signed off on every disposition; the open calls are collected in §4.
---
## 1. Verdicts up front
**DSP / pitch engine (the Q-11 question).** The correlation-aligned SOLA in `pitch_shift` is
**sound — no technique replacement (phase-vocoder / WSOLA) is warranted on this evidence** (T1
overall verdict). Track 1 finds the implementation "unusually well-defended" (normalized
correlation, ratio-scaled fades with drain-headroom derivation, prime-with-real-content onset,
frozen-writer tail, filled-span clamping) with RT discipline intact throughout. Every T1 finding
sits on the Q-11 escalation ladder's first rungs — bounded fixes within the existing technique, or
documented operating limits — exactly the settled default. The one High finding (T1-01, stereo
splice decorrelation) is a bounded fix inside the current design (link the per-channel lag
search), not a technique change. Track 1 states plainly that it cannot listen: every artifact is
mechanism + predicted audible consequence, and perceptual materiality is Daniel's call.
**Architecture (functional smells).** The load-bearing boundaries hold: no pure module includes a
host type, the VST bridge is genuinely read-only, WAV *decoding* has exactly one owner, the prune
and exception boundaries audit clean (T2 clean list). What Track 2 found instead is the classic
cost of duplicated *algorithms*: the length-prefixed wire `Cursor` copy-pasted 3× **with
security-hardening drift** — the oldest copy (`provenance`) missing the overflow guards its
siblings gained (T2-01, High, with a cheap Q-W0 backport); a fifth hand-rolled JSON decoder the
§2 audit did not count (T2-02); `readFileBytes` ×5 (T2-03); the ext-state grow-loop ×3 with its
pure decode half-adopted (T2-04); a 19-struct rect zoo (T2-05); and drifting copy-paste in the
capture-stamp epilogue (T2-09). All are dedup/relocation-shaped and route onto the reorg waves;
none is a live user-facing bug except the T2-01 robustness gap.
**Env-coupled constants (the prior-incident category).** The persistence surfaces — the
highest-stakes case — are **clean**: every wall-clock quantity written to disk since the S12
remediation is in seconds or ms, and every persisted frame-domain value is a source-file fact
whose rate travels with it (T3 clean list). Seven findings, only two fix-now: the master-gain
ramp step hard-codes 20 ms × 48 kHz (T3-01) and the Trigger-fade UI ceiling hard-codes
2 s × 44.1 kHz (T3-03) — both live hardcoded-rate residues in `src/`, both trivial, both in files
no then-planned downstream wave opens. The rest are deliberate-and-documented couplings or
recorded legacy residue, plus one systemic usability note (no DPI/content-scale support, T3-05)
that is a future phase of its own.
**Sizing + placement.** The wave plan's four planned splits are necessary but no longer
sufficient: the census measures **9 files needing TU splits and 2 needing header-only splits — 5
of them VST-side, which currently have no owning wave** (T4 §1.5). `reasampler_editor.cpp`
(3,065 LOC) is now the largest unowned file in the repo. Track 4's structural answer is a new
VST god-module wave (**Q-W2v**), reshaped seam lists for Q-W2 (6→8) and Q-W3 (3→4 hoists), one
documented exception (`sampler_core.cpp` stays whole — hot path, T4-14/T4-27), and one dead
abstraction to delete (`ICaptureBackend`, T4-26 — the brief's "two implementations" assumption is
false at current state). The `src/vst/` placement question is a genuine fork for Daniel
(T4-18/T4-19, §4a).
---
## 2. Unified findings register
Every finding from all four tracks, exactly once, with proposed disposition. Severities are the
tracks' own. "Q-W0" as a destination means remediated in this wave before it closes (post
sign-off). Cross-track overlaps are reconciled in §2.5.
### 2.1 Track 1 — DSP (appendix: `q-w0-t1-dsp.md`)
| ID | Finding (one line) | Sev | Proposed disposition | Rationale |
|----|--------------------|-----|----------------------|-----------|
| T1-01 | Stereo Preserve: per-channel independent splice alignment decorrelates L/R (image wander + mono-sum combing on stereo captures) | High | **Fix-now in Q-W0** (bounded SOLA fix: linked lag/schedule across channels) — **Daniel's call, §4b** | Hits the flagship path (Preserve default + permanently stereo bus); standard stereo-SOLA practice; no technique change |
| T1-02 | Ratio slew mid-fade can drain the outgoing tap past the writer (pitch-env attack case) | Med | Document-and-defer; bounded re-cap noted for when the file is next opened | Needs pitch-env + Preserve + steep attack to trigger; constant-ratio case already covered |
| T1-03 | Preserve prime ignores Trigger `playEnd_` bound; zero-pads sub-window samples as declared-valid ring content | Med | **Fix-now in Q-W0** (prime to feedBound + immediate `freezeTail()`) **if Daniel agrees short one-shots matter, else defer with note — §4b** | Small, contained in `Voice::start`; re-uses designed GA3 machinery |
| T1-04 | No sustain-loop crossfade — hard loop seam clicks unless loop points amplitude-matched | Med | Document-and-defer (record beside the zone-loop spec) | A crossfade is a feature (parameter + UI), wrong scope for a reorg phase |
| T1-05 | Linear interpolation + no band-limiting on repitch (both engines) | Low | Document-and-defer (recorded trade-off; cubic Hermite noted as drop-in if ever wanted) | Classic sampler behavior, deterministic and consistent across engines |
| T1-06 | Correlation search: coarse step-4 can mis-lock above ~5 kHz; maxLag bounds alignment to ≥ ~80 Hz | Low | Document-and-defer (record as the engine's stated operating range) | Inherent SOLA range/cost trades; widening costs splice-burst CPU linearly |
| T1-07 | `splice()` up-jump clamp comment contradicts the code (code is exactly tight; comment's margin direction is backwards) | Low | **Fix-now in Q-W0** (comment rewrite, zero behavior change) | Misleads the next maintainer of a safety-critical clamp; one line |
| T1-08 | Linear-in-amplitude ADSR decay/release (constant-dB nowhere; abrupt-late releases) | Low | Document-and-defer | Character-vs-correctness product decision; changing it alters every existing instrument's feel |
| T1-09 | `declickR_` is dead state (blend correctly shares one weight; R is seeded/decayed, never read) | Low | Fix-now-trivial **as a rider on any Q-W0 `sampler_core` edit** (T1-01/T1-03); else defer | Hygiene only, no audio effect; not worth a standalone change |
| T1-10 | `planWavTruncate` silently drops RIFF chunks after `data` (metadata loss on trim) | Low | Document-and-defer (note in the header's FORMAT ASSUMPTION block when next touched) | Metadata-only; preserving trailing chunks complicates the single-truncating-write design for no audio benefit |
| T1-11 | `makeUniqueTag` 1 s resolution → same-second batch captures collide (silent overwrite) | Low-Med | **Fix-now, assigned to Q-W3** (per-session monotonic counter, both call sites); Q-W0 fallback if triage prefers | T1 explicitly routes at triage; Q-W3 is the nearest wave opening the extension capture flow |
### 2.2 Track 2 — architecture (appendix: `q-w0-t2-architecture.md`)
| ID | Finding (one line) | Sev | Proposed disposition | Rationale |
|----|--------------------|-----|----------------------|-----------|
| T2-01 | Wire `Cursor` ×3 with hardening drift — `provenance` lacks the overflow/length guards its siblings have; unbounded `reserve` reachable from persisted input | High | **Fix-now, split:** (a) backport hardened `field()` + count sanity bound to `provenance.cpp` **in Q-W0** (§4c); (b) structural collapse to one shared wire codec **in Q-W1** | Hazard is cheap to close now with existing `provenance_tests`; the dedup should ride the wave already creating `core/` |
| T2-02 | Fifth hand-rolled JSON decoder in `tail_control` (the §2 "4× Parser" undercount) | Med | **Fix-now, folded into Q-W1** — add `tail_control` to the `core/json` consumer list explicitly | Zero extra cost when `core/json` lands; a stray fifth decoder afterward would be a defect of the wave |
| T2-03 | `readFileBytes` hand-rolled ×5 across both artifacts | Med | **Fix-now, folded into Q-W1** — one pure helper in the `core/` utility home; both targets link it | Five copies of a ten-line function; creating its home is exactly Q-W1's job |
| T2-04 | `GetProjExtState` grow-loop ×3; pure `bridge_marshal` decode only half-adopted (`usage_scan`'s copy is prune-safety-adjacent) | Med | **Fix-now, assigned to Q-W5** (the wave that splits `persist.cpp` — T2's own rule; its "Q-W4" label predates the plan's persist=W5 numbering) | Touching persist's session machinery outside its own wave risks the highest-traffic shell for a dedup with no live bug |
| T2-05 | 19 rect structs + ~15 inline point-in-rect predicates across the pure UI family | Med | **Fix-now, folded into Q-W1** — reconciled with T4-21 into one disposition, see §2.5(1) | Same-moment-as-relocation principle; PLAN Q-W1 already owns the `ui::` rect unification |
| T2-06 | Pure-computable layout math stranded in the VST editor shell (~49 inline geometry computations; §2's VST scope gap) | Med | Document-and-defer **with named reshape — satisfied by Q-W2v's `editor_layout` pure-candidate TU** (see §2.5(4), §3) | Behavior-preserving hoist best done under the reorg's test discipline; must be a recorded point or the layer keeps growing |
| T2-07 | Extension links the entire voice engine to serialize one preset blob (codec not separable from engine) | Low | Document-and-defer → **executed by Q-W2v's `component_state_io` split** (same split as T4-13, see §2.5(3)) | Right abstraction, wrong granularity; a module-homing decision the reorg waves exist to make once |
| T2-08 | WAV/RIFF container knowledge in 4 modules / 2 chunk walkers (dedup-by-hash + null-test invariants sit on their agreement) | Low | Document-and-defer → **consolidation moment is a triage question, §4e** (T2 prefers the `core/wav` homing moment; T4-23 prefers the wave that opens `ingest.cpp`) | All four currently correct against each other; pre-reorg consolidation churns the capture hot path for no functional gain |
| T2-09 | Capture backends' Sample-stamping epilogue copy-paste with silent divergences (active-project vs pinned-project time-sig read) | Med | **Fix-now, folded into Q-W3** — extract shared `stampCaptureSample` helper; divergent bits stay in the realtime caller | Q-W3 opens both backend TUs anyway; keeps one review of precision-invariant-adjacent code |
| T2-10 | Thumbnail cache-invalidation drifted across the split (extension: pure generation-baked key; editor: ad-hoc string key + call-site `clear()`s) | Low | Document-and-defer — adopt the pure `ThumbnailKey` on the VST side **as a rider on Q-W2v's editor work** | No live bug; pointless as standalone churn, natural rider on the editor wave |
| T2-11 | ComponentState v1→v11 deserialize chain sound, but v3/v4/v5 legacy branches triplicate the shared read | Low | Document-and-defer, explicitly — record that the next envelope bump (v12) extends the shared path rather than minting another branch | Legacy branches are frozen back-compat contract; rewriting them risks the one thing they must never break |
### 2.3 Track 3 — env-coupled constants (appendix: `q-w0-t3-env-constants.md`)
| ID | Finding (one line) | Sev | Proposed disposition | Rationale |
|----|--------------------|-----|----------------------|-----------|
| T3-01 | Master-gain ramp step is a per-sample constant baking in 20 ms × 48 kHz (`kGainRampRate = 1/960`); FB1 no-zipper contract degrades silently at higher rates | Med | **Fix-now in Q-W0** — store `kGainRampSeconds`, derive step from `sampleRate_` (the file's own `kPreserveWindowMs` pattern) — **§4d** | Trivial, isolated, behavior-identical at 48 kHz; a live violation of the no-hardcoded-rate ruling in a file no then-planned wave opens |
| T3-02 | Takeover-declick decay is a per-frame coefficient (~2× faster at 96 kHz) — documented deliberate in-code | Low | Document-and-defer — triage ratifies the in-code note as the record | Already an explicit, written, bounded design decision; converting buys no audible improvement |
| T3-03 | Trigger-fade UI throw ceiling hardcodes 2 s × 44 100 as `88200.0` frames (knob full-scale varies per source rate) | Low | **Fix-now in Q-W0**`kFadeMaxSeconds = 2.0` resolved against the loaded source's rate; T3's stated fallback (defer, amend comment) if zero UI-feel change is preferred — **§4d** | Small and contained; storage domain unchanged; the editor already threads `frameCount + rate` through pack/unpack |
| T3-04 | Drop-hint banner duration stored in sync-timer ticks (6 × 500 ms) | Low | Document-and-defer; fold in opportunistically if the file is opened | Cosmetic, self-documenting, cadence and decay live three lines apart |
| T3-05 | Systemic: no DPI/content-scale support in either UI surface (all layout constants are physical px at ~96 DPI) | Med | Document-and-defer — record as a named future phase; Q-W1's geometry relocation keeps constants centralized so the eventual scale factor lands in one place | A proper UI-scaling pass is a feature wave of its own, far outside Q-W0's remediation budget |
| T3-06 | Legacy v3 zone-payload lift divides by the *current* project rate (skewed times if the rate changed since write; write-era rate never recorded) | Low | Document-and-defer — this report is the record; the skew is a known, not a future mystery bug | Unrecoverable in principle; the documented residue of the incident that motivated the seconds invariant |
| T3-07 | SOLA correlation-segment cap of 512 frames — frame-domain by design (CPU bound); wall-clock span halves at 96 kHz | Low | Document-and-defer — **resolved against Track 1's verdict, see §2.5(2)** | T3 judged the frame domain arguably correct for a compute bound and handed the quality call to T1 |
### 2.4 Track 4 — sizing + placement (appendix: `q-w0-t4-sizing.md`)
| ID | Finding (one line) | Sev | Proposed disposition | Rationale |
|----|--------------------|-----|----------------------|-----------|
| T4-01 | `bank_panel.cpp` (3459): the plan's six seams no longer land sub-600 — `panel_render` ~700, `panel_input` ~800 | High | **Fix-now → reshapes Q-W2:** eight TUs, adding `panel_layout` and `panel_drag` | Without the two new seams, two of six TUs ship >600 on day one |
| T4-02 | `main.cpp` (1897): `capture_orchestrator` as specced lands ~885 | High | **Fix-now → reshapes Q-W3:** fourth hoist `capture_batch` (batch family + recapture + selection guards) | Recapture is planner-driven like batch and shares the guard machinery — it belongs with batch |
| T4-03 | `actions.cpp` (1016): plan's seams still land sub-600 | — | No change to Q-W4 (confirmation) | Measured against the current tree |
| T4-04 | `persist.cpp` (852): plan's seams still land sub-600; pS-usage growth landed exactly where the plan isolates it | — | No change to Q-W5 (confirmation) | Measured against the current tree |
| T4-05 | `bank_book.cpp` (1109): `SlotMap` is a self-contained type; post-JSON-extraction remainder splits cleanly | Med | Fix-now, fold into Q-W1 (`slot_map` extraction rides the JSON rewire already opening this file) | One `git mv`-shaped extraction on top of owned work |
| T4-06 | `view_mode_model.cpp` (1049) + `.h` (748): planners separable from model+indexes after JSON extraction | Med | Fix-now, fold into Q-W1 (planner split rides the JSON rewire); T4 allows deferring the planner split if the wave wants to stay minimal (~660 post-extraction is marginal) | JSON rewire opens the file; header splits the same way |
| T4-07 | `bank_model.cpp` (767): model vs JSON — the Q-W1 poster child, ~250 after extraction | — | Already owned by Q-W1; no new seam (confirmation) | — |
| T4-08 | `capture_realtime.cpp` (867): async lifecycle vs file-side finalize are distinct concerns | Low-Med | Fix-now, ride Q-W3 (`capture_realtime_finalize.cpp` split rides the Q-9 naming rider) | Same-wave file surgery is free |
| T4-09 | `view.cpp` (677): park/restore vs D2 lane machinery are separable halves | Low | Document-and-defer unless Q-W1's relocation touches it — then take the free `view_lanes` split | 677 is barely over the bar |
| T4-10 | `ingest.cpp` (636): pure WAV/PCM build helpers trapped in a shell TU | Low-Med | Fix in whichever wave lands the WAV consolidation — **moment is the §4e triage question** (circular with T4-23; no current wave opens `ingest.cpp`) | Extracting the pure WAV build gains a test target and drops the shell to ~500 |
| T4-11 | `vst/reasampler_editor.cpp` (3065): the largest unowned file — the `bank_panel` of the VST artifact | Highest | **Fix-now → new wave Q-W2v:** eight TUs (session / controls / layout / paint ×2 / input ×2 / platform), split axis = the face structure | Grew past `main.cpp` after the plan was written; same god-module profile |
| T4-12 | `vst/reasampler_processor.cpp` (1164): four seams | Med-High | **Fix-now → Q-W2v:** three TUs (`processor_state` / `processor_reload` / lifecycle+`process()` whole) | Partial-class-across-TUs is the same pattern as the Q-W2 panel split |
| T4-13 | `vst/sample_map.cpp` (970) + `.h` (708): resolution core vs binary ComponentState codec | Med-High | **Fix-now → Q-W2v:** split `component_state_io.cpp` + matching header split — one disposition with T2-07, see §2.5(3) | The codec grows every envelope bump (v6→v11 in one quarter); reduces editor/processor rebuild fan-out |
| T4-14 | `vst/sampler_core.cpp` (968) + `.h` (762): TU is a genuine single responsibility on the hottest path | Med | **Fix-now → Q-W2v, header only:** split `zone_params.h` from `sampler_core.h`; **TU stays whole — documented exception to the 600 bar** (record in the wave brief so nobody "fixes" it later) | Same-TU inlining on the per-sample path; no LTO in the build (see T4-27) |
| T4-15 | `view_mode_model.h` (748) | — | Covered under T4-06 (splits with its TU) | — |
| T4-16 | `vst/sample_map.h` (708) | — | Covered under T4-13 | — |
| T4-17 | `vst/sampler_core.h` (762) | — | Covered under T4-14 | — |
| T4-18 | VST placement, leading recommendation: integrate into the one `core/`/`shell/` split with `instrument/` subsystem dirs | — | **Daniel's fork — §4a** (T4 recommends T4-18) | One rule, no special case; the artifact boundary is a link-graph fact the sources already straddle |
| T4-19 | VST placement, alternative: parallel artifact-first subtree (`src/vst/core|shell` behind one SDK-gated `add_subdirectory`) | — | **Daniel's fork — §4a** | Defensible if artifact legibility outweighs discipline uniformity; T4 notes its self-containment is cosmetic |
| T4-20 | Little-endian byte codec hand-rolled ×5 — real template win (`putLE`/`readLE`) | Med | Fix-now, lands with `component_state_io` in Q-W2v (its biggest consumer); other consumers rewire opportunistically — relationship to T2-03/T2-04 noted in §2.5(5) | Compile-time dispatch, zero runtime cost, entirely off hot paths; gives the codec a tested primitive |
| T4-21 | Rect family: unify with one **concrete** `ui::Rect` + `contains()` + per-role aliases — NOT a template; retire the XYWH-vs-LTRB fork | Med | **Fix-now, ride Q-W1** — reconciled with T2-05 into one disposition, see §2.5(1) | The types differ in name only; a template would model nothing; cross-lib `Rect` collision is fixed by the Q-4 sub-namespaces |
| T4-22 | Linear rect-scan hit-tests: one small `hitIndex` template collapses them — only after T4-21 | Low | Document-and-defer; opportunistic rider on Q-W1 once the rect unification lands | Alone it would be a forced template |
| T4-23 | WAV build/parse consolidation into one pure `wav_codec` owner (dedup, not template) | Low-Med | Fix-now-sized, but the owning moment is the **§4e triage question** (T4 assigns it to the wave opening `ingest.cpp`; T2-08 prefers the `core/wav` homing moment; no current wave opens ingest) | Avoid a standalone churn commit; one tested owner of the RIFF layout |
| T4-24 | `clamp01` ×6 — one `constexpr inline` (or `std::clamp` at sites); anti-template | Trivial | Fix-now, rider on Q-W1 relocation | — |
| T4-25 | JSON `Parser` ×4 confirmed still accurate; `sample_usage` is **not** a fifth JSON parser (no scope growth from this track) | — | No change to Q-W1 (confirmation; T2-02's fifth decoder is a different file and does grow the consumer list) | — |
| T4-26 | `ICaptureBackend` is a dead abstraction: one deriver, zero polymorphic call sites; the brief's "two implementations" assumption is FALSE (realtime deliberately has a bespoke seam) | Low (runtime) / Med (hygiene) | **Fix-now, in Q-W3:** delete the interface, `OfflineRenderBackend` becomes concrete; **correct CLAUDE.md/CONTEXT ("ICaptureBackend interface; two backends") in the same commit** | It misleads — this audit's own brief was misled; Q-W3 touches every call site |
| T4-27 | Warning: do **not** split `sampler_core.cpp` along class lines — envelope `tick()`s are per-voice-per-sample; a by-class split is the exact heuristic-(c) dispatch blowout | — | Record as a guardrail in the Q-W2v brief (pairs with T4-14's whole-TU exception) | Same-TU definition is what lets the compiler inline the stack today |
| T4-28 | Warning to Q-W2: audition/preview stays a direct call-through across the 8-TU split; per-mouse-move work stays plain free-function calls | — | Record as a guardrail in the (reshaped) Q-W2 brief — reaffirms the plan's own guardrail; the two added TUs introduce no new risk | — |
| T4-29 | Warning to the processor split: `process()` + per-block helpers stay one TU; the atomic-pointer-swap pattern must not gain a virtual seam | — | Record as a guardrail in the Q-W2v brief | — |
| T4-30 | No other gratuitous indirection found (verified: only extension-side `virtual` is T4-26; VST virtuals are SDK-mandated; layered pure→shell pairs are the discipline, all direct calls) | — | Clean verification — see §5 | — |
### 2.5 Cross-track dedupes and reconciliations
1. **T2-05 ≡ T4-21 (+ T4-22 rider) — the rect zoo. Reconciled into ONE disposition: fix-now,
ride Q-W1.** Both tracks found the same duplication (19 XYWH structs + inline predicates;
T4-21 adds the VST side's second LTRB grammar) and both prescribe the same mechanism — one
concrete `ui::Rect` + `contains()` with per-role aliases, explicitly **not** a template
(T4-21's ruling). The only divergence was the wave label: T2-05 said "Q-W2 (the ui/
relocation wave)", but in the plan the relocation wave — and the settled `ui::` rect
unification (PLAN §Q-W1, "shared pure-UI rect types … get one `ui::` owner") — is **Q-W1**.
T2-05's own rationale ("same-moment-as-relocation") therefore points at Q-W1; reconciled
there. T4-22's hit-test template stays a deferred opportunistic rider behind it.
2. **T3-07 → T1 — the 512-frame correlation cap. Resolved: document-and-defer.** T3 handed the
"is 512 the right number" quality call to Track 1. Track 1's verdict on the correlation
search's bounds (T1-06) is document-and-defer — record the alignment limits as the engine's
stated operating range; "widening either costs splice-burst CPU linearly." Note for accuracy:
T1-06 adjudicates the coarse-step and `maxLag` bounds specifically and does not name the
512-frame `corrFrames_` cap; the resolution here rests on T1's general verdict (bounded
limits recorded, no change recommended) applied to the same search-cost family. If triage
wants the 512 value separately adjudicated, that is a residual question — flagged rather than
silently absorbed.
3. **T2-07 ≡ T4-13 — the ComponentState codec split. One disposition: Q-W2v's
`component_state_io`.** T2-07's complaint (extension links the whole voice engine to share
the preset serializer) is *solved by* T4-13's proposed split; T2-07's link-weight rationale
rides the T4-13 seam. T4-14's `zone_params.h` header split completes the extension-side
decoupling. T2 had provisionally pointed at "Q-W1/Q-W2 module-homing"; with Q-W2v now
proposed as the wave that opens `sample_map`, that is the owning wave.
4. **T2-06 ≡ T4-11's `editor_layout` — the stranded editor layout math.** T2-06's
document-and-defer explicitly demanded a *named* downstream reshape; Q-W2v's `editor_layout`
TU (T4-11, marked "pure candidate") is that reshape. One disposition: assigned to Q-W2v, with
the hoist targeting the existing pure homes (`editor_geometry` is T2's named natural owner).
T2-06's scope note (the §2 god-module catalogue missed the VST side) is also the evidence
base for Q-W2v existing at all.
5. **T4-20 / T2-03 / T2-04 — related but distinct dedup family, three separate dispositions.**
All three converge on shared `core/` utility homes but touch disjoint code: T4-20 (LE byte
codec ×5) lands with `component_state_io` in Q-W2v; T2-03 (`readFileBytes` ×5) lands in
Q-W1's utility home; T2-04 (ext-state grow-loop ×3, generalizing `bridge_marshal`) lands in
Q-W5 with the persist split. Marked here so triage sees them as one family and no wave
assumes another already covered its slice.
6. **T2-08 / T4-23 / T4-10 — WAV/RIFF consolidation. Genuine track disagreement on the moment;
surfaced as §4e** rather than silently picked. T2-08 defers to "the reorg wave that
relocates `wav_trim`" (Q-W1's relocation); T4-23 assigns to "the wave that opens
`ingest.cpp`" — and T4-10 assigns the ingest split to "whichever wave lands the WAV
consolidation," which is circular: **no current wave opens `ingest.cpp`.**
7. **T1-11 — capture-tag collision.** T1 offered it to Track 2 ("Track 2 may claim it");
Track 2 did not. It remains a single T1 finding, routed per T1's own suggestion to Q-W3.
8. **Cross-track interaction on the DSP/VST fix-nows.** T1 and T3 both justified
fix-now-in-Q-W0 partly by "no downstream wave opens these files" — written before T4
proposed Q-W2v, which *does* open `reasampler_processor.cpp` / `reasampler_editor.cpp` /
`sampler_core.h`. The recommendation stands unchanged: Q-W2v is a behavior-preserving
mechanical-split wave, and folding behavior-changing DSP/domain fixes into it would break the
wave discipline (CTest-green mechanical moves, no logic change). Fix-now items stay in Q-W0;
noted so the rationale reads correctly against the reshaped plan.
---
## 3. Plan reshape — what Q-W0 proposes for Q-W1..Q-W6
The concrete deltas to the PLAN §Phase Q wave graph. Every wave is named; "no change"
confirmations included deliberately.
**Q-W0 (this wave, post sign-off) — remediations before close:** T2-01(a) provenance cursor
hardening backport; T3-01 gain-ramp seconds; T3-03 fade-ceiling seconds (or its stated fallback);
T1-07 comment fix; pending §4b — T1-01 linked-lag stereo fix and T1-03 prime bound, with T1-09
riding any `sampler_core` edit. Each behavior-touching remediation lands with its module's CTest
target green per the Q-W0 verify contract.
**Q-W1 (safe opener — scope grows):**
- Add `tail_control` to the `core/json` consumer list (T2-02) — the wave's "one JSON path" goal
is not met without it. (T4-25 confirms the original 4× scope is otherwise accurate.)
- Add the shared `readFileBytes` pure helper to the `core/` utility home (T2-03).
- Rect unification: one concrete `ui::Rect` + `contains()` + per-role aliases, including
retiring the XYWH-vs-LTRB fork and folding in `editor_geometry`'s `Rect` (T2-05 ≡ T4-21);
`clamp01` dedup rider (T4-24); `hitIndex` template only as an opportunistic follow-on (T4-22).
- Structural collapse of the wire `Cursor` family into one shared `wire` codec module beside
`core/json`, consumed by `provenance` / `assignment_request` / `sample_usage` /
`parseBankGeneration` (T2-01(b)).
- `slot_map` extraction rider on the `bank_book` JSON rewire (T4-05); `view_mode_model` planner
split rider (T4-06, optional per T4); `bank_model` unchanged-as-planned (T4-07); `view_lanes`
split only if relocation touches `view.cpp` anyway (T4-09).
- Relocation scope grows to include the ~20 clean VST pure libs, under whichever placement shape
§4a settles (T4 §1.5, T4-18/T4-19).
**Q-W2 (bank_panel split — 6→8 seams):** the wave brief must name **eight** TUs — the planned
six plus `panel_layout` and `panel_drag` (T4-01) — or two of its TUs ship >600 on day one.
Guardrails reaffirmed: audition direct call-through; per-mouse-move work stays plain free-function
calls (T4-28).
**Q-W2v (NEW — VST god-module wave; the T2-06/T4 §1.5 scope gap made structural):**
- `reasampler_editor.cpp` → eight TUs: session / controls / **layout (pure-candidate hoist into
the existing pure homes — this discharges T2-06)** / paint_sample / paint_browse_zone /
input_sample / input_browse_zone / platform (T4-11).
- `reasampler_processor.cpp` → three TUs: `processor_state` / `processor_reload` /
lifecycle+`process()` kept whole (T4-12), with the T4-29 guardrail (no virtual seam on the
atomic-swap pattern).
- `sample_map``component_state_io` codec split + matching header split (T4-13 ≡ T2-07).
- `sampler_core`: **TU stays whole at 968 — documented hot-path exception** recorded in the wave
brief (T4-14, T4-27); header splits into `zone_params.h` + `sampler_core.h`.
- The `core/wire/bytes.h` LE-codec template lands here with its biggest consumer (T4-20).
- Rider: adopt the pure `ThumbnailKey` on the VST side while the editor is open (T2-10).
- **Scheduling:** parallel-safe with Q-W2 (different artifact, zero file overlap) — T4 also
offers serial-after-W5 as "Q-W7" if Daniel prefers serial waves (§4f).
**Q-W3 (main.cpp split — 3→4 hoists):** add **`capture_batch`** (batch family +
`RunRecaptureFromSource` + the two selection guards) as a fourth TU so `capture_orchestrator`
lands ~450 (T4-02). Also owned here: **delete `ICaptureBackend`** and correct the
CLAUDE.md/CONTEXT description in the same commit (T4-26); the shared `stampCaptureSample`
epilogue dedup (T2-09); the `capture_realtime_finalize` split riding the Q-9 naming rider
(T4-08); the `makeUniqueTag` monotonic-counter fix (T1-11).
**Q-W4 (actions split): no change** — the planned seams still land sub-600 (T4-03).
**Q-W5 (persist split): seams unchanged** (T4-04); **add** the ext-state grow-loop dedup —
generalize the retry policy into `bridge_marshal` (or its `core/` successor) and rewire all three
loops, `usage_scan`'s prune-safety-adjacent copy included (T2-04).
**Q-W6 (registration table): no change**; T4-02 notes the `main.cpp` registration residue (~385)
shrinks further under its table.
**Unassigned pending §4e:** the WAV/RIFF consolidation family (T2-08 / T4-23 / T4-10) — no
current wave opens `ingest.cpp`; the owning moment is Daniel's call.
Updated dependency sketch:
```
Q-W0 (this report + remediations) ── SUB-GATE: Daniel signs off every disposition (§4) ──
Q-W1 (core/json + wire codec + relocation incl. VST pure libs + rect unification + riders)
├─► Q-W2 (bank_panel split, 8 seams) ──► Q-W4 (actions; unchanged)
├─► Q-W2v (NEW: VST god-modules — editor/processor/sample_map splits, sampler_core header,
│ LE codec) [parallel-safe with Q-W2; serial "Q-W7" alternative — §4f]
├─► Q-W3 (main split, 4 hoists; ICaptureBackend deletion + doc fix; stamp dedup; T1-11)
│ └──► Q-W6 (registration table; unchanged)
└─► Q-W5 (persist; seams unchanged; + ext-state-loop dedup) [best after Q-W4]
```
---
## 4. Daniel's decision list (sign-off gate)
Each item: leading recommendation first, alternative second. Settled matters (Q-10, Q-11 framing,
the clean bills) are deliberately absent.
- **(a) VST placement fork — T4-18 vs T4-19.** *Recommend T4-18:* integrate `src/vst/` into the
one `core/`/`shell/` top split with `instrument/{engine,map,ui}` subsystem dirs — one rule
("directory = may it touch a host type"), the artifact boundary is a link-graph fact the
sources already straddle, CMake impact is path-edits only. *Alternative T4-19:* artifact-first
subtree (`src/vst/core|shell` behind one SDK-gated `add_subdirectory`) — cleanest expression
of the Windows-only gate, at the cost of two parallel `core/` trees and a subtree whose
self-containment is cosmetic. Q-W1 executes whichever shape is chosen.
- **(b) DSP bounded fixes in Q-W0 — T1-01 and T1-03.** *Recommend fix-now for T1-01* (linked
L/R lag + splice schedule): High severity on the flagship stereo-Preserve path, standard
stereo-SOLA practice, no technique change. *Alternative:* defer and record as the known
stereo-Preserve limitation. *For T1-03* (prime bound + immediate `freezeTail()` on
sub-window spans): T1's own framing — fix-now **if the short-one-shot case matters** to you
(short drum one-shots are realistic content); else document-and-defer with the T1 note as the
record. T1-09 (`declickR_` dead state) rides whichever `sampler_core` edit happens.
- **(c) T2-01(a) provenance wire-cursor hardening backport in Q-W0.** *Recommend yes:* small,
pure, closes a concrete robustness gap on persisted user-editable input, covered by existing
`provenance_tests`. *Alternative:* wait for the Q-W1 wire-codec collapse to fix it
structurally — leaves the gap open through the gate for no saving.
- **(d) Env-constant fix-nows in Q-W0 — T3-01 and T3-03.** *Recommend fix-now for both:* each
is trivial, isolated, and a live hardcoded-rate residue Daniel's standing ruling forbids;
behavior-identical at the baked-in rates. *Alternative for T3-03 only* (T3's stated
fallback): document-and-defer with the comment amended to name the 44.1 k assumption, if zero
UI-feel change is preferred. (Folding either into Q-W2v instead is *not* recommended — it
would put behavior changes inside a mechanical-split wave; §2.5(8).)
**Recorded deviation (Q-W0 remediation, code review):** T3-03 as implemented resolves
`fadeMaxFrames()` against `liveSampleRate()` (the host/project rate), not the per-file rate
this section's text literally suggests ("the loaded source's rate"). Reviewer verified this
is the more correct choice: no resample path exists anywhere in `src/`, the engine advances
one source frame per host frame, and this matches the time base `paintEnvelopeOverlay`
already uses for the same fades (`totalSeconds = frames / liveSampleRate()`). No further
action — recorded here so the audit text and the shipped behavior don't read as diverged.
- **(e) WAV/RIFF consolidation moment — the one true track disagreement (T2-08 vs T4-23/T4-10).**
T2 prefers the `core/wav` homing moment (the relocation wave); T4 prefers "the wave that opens
`ingest.cpp`" — which does not exist, and T4-10 points back circularly. *Recommend:* record
the consolidation (one pure `wav_codec` owner: walker + layout + build + patch, absorbing
T4-10's ingest extraction) as a named rider on **Q-W3** — the wave already opening the capture
family (`capture_realtime` finalize, T4-08) — with Q-W1-relocation as the alternative moment
if Daniel prefers T2's framing. Either way it must land somewhere named, or the
dedup-by-hash/null-test maintenance surface stays quadruplicated.
- **(f) Q-W2v scheduling.** *Recommend parallel with Q-W2* (different artifact, zero file
overlap — T4 §1.5). *Alternative:* sequence it serially after W5 as "Q-W7" if you want serial
waves throughout.
---
## 5. Clean bills — surfaces audited and found clean
Consolidated coverage evidence; details in the appendices' clean-surface sections.
**DSP (T1):** `peaks` (bin partition exact, overflow-guarded, per-channel no-fold), `master_gain`
(taper math correct end to end), `velocity_curve` (FritschCarlson monotonicity/no-overshoot
claims hold) — fully clean. Clean with only the noted findings: `wav_trim` (T1-10 metadata note),
offline capture path (T1-11; precision-invariant plumbing "disciplined"), realtime capture path
(T1-11 + already-in-code DAW-verify flags), `sampler_core`'s voice/steal/mono/panic machinery
(rate-free-seconds invariant honored; takeover blend mathematically sound), `pitch_shift`'s core
machinery (safe-band geometry, clamps, correlation, fades, `freezeTail` continuity all audit
sound; down-shift writer-lap unreachable above ≈ 109 st).
**Architecture (T2):** pure-module include hygiene across both trees (zero host-type includes in
any claimed-pure module); `bank_sync`; `bridge_marshal`; the realtime record lifecycle (explicit
enum state machine, not implicit); project-identity transitions; `usage_scan` (decisions
delegated pure, depth-bounded recursion, protect-on-truncation); the three `catch (...)` sites
(documented, narrow, non-swallowing); `FxBypassGuard` vs `view.cpp` park/restore (duplication of
shape, not concept — correctly separate); path resolution (`resolveBankFile` is the single
resolver both sides); WAV decode (one decoder); the draw layer (no parallel vocabulary on the VST
side); interface cost (no hot-path virtual/`std::function` chains); boolean parameters (no
smell). Also T2-11's headline: the ComponentState v1→v11 lift chain is **functionally sound**
the finding is shape, not correctness.
**Env-coupled constants (T3):** `sample_map` v5+ persistence (the reference implementation);
ComponentState v6v11 fields; Trigger fade/loop frames as source-file facts with the rate stored
alongside; `trigger_seam`; `kPreserveWindowMs` (the model pattern); `pitch_shift` internal
geometry; the tail system; `wav_trim`; `bank_model` persisted metadata; all ext-state wires; the
envelope schematic's param-domain scaling; every timer surveyed; `peaks` / `waveform_view` /
`master_gain` / `velocity_curve` / `keyboard_strip`. The persistence layer — the category's
highest-stakes surface — is clean end to end.
**Sizing/indirection (T4):** borderline files needing no action: `capture.cpp` (549),
`reasampler_editor.h`/`reasampler_processor.h` (shrink with their TU splits), `bank_book.h`,
`pitch_shift.cpp` (371 — single responsibility, hot, leave), `persist.h`/`capture.h` (owned by
Q-W6). T4-30: no gratuitous indirection anywhere beyond the dead `ICaptureBackend` — VST-side
virtuals are SDK-mandated, and the layered pure→shell pairs are the load-bearing discipline, all
direct calls. Plan-confirmations: Q-W4 and Q-W5 seams land as planned (T4-03/T4-04); Q-W1's JSON
scope confirmed accurate modulo T2-02 (T4-25).
-1016
View File
File diff suppressed because it is too large Load Diff
-92
View File
@@ -1,92 +0,0 @@
#pragma once
// actions — the Design View action family (Phase D4). Registers the bindable
// actions that drive the mode workflow and wires them end-to-end: toggle/activate
// a mode, tag/untag/show-both the current track selection. Each action mutates the
// session's ViewModeModel (D1, via persist's ReaSamplerSession) and then reapplies
// the active mode through the view shell (D2) so the change takes effect immediately.
//
// REAPER-facing shell: the .cpp includes reaper_plugin_functions.h WITHOUT
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). This
// header is SDK-free; main.cpp calls register/handle/unregister and nothing else.
//
// Split out of main.cpp (rather than inlined there) to match CONTEXT.md's planned
// `actions` module and keep main.cpp's entrypoint focused on API-pointer ownership
// and lifecycle. The reapply-on-open glue stays in main.cpp (it owns the timer that
// drives persist.poll()); this module only registers and services the actions.
// Forward-declared at GLOBAL scope (matches reaper_plugin.h's typedef struct
// reaper_plugin_info_t) so this header stays SDK-free; the .cpp includes the real
// definition. Declared before the namespace so it is the global type, not a
// namespace-local shadow.
struct reaper_plugin_info_t;
namespace reasampler {
class ReaSamplerSession;
// Registers the Design View action family against `rec` (command_id + gaccel +
// hookcommand-routing is owned by the caller's single hookcommand). `session` is the
// live session the actions mutate; it must outlive the registration. Idempotent is
// NOT promised — call exactly once at load, mirror-unregister once at unload.
void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session);
// Services one fired command. Returns true iff `command` is one of this module's
// action ids (and it was handled); false otherwise so the caller's hookcommand keeps
// looking (per the contract: claim only our own ids). Safe to call for any command.
bool designViewHandleCommand(int command);
// Mirror-unregisters everything designViewRegisterActions registered, with the
// '-'-prefixed strings (per the contract's unload rule). Call once on rec==nullptr.
void designViewUnregisterActions(reaper_plugin_info_t* rec);
// --- Multi-bank action family (Phase B3) -----------------------------------
// The bindable action set that drives the multi-bank workflow: create / rename /
// delete / evacuate a bank, activate a bank (direct pool/design-free + cycle), move /
// copy the panel's selected samples into a bank, and the two vertical-split
// full-height toggles. Every mutating action drives the B1 model on
// g_session.book() and persists via g_session.saveToActiveProject() so the change
// travels with the .rpp; the toggles flip the B4-rendered layout bit on the panel.
//
// Same registration/routing/unload contract as the Design View family above and the
// same shared g_session. Kept a distinct trio (not folded into the Design View one)
// because the two families are orthogonal pillars — but they share the single
// hookcommand main.cpp owns; each family's Handle claims only its own ids.
// Registers the multi-bank family against `rec`. `session` is the live session (must
// outlive registration). Call exactly once at load. Shares g_session with the Design
// View family — pass the SAME session pointer.
void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session);
// Services one fired command for the multi-bank family. True iff it was one of this
// family's ids (and handled); false otherwise so the caller's hookcommand keeps
// looking. Safe for any command.
bool bankHandleCommand(int command);
// Mirror-unregisters the multi-bank family with '-'-prefixed strings. Call once on
// rec==nullptr (before g_session is torn down).
void bankUnregisterActions(reaper_plugin_info_t* rec);
// The registered command id for the "Prune bank folder" action (Phase R, R3), or 0 before
// registration. The bank_panel prune button fires the action THROUGH this id via
// Main_OnCommand (fork R-E: the button dispatches the command, it does not call the session
// directly) so the panel affordance and the bindable action share one guarded code path.
int bankPruneCommandId();
// Persists a completed bank-index verb as a single REAPER undo point (R-B).
// Wraps persistBook() (= SetProjExtState) in a Begin/End block with UNDO_STATE_MISCCFG
// so the bank op is one Ctrl-Z. On an unsaved / no-active project persistBook() no-ops
// and the block is closed with an empty label + zero flag (REAPER discards it). Callers
// must invoke this ONLY after a successful/effective mutation — rejected ops (duplicate
// name, un-deletable pool, etc.) must return before reaching here so no empty undo
// point is ever opened for a no-op. Defined in actions.cpp alongside persistBook().
//
// S9 bank-generation bump: pass `bumpGeneration = true` for a verb that changes what a live
// instance would PLAY — move / copy / remove / evacuate / delete-with-members (a sample left,
// arrived, or dropped out of a bank an instance may reference). Leave it false (the default)
// for a PURELY ORGANIZATIONAL verb — create / rename / activate / reorder — which changes no
// existing (bankId, sampleId) -> content mapping, so no instance need refresh. The bump (when
// requested) happens INSIDE the block, BEFORE persistBook(), so the stamped counter rides the
// same ext-state write and undo captures the pre/post generation with the rest of the blob.
void persistBankOp(const char* label, bool bumpGeneration = false);
} // namespace reasampler
+404
View File
@@ -0,0 +1,404 @@
// main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers.
//
// This file is the entire contract between REAPER and the extension:
// * At startup REAPER scans UserPlugins/ for reaper_*.dll|dylib|so and
// dlopen()s each one, then looks up ONE exported symbol: ReaperPluginEntry
// (that name is produced by the REAPER_PLUGIN_ENTRYPOINT macro).
// * REAPER calls it, handing over `rec` — a small dispatch struct.
// - rec->GetFunc(name) resolves any REAPER API function to a pointer
// - rec->Register(what,ptr) plugs OUR callbacks into REAPER
// * REAPERAPI_LoadAPI(rec->GetFunc) walks reaper_plugin_functions.h and
// fills in every global function pointer (ShowConsoleMsg, InsertMedia...).
//
// Exactly ONE .cpp defines REAPERAPI_IMPLEMENT (this one) — that allocates
// storage for those global pointers. Every other .cpp includes
// reaper_plugin_functions.h WITHOUT the define and gets `extern` declarations.
//
// Since Q-W3 this TU is ONLY pointers + entry + dispatch; since Q-W6 its own
// action family registers through the DATA-DRIVEN TABLE below (kMainActionRows +
// action_registry's registerActionTable/actionTableHandleCommand/
// unregisterActionTable) — adding a bindable action here means adding ONE row and
// its handler function, nothing else (OCP). The design_view / bank / ingest
// families keep their own register/handle/unregister triples, called from entry.
#define REAPERAPI_IMPLEMENT
#include "reaper_plugin.h"
#include "reaper_plugin_functions.h"
#include <cstddef>
#include <string>
#include <vector>
#include "core/capture/render_settings.h" // captureActionTable
#include "core/version/app_version.h" // appVersion
#include "ingest.h"
#include "shell/actions/action_registry.h" // the Q-W6 registration table
#include "shell/actions/bank_actions.h" // multi-bank action family (B3; Q-W4 home)
#include "shell/actions/design_view_actions.h" // Design View action family (D4; Q-W4 home)
#include "shell/capture/capture_batch.h" // batch + recapture action bodies
#include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies
#include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver
#include "shell/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded
#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown)
#include "shell/persist/session.h" // ReaSamplerSession
#include "shell/view/view.h" // reconcileManagedLanes / applyMode
namespace capture = reasampler::capture;
// Globals other files reference via `extern`.
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle
reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct
// Retired command-id SUFFIXES. Kept ONLY to mirror-unregister them on unload so a
// user's stale keybindings are cleaned up. Never re-register these. Composed through
// the channel prefix at unload (channelIdFor) so a beta unload clears beta-qualified
// retired ids and a stable unload clears stable's — each channel cleans up only its
// own family.
// * The M7 four-mode ids (tracks/items/razor WET).
// * CAPTURE_MASTER and CAPTURE_MASTER_REALTIME — the master offline scope and the
// master realtime action are REMOVED (capture is now item + track only; realtime
// taps the selected track). Their shipped ids are retired so old keybindings clear.
// * CAPTURE_ITEM_TAIL and CAPTURE_TRACK_TAIL — the former per-action tail variants
// are REMOVED; tail is now a panel-setting toggle, not a paired action.
static const char* const kRetiredCaptureCmdSuffixes[] = {
"CAPTURE_TRACKS_WET",
"CAPTURE_ITEMS_WET",
"CAPTURE_RAZOR_WET",
"CAPTURE_MASTER",
"CAPTURE_MASTER_REALTIME",
"CAPTURE_ITEM_TAIL",
"CAPTURE_TRACK_TAIL",
};
// The persistence session (M4): owns the in-memory BankModel and bridges it to
// project ext state. A timer tick drives g_session.poll() to detect project
// load / Save-As; capture adds Samples to g_session.bank() — which (B2) resolves to
// the ACTIVE bank's index inside the session's BankBook; after a capture we serialize
// the book back into the active project's ext state (the `banks` key) so it travels
// with the .rpp. Replaces the M3 session-only g_bank.
static reasampler::ReaSamplerSession g_session;
// Command id of the TOGGLE_BANK_PANEL row, resolved from the table once at load so
// OnToggleAction's checked-state poll is a single int compare (no per-poll lookup).
static int g_cmdToggleBankPanel = 0;
// --- Action handlers (the table's function pointers) --------------------------
//
// Each is a thin stateless routing shim: (session, per-row arg) -> the action body
// hoisted in Q-W3/Q-W4 (shell/capture/, shell/panel/). The bodies own all behavior;
// these exist only so the table rows can be plain data with flat function pointers.
// Capture scope family: `arg` is the captureActionTable() row index — the table rows
// below are built by iterating that pure taxonomy, so the routing stays 1:1 by
// construction (never a hand-kept parallel list).
static void RunCaptureScopeRow(int arg) {
capture::RunCapture(g_session,
capture::captureActionTable()[static_cast<std::size_t>(arg)]);
}
static void RunToggleBankPanel(int) { reasampler::bankPanelToggle(); }
static void RunCaptureItemAssign(int) { capture::RunCaptureItemAssign(g_session); }
// Insert: `arg` != 0 is the EXPLICIT conform-to-project-tempo opt-in (CONTEXT.md
// §insert: conform is opt-in, never silent); 0 inserts at native length.
static void RunInsertSelected(int arg) {
capture::RunInsertSelected(g_session, arg != 0);
}
static void RunBatchCaptureItems(int) { capture::RunBatchCaptureItems(g_session); }
static void RunBatchCaptureRazor(int) { capture::RunBatchCaptureRazor(g_session); }
static void RunCaptureRealtime(int) { capture::RunCaptureRealtimeTrack(g_session); }
static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); }
static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); }
static void RunShowVersion(int) {
// On-demand version readout — the ONLY version output on any path (Phase V: no
// unconditional startup print; routine console chatter pops the console window).
ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str());
}
// --- The registration table (Q-W6) --------------------------------------------
//
// ONE row per bindable action this TU owns: FOREVER-STABLE id suffix (channel prefix
// composed at register — stable rebuilds the exact shipped id, e.g.
// "CEREBELLUM_REASAMPLER_CAPTURE_TRACK"; beta its isolated forever-family), the
// Actions-list phrase (after the "ReaSampler[ beta]: " lead), the handler, and its
// per-row arg. Registration, hookcommand dispatch, and the unload mirror-unregister
// all iterate this data — adding an action = adding a row + a handler above.
//
// The capture scope rows (CAPTURE_ITEM / CAPTURE_TRACK) come first, sourced from the
// pure captureActionTable() taxonomy (render_settings) — suffix/phrase live in that
// one testable list, and `arg` carries the row index back to RunCapture. The
// remaining rows are this TU's singles, in the pre-table registration order.
static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
using reasampler::ActionTableRow;
std::vector<ActionTableRow> rows;
const auto& cap = capture::captureActionTable();
for (std::size_t i = 0; i < cap.size(); ++i)
rows.push_back(ActionTableRow{cap[i].commandSuffix, cap[i].descriptionPhrase,
&RunCaptureScopeRow, static_cast<int>(i)});
// M5: show/hide the docked bank panel (display-only; never captures/inserts).
rows.push_back({"TOGGLE_BANK_PANEL", "toggle bank panel", &RunToggleBankPanel});
// S8: Item-scope capture + assignment-request write (capture family because it
// leans on the capture render machinery; the other ingest surfaces live in the
// ingest family and the panel drop callback).
rows.push_back({"CAPTURE_ITEM_ASSIGN",
"capture selected item into bank + assign to active instance",
&RunCaptureItemAssign});
// M6: place the panel's selected sample at the edit cursor. Two variants that
// differ ONLY in InsertOptions — native length vs the explicit conform opt-in.
rows.push_back({"INSERT_SELECTED", "insert selected sample at edit cursor",
&RunInsertSelected, 0});
rows.push_back({"INSERT_SELECTED_CONFORM",
"insert selected sample at edit cursor (conform to tempo)",
&RunInsertSelected, 1});
// M11: one action fires N captures (per selected item / per razor area); the
// original selection is restored on every exit path. Bank-only, never places.
rows.push_back({"CAPTURE_BATCH_ITEMS",
"batch capture selected items (one per item)",
&RunBatchCaptureItems});
rows.push_back({"CAPTURE_BATCH_RAZOR", "batch capture razor areas (one per area)",
&RunBatchCaptureRazor});
// M8: realtime sibling of the offline CAPTURE_TRACK scope — records the selected
// track's own output into a hidden temp track, dialog-free — plus its
// cancel-in-flight companion (stop + restore, non-destructive).
rows.push_back({"CAPTURE_TRACK_REALTIME", "capture selected track (realtime)",
&RunCaptureRealtime});
rows.push_back({"CANCEL_REALTIME_CAPTURE", "cancel realtime capture",
&RunCancelRealtime});
// M10: regenerate the selected PROVENANCED sample from its recorded source's
// current state, in place. Bank-only, never places on the timeline.
rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source",
&RunRecaptureFromSource});
// Phase V: on-demand version readout for bug reports.
rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion});
return rows;
}
// The timer callback REAPER runs periodically (registered via "timer"). It only
// forwards to the session poll — cheap per tick (reads the active project id and
// its .rpp path, acts only on a change).
static void OnTimer()
{
// Advance any in-flight realtime capture FIRST, so a project switch is caught and
// the capture torn down/restored before session.poll() reacts to that switch.
// LOAD-BEARING (CONTEXT.md §Phase Q): the idle fast-path is a SINGLE POINTER
// TEST — the cross-TU drive call is made only when a capture is in flight.
if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session);
g_session.poll();
// D4 reapply-on-open glue. persist stays MODEL-ONLY (it loads the saved view
// model but deliberately does NOT apply visibility — that would couple persist
// to the view shell). Instead poll() raises a one-shot load signal; here — the
// integration layer that already drives both persist and the view shell — we
// drain it and reapply the SAVED active mode's visibility/processing so opening a
// project saved in Design mode parks the Arrange tracks automatically, no manual
// toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks
// skip it. proj = nullptr -> REAPER's active project (the one poll just loaded).
//
// The SAME signal re-arms the bank panel's new-content detector: a load must
// re-baseline the detector against the just-loaded project's content so its
// pre-existing tracks are never mis-detected as "new" and mass-tagged into the
// active mode (the reload-mis-tag bug). Notify BEFORE the reapply so the detector's
// re-arm and the model restore ride the one authoritative load event.
if (g_session.consumeLoadSignal()) {
reasampler::bankPanelNotifyProjectLoaded();
// Reconcile the restored lane-ownership index against the live project's lanes
// FIRST (via REAPER's durable P_LANENAME — the cross-session source of truth),
// so a saved lane-split project's managed/manual classification is correct
// before the active mode's lane visibility is reapplied. Never re-mints, never
// mass-tags — it only records managed ownership recovered from lane names.
reasampler::reconcileManagedLanes(g_session.view(), nullptr);
reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
}
// Reflect a live bank change (capture / project load) in the docked grid.
// Cheap when the bank is unchanged (a fingerprint compare); repaints only on
// an actual change. No-op when the panel is closed.
reasampler::bankPanelRefresh();
}
// --- projectconfig hook: reload the session on undo/redo (R-B) ---------------
// A Ctrl-Z / Ctrl-Shift-Z rolls back / forward the "reasampler" project ext state on
// disk but keeps the SAME project identity (ReaProject*/GUID/.rpp path), so the timer's
// identity poll reads it as NoOp and never re-reads ext state — the in-memory book/view
// would stay stale until close+reopen. REAPER's projectconfig extension fires
// BeginLoadProjectState on every project-state (re)load, INCLUDING an undo/redo restore
// (isUndo == true for both). We hook it to drive a session reload.
//
// TIMING (the crux): BeginLoadProjectState is documented (reaper_plugin.h ~1203) as
// firing BEFORE any state restore. Reading GetProjExtState synchronously here would
// return the PRE-undo value. So we do NOT read here — we raise a one-shot reload request
// (g_session.requestReload()) that OnTimer's poll() drains on the NEXT tick, by which
// point REAPER has finished restoring the <EXTSTATE> block and GetProjExtState returns
// the POST-undo value. Deterministic, event-driven — NOT ext-state content polling.
//
// GATED ON isUndo: a normal project open also fires BeginLoadProjectState (isUndo=false);
// we ignore that here so a normal open flows solely through the timer's identity-transition
// Load path (no double load). Only undo/redo (isUndo=true) requests the reload.
static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/)
{
if (isUndo)
g_session.requestReload();
}
// ProcessExtensionLine / SaveExtensionConfig are intentional no-ops: ReaSampler stores
// its state via project EXT STATE (SetProjExtState/GetProjExtState under "reasampler"),
// which REAPER persists in its own <EXTSTATE> RPP block — NOT via this extension's own
// project lines. We register the struct ONLY for the BeginLoadProjectState undo/redo
// notification. Returning false from ProcessExtensionLine means "not our line" so REAPER
// keeps dispatching (we claim none). SaveExtensionConfig writes nothing.
static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/,
bool /*isUndo*/, project_config_extension_t* /*reg*/)
{
return false; // we own no project lines — ext state carries our data
}
static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/,
project_config_extension_t* /*reg*/)
{
// Nothing to write: our data rides in ext state, not project lines.
}
// Storage must outlive registration — REAPER holds this pointer until we unregister it.
static project_config_extension_t g_projectConfig{
&OnProcessExtensionLine,
&OnSaveExtensionConfig,
&OnBeginLoadProjectState,
nullptr, // userData
};
// REAPER calls this for EVERY action fired anywhere; claim only our own id,
// return false otherwise so REAPER keeps looking. This TU's own family dispatches
// through the registration table; the Q-W4 families claim their own ids after it.
static bool OnHookCommand(int command, int /*flag*/)
{
if (command == 0) return false;
if (reasampler::actionTableHandleCommand(command)) return true;
// Design View action family (D4). Claims only its own ids; returns false for the
// rest so this hook keeps looking (per the contract).
if (reasampler::designViewHandleCommand(command)) return true;
// Multi-bank action family (B3). Same contract: claims only its own ids.
if (reasampler::bankHandleCommand(command)) return true;
// S8 ingest action family (Media-Explorer import). Same contract.
if (reasampler::ingestHandleCommand(command)) return true;
return false;
}
// REAPER polls this to render each of OUR actions' checked state in menus/toolbars.
// Return 1 (on) / 0 (off) for ids we own, -1 for everything else (per the contract).
static int OnToggleAction(int command)
{
if (command != 0 && command == g_cmdToggleBankPanel)
return reasampler::bankPanelIsOpen() ? 1 : 0;
return -1; // not ours / non-toggling
}
extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec)
{
if (!rec)
{
// rec == nullptr => REAPER is UNLOADING us. Mirror-unregister every
// callback with the same strings prefixed '-' (per the contract).
if (g_rec)
{
// Abort any in-flight realtime capture FIRST, while the API pointers are
// still live — finalize-or-abort + restore so we never leave a temp track,
// an armed track, or an altered transport/cursor in the user's project on
// unload. Commit whatever was captured (best effort) before tearing down.
capture::AbortRealtimeCaptureForUnload(g_session);
g_rec->Register("-timer", (void*)&OnTimer);
g_rec->Register("-projectconfig", (void*)&g_projectConfig);
g_rec->Register("-toggleaction", (void*)&OnToggleAction);
g_rec->Register("-hookcommand", (void*)&OnHookCommand);
// Tear down the Design View action family (D4) — mirror-unregisters each
// gaccel + command_id with '-'-prefixed strings. After the hook is gone.
reasampler::designViewUnregisterActions(g_rec);
// Tear down the multi-bank action family (B3) — same mirror-unregister.
reasampler::bankUnregisterActions(g_rec);
// Tear down the S8 ingest action family — same mirror-unregister.
reasampler::ingestUnregisterActions(g_rec);
// Tear down this TU's own family from the registration table (reverse
// table order; each '-command_id' re-presents the SAME interned,
// channel-qualified pointer used at register).
reasampler::unregisterActionTable(g_rec);
// Retire the REMOVED command ids (command_id only — we never held a gaccel
// for them this session). Clears stale user keybindings on unload. Composed
// per channel so a beta clears beta-qualified retired ids, stable its own.
for (const char* suffix : kRetiredCaptureCmdSuffixes)
g_rec->Register("-command_id", (void*)reasampler::channelIdFor(suffix));
}
// Destroy the docked window and release cached thumbnails before we drop
// the API pointers (DockWindowRemove/DestroyWindow need them live).
reasampler::bankPanelShutdown();
g_rec = nullptr;
return 0;
}
// ABI guard: the struct layout we compiled against must match this REAPER.
if (rec->caller_version != REAPER_PLUGIN_VERSION)
return 0;
// Resolve every REAPER API function pointer. Returns the number that FAILED
// to load; 0 == success. Non-zero usually means REAPER is older than our SDK.
if (REAPERAPI_LoadAPI(rec->GetFunc) != 0)
return 0;
g_hInst = hInstance;
g_rec = rec;
// Point the bank panel at the live session BEFORE registering its action, so
// a toggle firing immediately has a session to read (M5). Does not open the
// window — only stores the session pointer.
reasampler::bankPanelInit(&g_session);
// Register this TU's whole action family from the table: command_id -> gaccel
// per row, all channel-qualified, all FOREVER-STABLE per channel.
{
const std::vector<reasampler::ActionTableRow> rows = buildMainActionTable();
reasampler::registerActionTable(rec, rows.data(), rows.size());
}
// The panel toggle renders a checked state — resolve its minted id once and
// register the toggleaction hook that reports it.
g_cmdToggleBankPanel = reasampler::actionTableCommandId("TOGGLE_BANK_PANEL");
if (g_cmdToggleBankPanel)
rec->Register("toggleaction", (void*)&OnToggleAction);
// Register the Design View action family (D4): toggle/activate mode, tag/untag/
// show-both selected tracks. Each mints its own command_id + gaccel; the single
// hookcommand below routes them via designViewHandleCommand. Registered before
// the hook so every id is minted first.
reasampler::designViewRegisterActions(rec, &g_session);
// Register the multi-bank action family (B3): create/rename/delete/evacuate bank,
// activate (cycle + pool), move/copy selected samples to a bank, and the two
// full-height layout toggles. Shares g_session with the Design View family; routed
// by the same hookcommand via bankHandleCommand. Registered before the hook.
reasampler::bankRegisterActions(rec, &g_session);
// Register the S8 ingest action family: the Media-Explorer import-into-bank+assign
// action. Shares g_session with the other families; routed by the same hookcommand via
// ingestHandleCommand. (The arrange capture+assign action is a table row above; the
// drop path is a bank_panel callback, not a bindable action.)
reasampler::ingestRegisterActions(rec, &g_session);
// One hookcommand routes every ReaSampler action (table + the three families).
// Registered once, after all command ids are minted.
rec->Register("hookcommand", (void*)&OnHookCommand);
// Drive project-load / Save-As detection (M4 persist). The timer polls the
// active project each tick; on a project load it reloads the bank from ext
// state, on a Save-As it relocates the bank folder under the new .rpp.
rec->Register("timer", (void*)&OnTimer);
// Register the projectconfig hook so an UNDO/REDO state restore reloads the
// session's book + view from the restored ext state (R-B). The timer's identity
// poll cannot see an undo (same project identity), so this hook owns undo/redo; it
// requests a deferred reload that the next timer tick drains (see the hook comment).
rec->Register("projectconfig", (void*)&g_projectConfig);
return 1; // success — REAPER keeps us loaded
}
-147
View File
@@ -1,147 +0,0 @@
// assignment_request.cpp — see assignment_request.h. Pure: standard library only.
#include "assignment_request.h"
#include <cstddef>
#include <limits>
namespace reasampler {
namespace {
constexpr const char* kMagic = "rsassign1";
// Append one length-prefixed field: <decimal-len> ':' <bytes>. Mirror of
// provenance's putField so the two seams share one wire idiom.
void putField(std::string& out, const std::string& field) {
out += std::to_string(field.size());
out += ':';
out += field;
}
// Cursor over the encoded string. All reads are bounds-checked; a short read fails
// the whole parse (ok_ latches false). Mirror of provenance's Cursor, trimmed to the
// three field kinds this record needs.
class Cursor {
public:
explicit Cursor(const std::string& s) : s_(s) {}
bool ok() const { return ok_; }
bool atEnd() const { return pos_ >= s_.size(); }
// Reads one length-prefixed field into `out`. Fails on a missing ':', an empty or
// non-numeric length, a length that overflows SIZE_MAX, or a length that runs past
// the end. The digit count is capped at 20 (the decimal width of SIZE_MAX on a
// 64-bit host) so a crafted 200-digit length cannot accumulate past SIZE_MAX via
// repeated multiply. "never UB" promise from the header is upheld here.
bool field(std::string& out) {
if (!ok_) return false;
const std::size_t colon = s_.find(':', pos_);
if (colon == std::string::npos) return fail();
if (colon == pos_) return fail(); // empty length token
// Cap: SIZE_MAX fits in at most 20 decimal digits; a longer run is bogus.
if (colon - pos_ > 20u) return fail();
std::size_t len = 0;
for (std::size_t i = pos_; i < colon; ++i) {
const char c = s_[i];
if (c < '0' || c > '9') return fail();
const std::size_t digit = static_cast<std::size_t>(c - '0');
// Overflow guard: if len would exceed SIZE_MAX after multiply+add, fail.
if (len > (std::numeric_limits<std::size_t>::max() - digit) / 10u)
return fail();
len = len * 10u + digit;
}
const std::size_t start = colon + 1;
// Guard: start may equal s_.size() (empty remainder), in which case only len==0
// is valid; start > s_.size() cannot happen (colon < s_.size() by find()).
// Use subtraction-first form to avoid start+len wrapping on a huge len.
if (start > s_.size() || len > s_.size() - start) return fail();
out.assign(s_, start, len);
pos_ = start + len;
return true;
}
// Reads a length-prefixed field and parses it as a signed 64-bit decimal (an
// optional leading '-'). Fails on empty, non-digit, trailing bytes, or a value
// that would overflow INT64_MAX / underflow INT64_MIN. The digit count is capped
// at 19 (the decimal width of INT64_MAX, plus 1 for the optional sign = 20
// characters maximum) so a crafted 21-digit field cannot accumulate UB. "never UB"
// promise from the header is upheld: all arithmetic is done on positive digits
// and capped before applying the sign.
bool fieldInt64(std::int64_t& out) {
std::string f;
if (!field(f)) return false;
if (f.empty()) return fail();
std::size_t i = 0;
bool neg = false;
if (f[0] == '-') {
neg = true;
i = 1;
if (f.size() == 1) return fail(); // bare "-"
}
// Cap at 19 digits (INT64_MAX = 9223372036854775807 — 19 digits). A 20-digit
// positive value would overflow INT64_MAX; a 20-digit negative might be valid
// (INT64_MIN = -9223372036854775808) but we conservatively reject it too: the
// generation field is a unix timestamp, never near INT64 limits in practice.
if (f.size() - i > 19u) return fail();
std::int64_t v = 0;
for (; i < f.size(); ++i) {
const char c = f[i];
if (c < '0' || c > '9') return fail();
const std::int64_t digit = static_cast<std::int64_t>(c - '0');
// Overflow guard: v * 10 + digit must not exceed INT64_MAX.
if (v > (std::numeric_limits<std::int64_t>::max() - digit) / 10)
return fail();
v = v * 10 + digit;
}
out = neg ? -v : v;
return true;
}
// Consumes an exact literal at the cursor (the magic tag). Fails if absent.
bool literal(const char* lit) {
if (!ok_) return false;
std::size_t i = 0;
for (; lit[i] != '\0'; ++i) {
if (pos_ + i >= s_.size() || s_[pos_ + i] != lit[i]) return fail();
}
pos_ += i;
return true;
}
private:
bool fail() {
ok_ = false;
return false;
}
const std::string& s_;
std::size_t pos_ = 0;
bool ok_ = true;
};
} // namespace
std::string encodeAssignmentRequest(const AssignmentRequest& req) {
std::string out = kMagic;
putField(out, req.bankId);
putField(out, req.sampleId);
putField(out, std::to_string(req.generation));
return out;
}
std::optional<AssignmentRequest> decodeAssignmentRequest(const std::string& wire) {
Cursor cur(wire);
if (!cur.literal(kMagic)) return std::nullopt;
AssignmentRequest req;
if (!cur.field(req.bankId)) return std::nullopt;
if (!cur.field(req.sampleId)) return std::nullopt;
if (!cur.fieldInt64(req.generation)) return std::nullopt;
// Reject trailing garbage: a well-formed value ends exactly at the last field.
if (!cur.ok() || !cur.atEnd()) return std::nullopt;
return req;
}
} // namespace reasampler
-1109
View File
File diff suppressed because it is too large Load Diff
-767
View File
@@ -1,767 +0,0 @@
#include "bank_model.h"
#include <cctype>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cstring>
// bank_model implementation.
//
// JSON is hand-rolled and self-contained (brief: keep the pure core
// dependency-free — no third-party JSON lib, no WDL coupling). The field set is
// a flat struct of primitives, strings, one enum, a small string array, and a
// few optionals, so a compact writer + recursive-descent parser is the simplest
// thing that works. Doubles are emitted with 17 significant digits (%.17g), the
// shortest form that round-trips every IEEE-754 double exactly, so the
// deserialize(serialize(x)) == x invariant holds bit-for-bit.
namespace reasampler {
// ---------------------------------------------------------------------------
// equality
// ---------------------------------------------------------------------------
bool SourceRange::operator==(const SourceRange& o) const {
return startSeconds == o.startSeconds && endSeconds == o.endSeconds &&
startPpq == o.startPpq && endPpq == o.endPpq;
}
bool Provenance::operator==(const Provenance& o) const {
return parentSampleId == o.parentSampleId && fxChainSnapshot == o.fxChainSnapshot;
}
bool Levels::operator==(const Levels& o) const {
return peakDb == o.peakDb && rmsDb == o.rmsDb && lufs == o.lufs;
}
bool LoopPoints::operator==(const LoopPoints& o) const {
return start == o.start && end == o.end;
}
bool Sample::operator==(const Sample& o) const {
return id == o.id && displayName == o.displayName && relativePath == o.relativePath &&
sourceMode == o.sourceMode && sourceRange == o.sourceRange &&
trackGuids == o.trackGuids && wetDry == o.wetDry &&
channelCount == o.channelCount && sampleRate == o.sampleRate &&
lengthSeconds == o.lengthSeconds && lengthBeats == o.lengthBeats &&
captureTempo == o.captureTempo &&
captureTimeSigNum == o.captureTimeSigNum &&
captureTimeSigDenom == o.captureTimeSigDenom && key == o.key &&
rootNote == o.rootNote && loop == o.loop && levels == o.levels &&
clipped == o.clipped && tier == o.tier && contentHash == o.contentHash &&
provenance == o.provenance && createdTimestamp == o.createdTimestamp;
}
// ---------------------------------------------------------------------------
// path invariant
// ---------------------------------------------------------------------------
// DECISION: reject absolute paths rather than normalize them. The pure model has
// no knowledge of the project root, so it cannot correctly relativize an absolute
// path — any "normalization" would be a guess that could point at the wrong file.
// Rejecting at the boundary is honest and deterministic; the capture backend (M3)
// is responsible for handing us an already-relative path. Covers POSIX ("/x"),
// Windows drive ("C:\x", "C:/x", "C:foo" drive-relative), and UNC ("\\host\share")
// forms. Any leading <alpha>: is rejected regardless of the character that follows —
// drive-relative paths ("C:foo.wav") resolve against the drive's current directory,
// not the project root, so they violate the relative-paths-only invariant just as
// much as "C:\foo.wav" does.
static bool isAbsolutePath(const std::string& p) {
if (p.empty()) return false;
if (p[0] == '/' || p[0] == '\\') return true; // POSIX root or UNC
if (p.size() >= 2 && std::isalpha(static_cast<unsigned char>(p[0])) && p[1] == ':')
return true; // Windows drive (C:\, C:/, C:foo, C:)
return false;
}
// ---------------------------------------------------------------------------
// BankIndex
// ---------------------------------------------------------------------------
AddResult BankIndex::add(const Sample& sample) {
if (sample.id.empty()) return AddResult::RejectedEmptyId;
if (isAbsolutePath(sample.relativePath)) return AddResult::RejectedAbsolutePath;
if (findByHash(sample.contentHash) != nullptr)
return AddResult::Collapsed;
samples_.push_back(sample);
return AddResult::Added;
}
bool BankIndex::remove(const std::string& id) {
for (auto it = samples_.begin(); it != samples_.end(); ++it) {
if (it->id == id) {
samples_.erase(it);
return true;
}
}
return false;
}
bool BankIndex::updateInPlace(const std::string& id, const Sample& updated) {
if (isAbsolutePath(updated.relativePath)) return false; // invariant still holds
for (auto& s : samples_) {
if (s.id == id) {
s = updated; // replace in place — position (insertion order) preserved
return true;
}
}
return false;
}
const Sample* BankIndex::query(const std::string& id) const {
for (const auto& s : samples_)
if (s.id == id) return &s;
return nullptr;
}
const Sample* BankIndex::findByHash(const std::string& contentHash) const {
if (contentHash.empty()) return nullptr; // empty hashes never dedup
for (const auto& s : samples_)
if (s.contentHash == contentHash) return &s;
return nullptr;
}
bool BankIndex::moveTier(const std::string& id, Tier tier) {
for (auto& s : samples_) {
if (s.id == id) {
s.tier = tier;
return true;
}
}
return false;
}
std::vector<Sample> BankIndex::byTier(Tier tier) const {
std::vector<Sample> out;
for (const auto& s : samples_)
if (s.tier == tier) out.push_back(s);
return out;
}
// ---------------------------------------------------------------------------
// JSON writer
// ---------------------------------------------------------------------------
namespace {
void writeEscaped(std::string& out, const std::string& s) {
out += '"';
for (char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\b': out += "\\b"; break;
case '\f': out += "\\f"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (static_cast<unsigned char>(c) < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(c));
out += buf;
} else {
out += c;
}
}
}
out += '"';
}
std::string numToStr(double v) {
char buf[32];
std::snprintf(buf, sizeof(buf), "%.17g", v);
return buf;
}
std::string numToStr(std::int64_t v) {
char buf[32];
std::snprintf(buf, sizeof(buf), "%lld", static_cast<long long>(v));
return buf;
}
std::string numToStr(int v) { return numToStr(static_cast<std::int64_t>(v)); }
class ObjWriter {
public:
explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; }
~ObjWriter() { out_ += '}'; }
void keyRaw(const char* key, const std::string& rawValue) {
sep();
writeEscaped(out_, key);
out_ += ':';
out_ += rawValue;
}
void keyStr(const char* key, const std::string& value) {
sep();
writeEscaped(out_, key);
out_ += ':';
writeEscaped(out_, value);
}
// Begin a nested value; caller writes the value immediately after.
void keyBegin(const char* key) {
sep();
writeEscaped(out_, key);
out_ += ':';
}
private:
void sep() {
if (first_) first_ = false; else out_ += ',';
}
std::string& out_;
bool first_ = true;
};
void writeStringArray(std::string& out, const std::vector<std::string>& v) {
out += '[';
for (std::size_t i = 0; i < v.size(); ++i) {
if (i) out += ',';
writeEscaped(out, v[i]);
}
out += ']';
}
void writeSample(std::string& out, const Sample& s) {
ObjWriter w(out);
w.keyStr("id", s.id);
w.keyStr("displayName", s.displayName);
w.keyStr("relativePath", s.relativePath);
w.keyRaw("sourceMode", numToStr(static_cast<int>(s.sourceMode)));
w.keyBegin("sourceRange");
{
ObjWriter r(out);
r.keyRaw("startSeconds", numToStr(s.sourceRange.startSeconds));
r.keyRaw("endSeconds", numToStr(s.sourceRange.endSeconds));
r.keyRaw("startPpq", numToStr(s.sourceRange.startPpq));
r.keyRaw("endPpq", numToStr(s.sourceRange.endPpq));
}
w.keyBegin("trackGuids");
writeStringArray(out, s.trackGuids);
w.keyRaw("wetDry", numToStr(s.wetDry));
w.keyRaw("channelCount", numToStr(s.channelCount));
w.keyRaw("sampleRate", numToStr(s.sampleRate));
w.keyRaw("lengthSeconds", numToStr(s.lengthSeconds));
w.keyRaw("lengthBeats", numToStr(s.lengthBeats));
w.keyRaw("captureTempo", numToStr(s.captureTempo));
w.keyRaw("captureTimeSigNum", numToStr(s.captureTimeSigNum));
w.keyRaw("captureTimeSigDenom", numToStr(s.captureTimeSigDenom));
// Optionals are emitted as null when absent so present/absent round-trips.
w.keyBegin("key");
if (s.key) writeEscaped(out, *s.key); else out += "null";
// Phase S seam fields (D-B). Emitted as null when absent (same shape as `key`
// and `provenance`) so pre-Phase-S JSON — which lacks these keys entirely —
// parses to empty optionals and re-serializes without invention.
w.keyBegin("rootNote");
if (s.rootNote) out += numToStr(*s.rootNote); else out += "null";
w.keyBegin("loop");
if (s.loop) {
ObjWriter lp(out);
lp.keyRaw("start", numToStr(s.loop->start));
lp.keyRaw("end", numToStr(s.loop->end));
} else {
out += "null";
}
w.keyBegin("levels");
{
ObjWriter l(out);
l.keyRaw("peakDb", numToStr(s.levels.peakDb));
l.keyRaw("rmsDb", numToStr(s.levels.rmsDb));
l.keyRaw("lufs", numToStr(s.levels.lufs));
}
w.keyRaw("clipped", s.clipped ? "true" : "false");
w.keyRaw("tier", numToStr(static_cast<int>(s.tier)));
w.keyStr("contentHash", s.contentHash);
w.keyBegin("provenance");
if (s.provenance) {
ObjWriter p(out);
p.keyStr("parentSampleId", s.provenance->parentSampleId);
p.keyStr("fxChainSnapshot", s.provenance->fxChainSnapshot);
} else {
out += "null";
}
w.keyRaw("createdTimestamp", numToStr(s.createdTimestamp));
}
} // namespace
std::string BankIndex::serialize() const {
std::string out;
{
ObjWriter root(out);
root.keyRaw("version", numToStr(1));
root.keyBegin("samples");
out += '[';
for (std::size_t i = 0; i < samples_.size(); ++i) {
if (i) out += ',';
writeSample(out, samples_[i]);
}
out += ']';
} // root closes the object here — not deferred to function return (NRVO would
// otherwise let the caller observe `out` before the closing brace is appended)
return out;
}
// ---------------------------------------------------------------------------
// JSON parser (recursive descent). Returns false on any malformed input; never
// reads out of bounds. Only supports the subset our writer emits.
// ---------------------------------------------------------------------------
namespace {
class Parser {
public:
explicit Parser(const std::string& s) : s_(s) {}
bool parseIndex(BankIndex& out);
private:
const std::string& s_;
std::size_t pos_ = 0;
bool eof() const { return pos_ >= s_.size(); }
char peek() const { return s_[pos_]; }
void skipWs() {
while (!eof()) {
char c = s_[pos_];
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_;
else break;
}
}
bool consume(char c) {
skipWs();
if (eof() || s_[pos_] != c) return false;
++pos_;
return true;
}
bool parseString(std::string& out);
bool parseRawScalar(std::string& out); // number / true / false / null token
bool parseDouble(double& out);
bool parseInt64(std::int64_t& out);
bool parseInt(int& out);
bool parseBool(bool& out);
bool expectNullOr(bool& wasNull); // peeks for `null`; consumes if present
bool parseSample(Sample& out);
bool parseKey(std::string& key); // an object member key + ':'
bool skipValue(); // for forward-compat unknown keys
};
// Parses a JSON string literal (with the escapes our writer emits, plus \uXXXX
// for control chars). Positioned at the opening quote after whitespace.
bool Parser::parseString(std::string& out) {
skipWs();
if (eof() || s_[pos_] != '"') return false;
++pos_;
out.clear();
while (!eof()) {
char c = s_[pos_++];
if (c == '"') return true;
if (c == '\\') {
if (eof()) return false;
char e = s_[pos_++];
switch (e) {
case '"': out += '"'; break;
case '\\': out += '\\'; break;
case '/': out += '/'; break;
case 'b': out += '\b'; break;
case 'f': out += '\f'; break;
case 'n': out += '\n'; break;
case 'r': out += '\r'; break;
case 't': out += '\t'; break;
case 'u': {
// Decode a \uXXXX escape to its code point.
auto readHex4 = [&](unsigned int& cp) -> bool {
if (pos_ + 4 > s_.size()) return false;
cp = 0;
for (int i = 0; i < 4; ++i) {
char h = s_[pos_++];
cp <<= 4;
if (h >= '0' && h <= '9') cp |= static_cast<unsigned>(h - '0');
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(h - 'A' + 10);
else return false;
}
return true;
};
unsigned int hi = 0;
if (!readHex4(hi)) return false;
unsigned int codePoint = hi;
if (hi >= 0xD800 && hi <= 0xDBFF) {
// High surrogate — must be followed by \uDC00\uDFFF.
if (pos_ + 6 > s_.size()) return false;
if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false;
pos_ += 2;
unsigned int lo = 0;
if (!readHex4(lo)) return false;
if (lo < 0xDC00 || lo > 0xDFFF) return false; // unpaired high surrogate
codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00);
} else if (hi >= 0xDC00 && hi <= 0xDFFF) {
return false; // unpaired low surrogate — malformed
}
// Encode codePoint as UTF-8.
if (codePoint <= 0x7F) {
out += static_cast<char>(codePoint);
} else if (codePoint <= 0x7FF) {
out += static_cast<char>(0xC0 | (codePoint >> 6));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else if (codePoint <= 0xFFFF) {
out += static_cast<char>(0xE0 | (codePoint >> 12));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else {
out += static_cast<char>(0xF0 | (codePoint >> 18));
out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
}
break;
}
default: return false;
}
} else {
out += c;
}
}
return false; // unterminated string
}
// Reads a bare token (number, true, false, null) up to the next structural char.
bool Parser::parseRawScalar(std::string& out) {
skipWs();
std::size_t start = pos_;
while (!eof()) {
char c = s_[pos_];
if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' ||
c == '\n' || c == '\r')
break;
++pos_;
}
if (pos_ == start) return false;
out.assign(s_, start, pos_ - start);
return true;
}
bool Parser::parseDouble(double& out) {
std::string tok;
if (!parseRawScalar(tok)) return false;
const char* b = tok.c_str();
char* end = nullptr;
errno = 0;
double v = std::strtod(b, &end);
if (end != b + tok.size()) return false;
if (errno == ERANGE) return false; // overflow / underflow → malformed
out = v;
return true;
}
bool Parser::parseInt64(std::int64_t& out) {
std::string tok;
if (!parseRawScalar(tok)) return false;
const char* b = tok.c_str();
char* end = nullptr;
errno = 0;
long long v = std::strtoll(b, &end, 10);
if (end != b + tok.size()) return false;
if (errno == ERANGE) return false; // overflow → malformed
out = static_cast<std::int64_t>(v);
return true;
}
bool Parser::parseInt(int& out) {
std::int64_t v = 0;
if (!parseInt64(v)) return false;
out = static_cast<int>(v);
return true;
}
bool Parser::parseBool(bool& out) {
std::string tok;
if (!parseRawScalar(tok)) return false;
if (tok == "true") { out = true; return true; }
if (tok == "false") { out = false; return true; }
return false;
}
// If the next value is the `null` token, consumes it and sets wasNull=true.
// Otherwise leaves the position untouched and sets wasNull=false. Returns false
// only on eof.
bool Parser::expectNullOr(bool& wasNull) {
skipWs();
if (eof()) return false;
if (s_.compare(pos_, 4, "null") == 0) {
pos_ += 4;
wasNull = true;
} else {
wasNull = false;
}
return true;
}
bool Parser::parseKey(std::string& key) {
if (!parseString(key)) return false;
if (!consume(':')) return false;
return true;
}
// Skips one JSON value (object / array / string / scalar) for forward-compat
// with keys we don't recognize. Assumes position is at the start of the value.
bool Parser::skipValue() {
skipWs();
if (eof()) return false;
char c = s_[pos_];
if (c == '"') {
std::string tmp;
return parseString(tmp);
}
if (c == '{' || c == '[') {
char open = c, close = (c == '{') ? '}' : ']';
++pos_;
int depth = 1;
while (!eof() && depth > 0) {
char d = s_[pos_];
if (d == '"') {
std::string tmp;
if (!parseString(tmp)) return false;
continue;
}
if (d == open) ++depth;
else if (d == close) --depth;
++pos_;
}
return depth == 0;
}
std::string tmp;
return parseRawScalar(tmp);
}
bool Parser::parseSample(Sample& s) {
if (!consume('{')) return false;
skipWs();
if (consume('}')) return true; // empty object (shouldn't happen, but valid)
do {
std::string key;
if (!parseKey(key)) return false;
if (key == "id") {
if (!parseString(s.id)) return false;
} else if (key == "displayName") {
if (!parseString(s.displayName)) return false;
} else if (key == "relativePath") {
if (!parseString(s.relativePath)) return false;
} else if (key == "sourceMode") {
int v = 0;
if (!parseInt(v)) return false;
// Valid range: MasterMix(0) .. Realtime(5).
if (v < static_cast<int>(SourceMode::MasterMix) ||
v > static_cast<int>(SourceMode::Realtime))
return false;
s.sourceMode = static_cast<SourceMode>(v);
} else if (key == "sourceRange") {
if (!consume('{')) return false;
do {
std::string rk;
if (!parseKey(rk)) return false;
double dv = 0.0;
if (!parseDouble(dv)) return false;
if (rk == "startSeconds") s.sourceRange.startSeconds = dv;
else if (rk == "endSeconds") s.sourceRange.endSeconds = dv;
else if (rk == "startPpq") s.sourceRange.startPpq = dv;
else if (rk == "endPpq") s.sourceRange.endPpq = dv;
} while (consume(','));
if (!consume('}')) return false;
} else if (key == "trackGuids") {
if (!consume('[')) return false;
skipWs();
if (!consume(']')) {
do {
std::string g;
if (!parseString(g)) return false;
s.trackGuids.push_back(g);
} while (consume(','));
if (!consume(']')) return false;
}
} else if (key == "wetDry") {
if (!parseDouble(s.wetDry)) return false;
} else if (key == "channelCount") {
if (!parseInt(s.channelCount)) return false;
} else if (key == "sampleRate") {
if (!parseInt(s.sampleRate)) return false;
} else if (key == "lengthSeconds") {
if (!parseDouble(s.lengthSeconds)) return false;
} else if (key == "lengthBeats") {
if (!parseDouble(s.lengthBeats)) return false;
} else if (key == "captureTempo") {
if (!parseDouble(s.captureTempo)) return false;
} else if (key == "captureTimeSigNum") {
if (!parseInt(s.captureTimeSigNum)) return false;
} else if (key == "captureTimeSigDenom") {
if (!parseInt(s.captureTimeSigDenom)) return false;
} else if (key == "key") {
bool wasNull = false;
if (!expectNullOr(wasNull)) return false;
if (wasNull) {
s.key.reset();
} else {
std::string k;
if (!parseString(k)) return false;
s.key = k;
}
} else if (key == "rootNote") {
bool wasNull = false;
if (!expectNullOr(wasNull)) return false;
if (wasNull) {
s.rootNote.reset();
} else {
int v = 0;
if (!parseInt(v)) return false;
// Valid MIDI note range: 0..127 inclusive (boundaries valid).
if (v < 0 || v > 127) return false;
s.rootNote = v;
}
} else if (key == "loop") {
bool wasNull = false;
if (!expectNullOr(wasNull)) return false;
if (wasNull) {
s.loop.reset();
} else {
if (!consume('{')) return false;
LoopPoints lp;
do {
std::string lk;
if (!parseKey(lk)) return false;
std::int64_t lv = 0;
if (!parseInt64(lv)) return false;
if (lk == "start") lp.start = lv;
else if (lk == "end") lp.end = lv;
} while (consume(','));
if (!consume('}')) return false;
// Invariant: 0 <= start <= end. start == end is a valid zero-length
// marker; a negative index or start > end is malformed, not silently
// clamped (mirrors the enum-range rejection above).
if (lp.start < 0 || lp.end < lp.start) return false;
s.loop = lp;
}
} else if (key == "levels") {
if (!consume('{')) return false;
do {
std::string lk;
if (!parseKey(lk)) return false;
double dv = 0.0;
if (!parseDouble(dv)) return false;
if (lk == "peakDb") s.levels.peakDb = dv;
else if (lk == "rmsDb") s.levels.rmsDb = dv;
else if (lk == "lufs") s.levels.lufs = dv;
} while (consume(','));
if (!consume('}')) return false;
} else if (key == "clipped") {
if (!parseBool(s.clipped)) return false;
} else if (key == "tier") {
int v = 0;
if (!parseInt(v)) return false;
// Valid range: Scratch(0) .. Archive(1).
if (v < static_cast<int>(Tier::Scratch) ||
v > static_cast<int>(Tier::Archive))
return false;
s.tier = static_cast<Tier>(v);
} else if (key == "contentHash") {
if (!parseString(s.contentHash)) return false;
} else if (key == "provenance") {
bool wasNull = false;
if (!expectNullOr(wasNull)) return false;
if (wasNull) {
s.provenance.reset();
} else {
if (!consume('{')) return false;
Provenance p;
do {
std::string pk;
if (!parseKey(pk)) return false;
std::string pv;
if (!parseString(pv)) return false;
if (pk == "parentSampleId") p.parentSampleId = pv;
else if (pk == "fxChainSnapshot") p.fxChainSnapshot = pv;
} while (consume(','));
if (!consume('}')) return false;
s.provenance = p;
}
} else if (key == "createdTimestamp") {
if (!parseInt64(s.createdTimestamp)) return false;
} else {
if (!skipValue()) return false; // forward-compat: ignore unknown
}
} while (consume(','));
return consume('}');
}
bool Parser::parseIndex(BankIndex& out) {
if (!consume('{')) return false;
skipWs();
if (consume('}')) return true; // empty object — vacuously an empty index
std::vector<Sample> parsed;
do {
std::string key;
if (!parseKey(key)) return false;
if (key == "samples") {
if (!consume('[')) return false;
skipWs();
if (!consume(']')) {
do {
Sample s;
if (!parseSample(s)) return false;
parsed.push_back(std::move(s));
} while (consume(','));
if (!consume(']')) return false;
}
} else {
if (!skipValue()) return false; // version, or unknown keys
}
} while (consume(','));
if (!consume('}')) return false;
// Trailing garbage after the root object is malformed.
skipWs();
if (!eof()) return false;
// Rebuild via add() so the same invariants (relative-path, dedup) that guard
// live inserts also guard deserialized data. Rejected/collapsed entries are
// dropped silently — a well-formed serialized index never triggers them.
for (auto& s : parsed) out.add(s);
return true;
}
} // namespace
std::optional<BankIndex> BankIndex::deserialize(const std::string& json) {
BankIndex idx;
Parser p(json);
if (!p.parseIndex(idx)) return std::nullopt;
return idx;
}
} // namespace reasampler
-3459
View File
File diff suppressed because it is too large Load Diff
-130
View File
@@ -1,130 +0,0 @@
#pragma once
// bank_panel — the docked grid window (M5, Wave A). REAPER-facing shell: it owns
// a SWELL dialog docked via DockWindowAddEx, and paints the current project's
// bank as a grid of LICE-drawn waveform thumbnails. The panel itself NEVER inserts
// into the arrange or mutates the project/bank (CONTEXT.md §load-bearing
// principle). Audition / multi-select / keyboard nav are Wave B.
//
// The header is REAPER-free as practical: main.cpp drives the panel through these
// free functions, passing the live session so the panel reads the current bank.
// All SWELL / LICE / PCM_source use is confined to bank_panel.cpp. The pure
// layout math and cache keys live in bank_grid (unit-tested outside the DAW).
#include <string>
#include <vector>
#include "tail_control.h" // TailSetting — the panel's tail-mode toggle state
namespace reasampler {
class ReaSamplerSession;
// Wires the panel into main.cpp's lifecycle. Called once after the API pointers
// are loaded, BEFORE the toggle action is registered. `session` must outlive the
// panel (it is the extension-lifetime g_session). Stores the session pointer the
// panel reads on every repaint; does not create the window yet.
void bankPanelInit(ReaSamplerSession* session);
// Toggles the docked window: creates+docks it if hidden, hides+undocks it if
// shown. Bound to the "toggle bank panel" action. Safe to call before the first
// timer tick.
void bankPanelToggle();
// Whether the panel window is currently open/visible. Feeds the action's
// checked-state (toggleaction) so REAPER shows a tick next to the menu entry.
bool bankPanelIsOpen();
// The stable ids of the currently-selected samples, in bank (insertion) order.
// Empty when nothing is selected or the panel has never opened. This is the clean
// seam the `insert` action reads to know WHAT to place — it returns ids (not grid
// indices) so the caller resolves against the live bank and is unaffected by the
// panel's internal index bookkeeping. READ of panel state only; no mutation.
//
// Note: the panel's selection is cleared on a bank change (capture / project
// load), so a returned id always names a sample present in the current bank at
// the moment of the call; the caller still tolerates an absent id gracefully.
//
// Phase B4 (vertical split): the selection lives in whichever REGION the user last
// interacted with (the pool grid on top or a named-bank grid below), which is NOT
// necessarily the active/capture-target bank. The returned ids therefore name
// samples in the FOCUSED region's displayed bank — the bank the user visibly
// selected in. Pair with bankPanelSelectedSourceBankId() to know which bank those
// ids belong to (the move/copy source).
std::vector<std::string> bankPanelSelectedSampleIds();
// The bank id the current selection belongs to — the displayed bank of the region
// the user last interacted with (pool region -> the pool id; named-banks region ->
// the shown tab's bank id). This is the SOURCE bank for a move/copy of the current
// selection, and it is distinct from the active/capture-target bank (active ≠ shown).
// Returns the pool id when nothing is selected or the panel has never opened (a safe
// default source). READ of panel state only; no mutation.
std::string bankPanelSelectedSourceBankId();
// Requests a repaint if the bank changed since the last paint (generation bump).
// Cheap when nothing changed. Driven by the timer so a capture / project load is
// reflected without the panel diffing the bank itself.
void bankPanelRefresh();
// Notifies the panel that persist just (re)loaded a project's view model (membership +
// active mode). main.cpp calls this on the exact tick it drains persist's load signal
// and reapplies the active mode. It re-arms the new-content detector so the just-loaded
// project's PRE-EXISTING content is taken as the baseline (reported as nothing new),
// never diffed against the previously-open project and mass-tagged into the active mode.
// This coordinates the detector's project-identity signal with persist's authoritative
// (GUID-primary) one — the two can no longer diverge on a recycled ReaProject* address,
// which is what caused a project opened in Design to mis-tag its Arrange tracks. READ/
// arm of panel state only; no project or bank mutation.
void bankPanelNotifyProjectLoaded();
// The panel's current tail-mode setting (mode + Manual length), read by the plain
// CAPTURE_ITEM / CAPTURE_TRACK actions when building a CaptureRequest so a capture
// applies whatever the panel toggle is set to. Default None (exact bounds) — a
// capture with no explicit choice stays byte-identical to today. Extension-session
// setting: persists across project loads and panel open/close within a REAPER session;
// resets to None only when the extension unloads (fresh REAPER session). Project
// persistence across REAPER restarts is a noted follow-on.
// Safe to call before the panel has ever opened (returns the default). READ of panel
// state only; the toggle is mutated by a click inside the panel, never here.
TailSetting bankPanelTailSetting();
// The vertical-split full-height layout state (Phase B). The bank window splits
// vertically — pool on top, named-banks region below — and two toggles collapse the
// split: pool full-height (hide the named-banks region) and banks full-height (hide
// the pool). The two are mutually exclusive with the default (both regions shown),
// so one enum captures the whole state.
//
// This bit is B3-owned (the actions flip it); B4's panel RENDERS from it. It lives
// here beside the tail setting — the other session-level view-layout bit the panel
// reads — NOT in the persisted ReaSamplerSession: it is a UI-layout preference, not
// project state, so it must not travel with the .rpp. In-memory for the extension's
// lifetime; resets to Split on unload.
enum class BankPanelFullHeight {
Split, // default: pool region on top, named-banks region below
PoolOnly, // pool full-height — named-banks region hidden
BanksOnly, // banks full-height — pool region hidden
};
// The current full-height layout state (default Split). READ by B4's panel to decide
// which region(s) to draw. Safe before the panel has ever opened.
BankPanelFullHeight bankPanelFullHeight();
// Toggles pool full-height: Split <-> PoolOnly. From PoolOnly returns to Split; from
// either other state (Split or BanksOnly) enters PoolOnly. Bound to the "pool
// full-height" action. Requests a repaint so an open panel reflects the change.
void bankPanelToggledPoolFullHeight();
// Toggles banks full-height: Split <-> BanksOnly, symmetric to the pool toggle.
// Bound to the "banks full-height" action. Requests a repaint.
void bankPanelToggledBanksFullHeight();
// Requests an immediate repaint of the panel if it is open. A no-op when the panel
// is closed (safe to call unconditionally). Called by the actions layer after a
// mode change so the footer [Arrange|Design] toggle reflects the new mode without
// requiring a hide/reshow.
void bankPanelInvalidate();
// Tears the panel down on extension unload: destroys the window and releases any
// cached thumbnails / PCM handles. Mirror of bankPanelInit; safe if never opened.
void bankPanelShutdown();
} // namespace reasampler
-234
View File
@@ -1,234 +0,0 @@
#pragma once
// capture — the REAPER-facing capture shell (CLAUDE.md §load-bearing split).
//
// This header declares the capture *seam* the later milestones fill:
// * CaptureRequest — everything a capture needs, source-mode-agnostic.
// * ICaptureBackend — the SYNCHRONOUS interface OfflineRenderBackend implements
// (headless, immediate, returns a finished Sample).
// * OfflineRenderBackend — the deterministic default; drives the offline scopes.
// * RealtimeRecordBackend — the ASYNC realtime seam (begin/tick/abort), driven
// across timer ticks; deliberately NOT an ICaptureBackend
// (see the SEAM CHOICE note at its declaration).
//
// It includes bank_model (pure) to hand back a populated Sample, but NO REAPER
// headers — the .cpp is the REAPER-facing translation unit. Keeping this header
// REAPER-free lets callers (main.cpp, future actions.cpp) depend on the seam
// without dragging the SDK into every include site.
#include <memory>
#include <string>
#include <vector>
#include "bank_model.h"
#include "render_settings.h" // TailMode (pure) — the three-state tail contract
// MediaTrack is forward-declared (like track_guid.h) so this header stays
// REAPER-free while RealtimeRecordBackend::begin can take the resolved source
// MediaTrack* to tap. The pointers are opaque here — never dereferenced in a
// pure/header context; only the REAPER-facing capture_realtime.cpp touches them.
class MediaTrack;
namespace reasampler {
// Audio bit-depth for the rendered wav. 32-bit float is the M3 default —
// rationale lives in capture.cpp next to the sink-config bytes.
enum class WavBitDepth {
Int16,
Int24,
Float32,
};
// One capture, independent of source mode. Populated by the caller (the action
// handler in M3; the action family in M7) and consumed by a backend.
//
// M3 fills only the fields the master-mix/time-selection path needs; the rest
// are declared now so M7/M8 do not reshape the struct (they are the seam).
struct CaptureRequest {
SourceMode sourceMode = SourceMode::MasterMix;
// Sample-accurate render bounds in project seconds. For the M3 spike these
// come straight from the time selection (GetSet_LoopTimeRange) — NO rounding.
double startSeconds = 0.0;
double endSeconds = 0.0;
// 1.0 = fully wet, 0.0 = fully dry. All three-scope capture actions set this to 1.0 (wet).
// The field is kept as the seam for future true-dry work (M10 null test):
// true pre-FX dry offline is NOT available via RENDER_SETTINGS — it requires
// FX-bypass-around-render or the M8 realtime pre-FX path, and will be
// designed alongside the M10 null test. Also recorded on the Sample.
double wetDry = 1.0;
// Track GUID(s) the capture came from, when the source mode is track-scoped
// (SelectedTracks). Empty for master/items/razor. The action layer (M7)
// resolves the selection to canonical GUID strings and passes them here; the
// backend copies them onto the Sample (it does NOT itself read the selection —
// it stays source-agnostic, driven entirely by the request).
std::vector<std::string> trackGuids;
// Render tail (docs/product/capture-tail.md §The three tail states). Default
// None: exact bounds, no added silence — the precision invariant, and the only
// mode valid for null-test / verify captures. `tailMs` is meaningful ONLY for
// TailMode::Manual (clamped to the 8 s cap by the pure mapping); Auto uses the
// 8 s cap + -72 dB trim internally, None ignores it.
TailMode tailMode = TailMode::None;
double tailMs = 0.0;
// Output format. 0 sampleRate => follow project rate (deterministic: the
// project rate is fixed for a given project).
int sampleRate = 0;
int channelCount = 2;
WavBitDepth bitDepth = WavBitDepth::Float32;
// Human base name for the file stem; sanitized by capture_paths. The unique
// tag (disambiguator) is supplied separately by the backend caller so the
// pure naming logic stays testable.
std::string baseName = "capture";
std::string uniqueTag; // e.g. a timestamp/counter; may be empty
};
// Outcome of a capture attempt. `Ok` carries the populated Sample; every failure
// is an explicit code (never a thrown exception across the REAPER boundary) so
// the action handler can log a precise reason.
enum class CaptureStatus {
Ok,
NoProject, // no active project to render / resolve a bank folder
EmptyRange, // start >= end: nothing to render
UnsupportedMode, // backend does not implement this source mode (M3 scope)
UnsupportedFormat, // requested bit depth has no known REAPER blob (M3: Float32 only)
RenderFailed, // the render action ran but produced no output file
TransportBusy, // realtime backend: transport already playing/recording — refused
};
struct CaptureResult {
CaptureStatus status = CaptureStatus::RenderFailed;
Sample sample; // valid only when status == Ok
std::string message; // human-readable detail for the console log
};
// The capture seam. One method: run a request, return a populated Sample (or a
// failure code). Backends are non-destructive — they must restore any global
// state they touch before returning (OfflineRenderBackend snapshots/restores the
// RENDER_* project settings).
class ICaptureBackend {
public:
virtual ~ICaptureBackend() = default;
virtual CaptureResult capture(const CaptureRequest& request) = 0;
};
// Deterministic offline-render backend. Drives the full offline source family —
// master mix / time selection, selected tracks, selected items, razor area — all
// wet-only (render_settings.h) with optional tail. The source selection + range
// are resolved by the caller (the action layer) and handed in via the
// CaptureRequest; the backend drives RENDER_* and never reads the DAW selection
// itself. SourceMode::Realtime returns UnsupportedMode (that is the M8 backend).
class OfflineRenderBackend : public ICaptureBackend {
public:
CaptureResult capture(const CaptureRequest& request) override;
};
// --- Realtime-record backend: the ASYNC seam ---------------------------------
//
// A realtime record is inherently asynchronous: CSurf_OnRecord starts the transport
// on REAPER's audio thread and returns immediately — it does NOT block until the
// range completes, which takes (end - start) wall-clock seconds. Blocking the main
// thread for that duration freezes REAPER's UI, so the realtime backend is DRIVEN
// ACROSS TIMER TICKS instead: begin() starts and returns at once; tick() (called
// from the same OnTimer that runs session.poll()) advances the in-flight record and
// reports when it is done.
//
// SEAM CHOICE (surfaced): RealtimeRecordBackend deliberately does NOT implement the
// synchronous ICaptureBackend — that interface returns a finished Sample from one
// call, which no longer fits a record that spans ticks. The two backends have
// genuinely different lifecycles (offline is headless + immediate; realtime is
// transport-driven + async), so forcing a shared async interface would make offline
// fake a lifecycle it does not have (its tick() would always be Done on the first
// call — dead code / an LSP smell). Offline stays synchronous and unchanged; the
// realtime backend owns this small bespoke async seam, driven by exactly one caller
// (main.cpp's OnTimer). This is the split-sync/async fork, chosen over a unified
// async interface for that reason.
// One tick's verdict from the in-flight record.
enum class RealtimeTickStatus {
InProgress, // still recording — call tick() again next timer tick
Done, // finished (range end reached, or the user stopped) — `result` is set
Failed, // an error tore the capture down — `result.message` explains
};
struct RealtimeTickResult {
RealtimeTickStatus status = RealtimeTickStatus::InProgress;
CaptureResult result; // meaningful only when status == Done or Failed
};
// The opaque in-flight capture state. Owns the snapshot of everything to restore
// (temp track + its receive sends from the source tracks, other tracks' I_RECARM,
// transport, edit cursor, time selection) and the record's own project handle.
// Defined in
// capture_realtime.cpp; the header stays REAPER-free (no MediaTrack*/ReaProject*
// leaks here) by holding it behind a forward-declared type + unique_ptr.
//
// restore()/teardown is idempotent and lives ON THIS OBJECT (not a function-scope
// RAII guard) because the record spans ticks — no single stack frame outlives it.
// Every terminal path (normal completion, user stop, error, project switch, unload)
// funnels through the same single restore, safe to call once from whichever fires.
class RealtimeCaptureState;
// Out-of-line deleter so callers (main.cpp) can own a unique_ptr to the opaque
// RealtimeCaptureState WITHOUT its full (REAPER-typed) definition — the delete is
// compiled in capture_realtime.cpp where the type is complete, keeping this header
// REAPER-free (load-bearing split).
struct RealtimeCaptureStateDeleter {
void operator()(RealtimeCaptureState* p) const noexcept;
};
using RealtimeCaptureHandle =
std::unique_ptr<RealtimeCaptureState, RealtimeCaptureStateDeleter>;
// Realtime-record backend — captures by RECORDING in realtime (transport-driven)
// into a hidden temp track, then moves the recorded file into the bank as a Sample.
// For sources offline render cannot do (hardware, performed FX) and as the true
// pre-FX-dry path (I_RECMODE_FLAGS &3==1 — the only pre-FX tap in the SDK; offline
// render has none). Dialog-free: never invokes the offline-render progress window.
//
// Non-bit-identical by nature (it is realtime); offline stays the deterministic
// default. Non-destructive across EVERY terminal path — the review gate — which is
// harder here than offline because the record spans ticks: the snapshot + restore
// live on RealtimeCaptureState, not a function-scope RAII destructor.
//
// SCOPE (this increment): TRACK scope only — records the selected track's OWN
// output (item + that track's own FX + its own fader/pan, PRE-parent), matching
// offline's track scope. This needs NO FxBypassGuard: a send tapping a track's
// output is naturally PRE-parent (the parent has not summed it yet), so the tap is
// chain-independent by construction. Item realtime is deferred (UnsupportedMode).
class RealtimeRecordBackend {
public:
// Starts a realtime record: validates the request (track scope, non-empty range,
// at least one source track, active + saved project, transport idle), snapshots
// all state to restore, creates the hidden temp track, routes a send FROM each
// source track INTO the temp track, arms, and CSurf_OnRecord — then returns
// IMMEDIATELY (no wait, no UI block). `sourceTracks` are the selected tracks to
// tap (resolved by the action layer — the CaptureRequest itself stays REAPER-free,
// carrying only the provenance GUIDs). On success the returned unique_ptr owns the
// in-flight state; drive it with tick(). On a validation/setup failure returns
// nullptr and fills `outFailure` with the CaptureStatus + message (nothing was
// left mutated — begin() restores on its own failure paths).
RealtimeCaptureHandle begin(const CaptureRequest& request,
const std::vector<MediaTrack*>& sourceTracks,
CaptureResult& outFailure);
// Advances the in-flight record one tick. Reads the transport (bound to the
// record's OWN project handle so a project switch cannot confuse it), and on a
// terminal verdict stops the transport, finalizes the recorded file into the
// bank Sample (Done) or reports the failure (Failed), then restores ALL
// snapshotted state. Returns InProgress while the record is still running.
// After Done/Failed the state is spent — the caller drops the unique_ptr.
RealtimeTickResult tick(RealtimeCaptureState& state);
// Force-terminate an in-flight record NOW without waiting for the range end:
// stops the transport, finalizes whatever was captured (best effort) or abandons
// it, and restores ALL snapshotted state. For the shutdown / project-switch
// paths (extension unload, a new project became active) where the record must
// not leak a temp track / armed track / altered transport into the user's
// project. Idempotent — safe even if a prior tick already tore the state down.
RealtimeTickResult abort(RealtimeCaptureState& state);
};
} // namespace reasampler
+3 -3
View File
@@ -1,4 +1,4 @@
#include "peaks.h"
#include "core/audio/peaks.h"
#include <algorithm>
#include <climits>
@@ -14,7 +14,7 @@
// extra frames) with no rounding drift and no dropped tail — the last bin's end is
// always exactly frameCount.
namespace reasampler {
namespace reasampler::audio {
Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
std::size_t channelCount,
@@ -123,4 +123,4 @@ std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
return kNoFrameAboveThreshold;
}
} // namespace reasampler
} // namespace reasampler::audio
+2 -2
View File
@@ -11,7 +11,7 @@
#include <cstddef>
#include <vector>
namespace reasampler {
namespace reasampler::audio {
// Canonical in-memory audio-sample type. `float` is REAPER's native audio buffer
// format (its render/PCM_source callbacks hand back interleaved 32-bit float), so
@@ -119,4 +119,4 @@ std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
std::size_t frameCount,
AudioSample linearThreshold);
} // namespace reasampler
} // namespace reasampler::audio
@@ -1,11 +1,11 @@
// batch_capture.cpp — pure logic for M11 batch capture. See header.
// NO REAPER types; unit-tested by tests/test_batch_capture.cpp.
#include "batch_capture.h"
#include "core/capture/batch_capture.h"
#include <algorithm>
namespace reasampler {
namespace reasampler::capture {
std::vector<CaptureUnit> planCaptureUnits(const std::vector<BatchRange>& ranges) {
std::vector<CaptureUnit> units;
@@ -73,4 +73,4 @@ std::string BatchOutcome::summaryLine(const std::string& noun) const {
return line;
}
} // namespace reasampler
} // namespace reasampler::capture
@@ -29,7 +29,7 @@
#include <string>
#include <vector>
namespace reasampler {
namespace reasampler::capture {
// One capture in a batch: an exact source range plus its 1-based ordinal within the
// KEPT set. The ordinal disambiguates per-unit file stems (the offline backend's
@@ -97,4 +97,4 @@ private:
std::vector<BatchUnitResult> results_;
};
} // namespace reasampler
} // namespace reasampler::capture
@@ -1,120 +1,14 @@
#include "capture_paths.h"
#include "core/capture/capture_paths.h"
#include <cassert>
#include <cctype>
#include <cstdint>
#include <cstdio>
#include <cstring> // std::memcmp
#include <filesystem>
#include <vector>
namespace reasampler {
namespace reasampler::capture {
std::string hashBytes(const std::uint8_t* data, std::size_t len) {
// FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity.
// Constants from the FNV spec (http://www.isthe.com/chongo/tech/comp/fnv/).
constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL;
constexpr std::uint64_t kPrime = 1099511628211ULL;
std::uint64_t h = kOffsetBasis;
for (std::size_t i = 0; i < len; ++i) {
h ^= static_cast<std::uint64_t>(data[i]);
h *= kPrime;
}
// Format as 16-digit lowercase hex (zero-padded) for a fixed-length string.
char buf[17];
std::snprintf(buf, sizeof(buf), "%016llx",
static_cast<unsigned long long>(h));
return std::string(buf);
}
std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
// Walk the RIFF/WAVE container and feed only the `fmt ` body and `data` body
// through FNV-1a, prefixed with the domain-separation tag byte 'W' (0x57).
// Any render-varying metadata chunks (bext, iXML, LIST, SMED, etc.) are skipped.
// If the file does not parse as RIFF/WAVE with both fmt and data chunks, fall back
// to whole-file hashBytes (no prefix) so an unrecognized file still gets a hash.
//
// The chunk-walk mirrors wav_trim::parseWavLayout's structure but accumulates
// FNV state instead of recording geometry — no second parser, same logic.
// FNV-1a 64-bit constants (same as hashBytes).
constexpr std::uint64_t kOffsetBasis = 14695981039346656037ULL;
constexpr std::uint64_t kPrime = 1099511628211ULL;
// Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes.
auto tagEq = [&](std::size_t off, const char* tag) -> bool {
return off + 4 <= bytes.size() &&
std::memcmp(bytes.data() + off, tag, 4) == 0;
};
auto readU32LE = [&](std::size_t off) -> std::uint32_t {
return static_cast<std::uint32_t>(bytes[off]) |
(static_cast<std::uint32_t>(bytes[off + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[off + 2]) << 16) |
(static_cast<std::uint32_t>(bytes[off + 3]) << 24);
};
bool isWav = bytes.size() >= 12 &&
tagEq(0, "RIFF") &&
tagEq(8, "WAVE");
if (isWav) {
// Accumulate FNV-1a starting with the domain-separation tag byte 'W'.
std::uint64_t h = kOffsetBasis;
auto feedByte = [&](std::uint8_t b) {
h ^= static_cast<std::uint64_t>(b);
h *= kPrime;
};
bool haveFmt = false;
bool haveData = false;
// Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a
// whole-file hash of different bytes that happen to be the same length.
feedByte(static_cast<std::uint8_t>('W'));
std::size_t pos = 12;
while (pos + 8 <= bytes.size()) {
const std::size_t bodyOffset = pos + 8;
const std::uint32_t bodySize = readU32LE(pos + 4);
if (tagEq(pos, "fmt ")) {
// Feed the entire fmt body (all fields, including format tag, channels,
// sample rate, bits-per-sample — everything that defines the audio format).
if (bodyOffset + bodySize <= bytes.size()) {
for (std::uint32_t i = 0; i < bodySize; ++i)
feedByte(bytes[bodyOffset + i]);
haveFmt = true;
}
} else if (tagEq(pos, "data")) {
// Feed the entire PCM payload.
if (bodyOffset + bodySize <= bytes.size()) {
for (std::uint32_t i = 0; i < bodySize; ++i)
feedByte(bytes[bodyOffset + i]);
haveData = true;
}
}
// All other chunks (bext, iXML, LIST, SMED, cue, etc.) are skipped.
// Advance past this chunk's body, honoring RIFF even-byte padding.
std::size_t advance = bodySize;
if (advance & 1u) ++advance; // RIFF pad byte
if (advance > bytes.size() - bodyOffset) break; // overrun guard
pos = bodyOffset + advance;
}
if (haveFmt && haveData) {
char buf[17];
std::snprintf(buf, sizeof(buf), "%016llx",
static_cast<unsigned long long>(h));
return std::string(buf);
}
// Falls through to whole-file fallback if chunks were missing/malformed.
}
// Fallback: not a parseable RIFF/WAVE — hash the whole file (same as the old
// per-call hashBytes). No prefix tag: identical to hashBytes(data, size).
return hashBytes(bytes.data(), bytes.size());
}
// The content-identity hashes (hashBytes / hashWavContent) moved to wav_codec
// (Q-W3, audit §4e) — one pure owner of the RIFF chunk walk, shared with the
// layout parse so hashing and decoding cannot desynchronize.
std::string normalizeSlashes(const std::string& path) {
std::string out = path;
@@ -216,7 +110,7 @@ std::string resolveBankFile(const std::string& projectDir,
std::string projectDirOfRpp(const std::string& rppPath) {
// An unsaved project reports an empty .rpp path; keep it empty so downstream
// resolution refuses (no default-location fallback). Mirrors persist.cpp's prior
// resolution refuses (no default-location fallback). Mirrors the former persist shell's
// projectDirOf exactly: parent_path of the .rpp, then normalizeSlashes.
if (rppPath.empty()) return {};
std::string dir = std::filesystem::path(rppPath).parent_path().string();
@@ -284,4 +178,4 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject,
return ProjectTransition::NoOp;
}
} // namespace reasampler
} // namespace reasampler::capture
@@ -17,7 +17,7 @@
#include <string>
#include <vector>
namespace reasampler {
namespace reasampler::capture {
// The project-relative bank subfolder. All captured wavs live here so the bank
// travels with the .rpp (CONTEXT.md §Settled decisions: per-project bank).
@@ -25,7 +25,7 @@ inline constexpr const char* kBankSubfolder = "reasampler_bank";
// A resolved pair of paths for one capture: where REAPER must be told to write
// (absolute, because RENDER_FILE wants a directory REAPER can create/open) and
// what we store in the BankIndex (project-relative, because the index is
// what we store in the BankModel (project-relative, because the index is
// relative-paths-only — CLAUDE.md precision invariant).
struct BankPaths {
std::string absoluteDir; // <projectDir>/reasampler_bank (forward slash)
@@ -34,37 +34,9 @@ struct BankPaths {
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
};
// Computes a deterministic FNV-1a 64-bit content hash over `len` bytes at `data`
// and returns it as a 16-character lowercase hex string. Designed to fill
// Sample::contentHash so the confirm-on-last-reference guardrail
// (BankBook::hashReferencedElsewhere) can distinguish "no other bank holds this
// file" from "another bank holds the same file." An empty buffer returns the bare
// FNV-1a 64-bit offset basis in hex (a stable, non-empty sentinel that two empty
// files would share, but real WAV files are never empty).
std::string hashBytes(const std::uint8_t* data, std::size_t len);
// WAV-aware content hash: hashes only the audio-defining content of a 32-bit-float
// RIFF/WAVE file — the `fmt ` chunk body + the `data` chunk payload — skipping all
// other RIFF chunks (e.g. `bext` origination timestamp, `iXML`, `LIST`/`INFO`, SMED).
//
// WHY: REAPER's offline renderer embeds render-varying metadata chunks (at minimum a
// `bext` chunk containing the origination date/time) even when the format config blob
// requests no BWF metadata. Two renders of identical audio therefore differ in those
// bytes, making whole-file hashes diverge and preventing dedup collapse.
//
// DOMAIN SEPARATION: the FNV-1a input is prefixed with the tag byte 'W' (0x57) before
// the fmt/data bytes are fed in, so a content hash can never equal a whole-file
// hashBytes result for a different file of the same size.
//
// FALLBACK: if `bytes` does not parse as a valid RIFF/WAVE with both a `fmt ` and a
// `data` chunk, the function falls back to whole-file hashBytes (no prefix tag) —
// identical to calling hashBytes(bytes.data(), bytes.size()). This ensures that an
// unrecognized or malformed file still gets a non-empty hash rather than silently
// skipping dedup.
//
// Called by both capture commit paths (offline and realtime) in place of the raw
// hashBytes call.
std::string hashWavContent(const std::vector<std::uint8_t>& bytes);
// NOTE (Q-W3, audit §4e): the content-identity hashes (hashBytes / hashWavContent)
// moved to core/capture/wav_codec.{h,cpp} — the ONE pure owner of the WAV/RIFF byte
// format — so this module holds path arithmetic only, with no RIFF chunk knowledge.
// Normalizes a path to forward slashes and strips any trailing slash. Empty in
// -> empty out. Pure string transform (does not consult the filesystem).
@@ -87,7 +59,7 @@ std::string sanitizeStem(const std::string& baseName);
// 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 BankIndex::add's relative-only invariant.
// forward-slashed so it satisfies BankModel::add's relative-only invariant.
BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName,
const std::string& uniqueTag);
@@ -212,4 +184,4 @@ ProjectTransition classifyProjectTransition(bool sameProjectObject,
const std::string& currentGuid,
const std::string& currentPath);
} // namespace reasampler
} // namespace reasampler::capture
@@ -1,9 +1,10 @@
// realtime_record.cpp — pure logic for the realtime-record backend (M8). See header.
// NO REAPER types; unit-tested by tests/test_realtime_record.cpp.
// capture_realtime.cpp — pure logic for the realtime-record backend (M8). See
// header. NO REAPER types; unit-tested by tests/test_capture_realtime.cpp.
// (Renamed from realtime_record.cpp in Q-W3 — the Q-9 naming rider.)
#include "realtime_record.h"
#include "core/capture/capture_realtime.h"
namespace reasampler {
namespace reasampler::capture {
RecordModePlan recordModePlanFor(int channelCount, OutputTap tap) {
RecordModePlan p;
@@ -124,4 +125,4 @@ bool isTerminalPhase(RecordPhase phase) {
return phase == RecordPhase::Done || phase == RecordPhase::Failed;
}
} // namespace reasampler
} // namespace reasampler::capture
@@ -1,9 +1,12 @@
#pragma once
// realtime_record — the REAPER-free logic behind the realtime-record backend (M8).
// capture_realtime — the REAPER-free logic behind the realtime-record backend (M8).
// (Renamed from realtime_record in Q-W3 — the Q-9 naming rider: the PURE module
// takes the stem, the shell takes the suffix — capture_realtime_shell.cpp /
// capture_realtime_finalize.cpp — matching the drag_out ↔ drag_out_win model.)
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The realtime backend (capture.cpp)
// drives the transport, the temp track, the send routing, and the file move —
// vendor/ includes. Standard library only. The realtime shell drives the
// transport, the temp track, the send routing, and the file move —
// all REAPER-bound and DAW-verified. The genuinely pure, easy-to-get-wrong
// pieces are split out here and unit-tested outside the DAW:
//
@@ -24,9 +27,13 @@
#include <string>
#include <vector>
#include "bank_model.h" // Sample, SourceMode (pure)
#include "core/model/bank_model.h" // Sample, SourceMode (pure)
namespace reasampler {
namespace reasampler::capture {
using model::Sample;
using model::Tier;
using model::SourceMode;
// --- I_RECMODE values (verbatim from SDK header ~2197) -----------------------
//
@@ -107,6 +114,16 @@ struct RecordedCapture {
std::vector<std::string> trackGuids;
int channelCount = 0;
// TEST-ONLY / dead in production (Q-W3 review follow-up): the shell no longer
// populates these five fields before calling sampleFromRecordedCapture — the
// finalize path (capture_realtime_finalize.cpp) leaves them at their defaults
// and instead calls the shared stampCaptureSample(result.sample, ...) right
// after, which writes Sample::sampleRate/captureTempo/captureTimeSigNum/
// captureTimeSigDenom/createdTimestamp directly, overwriting whatever
// sampleFromRecordedCapture set from these. Kept (not deleted) because the pure
// unit tests still construct/assert them directly; removing the fields is a
// struct-shape decision out of scope here.
int sampleRate = 0; // 0 when the project rate was unknown (as offline)
double captureTempo = 0.0; // BPM at capture time (shell reads Master_GetTempo)
// Time signature at capture start (L7 F1; shell reads TimeMap_GetTimeSigAtTime).
@@ -231,4 +248,4 @@ bool isStopRequested(RecordPhase phase);
// Only Done and Failed are terminal; Recording and Finalizing are live.
bool isTerminalPhase(RecordPhase phase);
} // namespace reasampler
} // namespace reasampler::capture
@@ -1,8 +1,8 @@
// insert_plan.cpp — see insert_plan.h. Pure InsertMedia mode-bit arithmetic.
#include "insert_plan.h"
#include "core/capture/insert_plan.h"
namespace reasampler {
namespace reasampler::capture {
namespace {
@@ -46,4 +46,4 @@ int computeInsertMode(const InsertOptions& opts) {
return mode;
}
} // namespace reasampler
} // namespace reasampler::capture
@@ -22,7 +22,7 @@
#include <cstdint>
namespace reasampler {
namespace reasampler::capture {
// Where InsertMedia drops the item. Maps to the low bits of `mode` (mode&3).
// We expose only the two placement targets M6 needs; "add as takes" (3) is a
@@ -72,4 +72,4 @@ int computeInsertMode(const InsertOptions& opts);
// any computed mode (the "no silent time-stretch" invariant, made checkable).
inline constexpr int kStretchToTimeSelBit = 4;
} // namespace reasampler
} // namespace reasampler::capture
@@ -1,13 +1,13 @@
// render_settings.cpp — pure logic for the three-scope capture action family. See header.
// NO REAPER types; unit-tested by tests/test_render_settings.cpp.
#include "render_settings.h"
#include "core/capture/render_settings.h"
#include <algorithm>
#include <cmath>
#include <sstream>
namespace reasampler {
namespace reasampler::capture {
double autoTrimEndRatio() {
// Amplitude ratio = 10^(dB/20). Derived from kAutoTrimThresholdDb so the dB is
@@ -221,4 +221,4 @@ const std::vector<CaptureActionDef>& captureActionTable() {
return table;
}
} // namespace reasampler
} // namespace reasampler::capture
@@ -25,9 +25,11 @@
#include <string>
#include <vector>
#include "bank_model.h" // SourceMode (pure enum)
#include "core/model/bank_model.h" // SourceMode (pure enum)
namespace reasampler {
namespace reasampler::capture {
using model::SourceMode;
// --- RENDER_SETTINGS source/processing bits (verbatim from SDK header ~3041) --
//
@@ -260,4 +262,4 @@ struct CaptureActionDef {
// capture applies is read from the docked-panel setting, not baked into the row.
const std::vector<CaptureActionDef>& captureActionTable();
} // namespace reasampler
} // namespace reasampler::capture
@@ -1,14 +1,13 @@
// tail_control — pure implementation. See tail_control.h. NO REAPER / SWELL / vendor.
#include "tail_control.h"
#include "core/capture/tail_control.h"
#include <algorithm>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cstring>
namespace reasampler {
#include "core/json/json.h"
namespace reasampler::capture {
TailMode cycleTailMode(TailMode current) {
switch (current) {
@@ -51,13 +50,13 @@ std::string tailToggleLabel(const TailSetting& setting) {
// JSON round-trip
// ---------------------------------------------------------------------------
//
// The setting is a flat object of one enum + one double, so a compact hand-rolled
// writer + a tolerant minimal reader is the simplest thing that works (mirroring
// bank_model's dependency-free JSON choice). manualMs is emitted with 17 significant
// digits (%.17g) — the shortest form that round-trips every IEEE-754 double exactly —
// so deserialize(serialize(x)) == x holds bit-for-bit. deserialize is deliberately
// forgiving: any parse failure returns nullopt so the caller falls back to a default,
// exactly as an absent ext-state key does.
// The setting is a flat object of one enum + one double, riding the shared
// core/json layer (Q-W1, T2-02: the former substring-scan valueAfterKey reader —
// the fifth hand-rolled JSON decoder — is retired). manualMs is emitted with 17
// significant digits (%.17g) — the shortest form that round-trips every IEEE-754
// double exactly — so deserialize(serialize(x)) == x holds bit-for-bit.
// deserialize stays forgiving in outcome: any parse failure returns nullopt so
// the caller falls back to a default, exactly as an absent ext-state key does.
namespace {
@@ -81,52 +80,52 @@ std::optional<TailMode> modeFromInt(int v) {
}
}
// Find the value token following `"key":` in `json`. Returns a pointer just past the
// colon (skipping whitespace) or nullptr if the key is absent. Minimal: the writer
// emits exactly one flat object with unique keys, so a substring search is sufficient
// and there is no nesting to confuse it.
const char* valueAfterKey(const std::string& json, const char* key) {
const std::string needle = std::string("\"") + key + "\"";
const std::size_t pos = json.find(needle);
if (pos == std::string::npos) return nullptr;
const char* p = json.c_str() + pos + needle.size();
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') ++p;
if (*p != ':') return nullptr;
++p;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') ++p;
return p;
}
} // namespace
std::string serializeTailSetting(const TailSetting& setting) {
char buf[128];
std::snprintf(buf, sizeof(buf), "{\"mode\":%d,\"manualMs\":%.17g}",
modeToInt(setting.mode), setting.manualMs);
return std::string(buf);
// Byte-identical to the former snprintf writer: {"mode":%d,"manualMs":%.17g}.
std::string out;
{
json::Writer w(out);
w.keyRaw("mode", json::numToStr(modeToInt(setting.mode)));
w.keyRaw("manualMs", json::numToStr(setting.manualMs));
} // Writer closes the object here (see bank_model's NRVO note)
return out;
}
std::optional<TailSetting> deserializeTailSetting(const std::string& json) {
const char* modeTok = valueAfterKey(json, "mode");
const char* msTok = valueAfterKey(json, "manualMs");
if (!modeTok || !msTok) return std::nullopt; // absent key -> malformed -> default
std::optional<TailSetting> deserializeTailSetting(const std::string& blob) {
json::Reader r(blob);
if (!r.consume('{')) return std::nullopt;
char* end = nullptr;
errno = 0;
const long modeVal = std::strtol(modeTok, &end, 10);
if (end == modeTok || errno != 0) return std::nullopt;
const std::optional<TailMode> mode = modeFromInt(static_cast<int>(modeVal));
int modeInt = 0;
double ms = 0.0;
bool haveMode = false, haveMs = false;
r.skipWs();
if (!r.consume('}')) {
do {
std::string key;
if (!r.parseKey(key)) return std::nullopt;
if (key == "mode") {
if (!r.parseInt(modeInt)) return std::nullopt;
haveMode = true;
} else if (key == "manualMs") {
if (!r.parseDouble(ms)) return std::nullopt;
haveMs = true;
} else {
if (!r.skipValue()) return std::nullopt; // forward-compat
}
} while (r.consume(','));
if (!r.consume('}')) return std::nullopt;
}
if (!haveMode || !haveMs) return std::nullopt; // absent key -> malformed -> default
const std::optional<TailMode> mode = modeFromInt(modeInt);
if (!mode) return std::nullopt;
end = nullptr;
errno = 0;
const double ms = std::strtod(msTok, &end);
if (end == msTok || errno != 0) return std::nullopt;
TailSetting out;
out.mode = *mode;
out.manualMs = ms;
return out;
}
} // namespace reasampler
} // namespace reasampler::capture
@@ -1,6 +1,6 @@
#pragma once
// tail_control — the REAPER-free logic behind the docked bank_panel's tail-mode
// toggle. The panel shell (bank_panel.cpp) owns the SWELL window, LICE drawing, and
// toggle. The panel shell (shell/panel/) owns the SWELL window, LICE drawing, and
// click hit-testing; what is NOT DAW-bound — the cycle order, the manual-length
// clamp, and the toggle's label text — lives here so it is unit-tested outside the
// DAW (CLAUDE.md §load-bearing split). Mirror of bank_grid / mode_switch.
@@ -12,9 +12,9 @@
#include <optional>
#include <string>
#include "render_settings.h" // TailMode (pure enum) — the three-state tail contract
#include "core/capture/render_settings.h" // TailMode (pure enum) — the three-state tail contract
namespace reasampler {
namespace reasampler::capture {
// The Manual-mode starting length. 2 s is a musically useful default tail (a bar of
// reverb throw at a moderate tempo) that is well under the 8 s cap. Also the value a
@@ -27,7 +27,7 @@ inline constexpr double kDefaultManualTailMs = 2000.0;
inline constexpr double kManualStepMs = 250.0;
// The panel's current tail setting: the mode plus the length used ONLY when the
// mode is Manual. Held as in-memory panel/session state (bank_panel.cpp), default
// mode is Manual. Held as in-memory panel/session state (shell/panel), default
// None so a capture with no explicit choice stays exact-bounds / byte-identical to
// today. `manualMs` is a stored default a future fine-adjust UI can tune; it is
// clamped to the 8 s cap (kMaxTailMs) before it ever reaches a CaptureRequest.
@@ -67,4 +67,4 @@ std::string tailToggleLabel(const TailSetting& setting);
std::string serializeTailSetting(const TailSetting& setting);
std::optional<TailSetting> deserializeTailSetting(const std::string& json);
} // namespace reasampler
} // namespace reasampler::capture
+333
View File
@@ -0,0 +1,333 @@
// wav_codec — pure implementation. See wav_codec.h. NO REAPER / SWELL / vendor.
//
// The ONE RIFF chunk traversal lives here (nextWavChunk); the layout parse and the
// content hash both walk with it, so their view of the container cannot drift.
#include "core/capture/wav_codec.h"
#include <cstdio> // std::snprintf (hash hex render)
#include <cstring> // std::memcpy, std::memcmp
namespace reasampler::capture {
namespace {
// Little-endian readers. Bounds are checked by the caller before each read; these
// assume `off + N <= bytes.size()`. memcpy avoids alignment/aliasing UB.
std::uint16_t readU16LE(const std::vector<std::uint8_t>& b, std::size_t off) {
return static_cast<std::uint16_t>(b[off] | (b[off + 1] << 8));
}
std::uint32_t readU32LE(const std::vector<std::uint8_t>& b, std::size_t off) {
return static_cast<std::uint32_t>(b[off]) |
(static_cast<std::uint32_t>(b[off + 1]) << 8) |
(static_cast<std::uint32_t>(b[off + 2]) << 16) |
(static_cast<std::uint32_t>(b[off + 3]) << 24);
}
bool tagEquals(const std::vector<std::uint8_t>& b, std::size_t off, const char* tag) {
return off + 4 <= b.size() && std::memcmp(b.data() + off, tag, 4) == 0;
}
// WAVE format tags we accept as 32-bit float (see wav_codec.h FORMAT ASSUMPTION).
constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003;
constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE;
// FNV-1a 64-bit constants (http://www.isthe.com/chongo/tech/comp/fnv/).
constexpr std::uint64_t kFnvOffsetBasis = 14695981039346656037ULL;
constexpr std::uint64_t kFnvPrime = 1099511628211ULL;
std::string fnvHex(std::uint64_t h) {
// 16-digit lowercase hex (zero-padded) for a fixed-length string.
char buf[17];
std::snprintf(buf, sizeof(buf), "%016llx", static_cast<unsigned long long>(h));
return std::string(buf);
}
// --- The ONE RIFF chunk traversal --------------------------------------------
//
// One sub-chunk of a RIFF/WAVE container as the walk sees it: header at
// `headerOffset` (id(4) + size(4)), body at `bodyOffset` with declared `bodySize`.
// `bodyInBounds` is whether the declared body fits inside the buffer — a chunk
// whose declared size lies past the end is still REPORTED (callers decide how to
// treat it) but its body must not be read.
struct WavChunkView {
std::size_t headerOffset = 0;
std::size_t bodyOffset = 0;
std::uint32_t bodySize = 0;
bool bodyInBounds = false;
};
// Advances one chunk. `pos` starts at 12 (after "RIFF" size "WAVE"); each call
// fills `out` and moves `pos` past the chunk's body, honoring RIFF even-byte
// padding. Returns false when no further chunk header fits. If the padded advance
// would overrun the buffer, the chunk is still reported (return true) and `pos` is
// parked past the end so the NEXT call returns false — exactly the process-then-
// break shape the pre-consolidation walkers shared.
bool nextWavChunk(const std::vector<std::uint8_t>& bytes, std::size_t& pos,
WavChunkView& out) {
if (pos + 8 > bytes.size()) return false;
out.headerOffset = pos;
out.bodyOffset = pos + 8;
out.bodySize = readU32LE(bytes, pos + 4);
out.bodyInBounds = (out.bodyOffset + out.bodySize <= bytes.size());
std::size_t advance = out.bodySize;
if (advance & 1u) ++advance; // RIFF pad byte
if (advance > bytes.size() - out.bodyOffset) {
pos = bytes.size(); // overrun -> this is the last reported chunk
} else {
pos = out.bodyOffset + advance;
}
return true;
}
bool isRiffWave(const std::vector<std::uint8_t>& bytes) {
// Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes.
return bytes.size() >= 12 && tagEquals(bytes, 0, "RIFF") &&
tagEquals(bytes, 8, "WAVE");
}
} // namespace
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
WavLayout out;
if (!isRiffWave(bytes)) return out;
bool haveFmt = false;
std::uint16_t fmtTag = 0, channels = 0, bitsPerSample = 0;
std::uint32_t sampleRate = 0;
std::uint16_t extensibleSubFormatTag = 0; // set only when fmtTag == kWaveFormatExtensible
// Walk the sub-chunks after "WAVE" (offset 12) with the shared traversal. A
// malformed/truncated file is "invalid", never an OOB read.
std::size_t pos = 12;
WavChunkView c;
while (nextWavChunk(bytes, pos, c)) {
if (tagEquals(bytes, c.headerOffset, "fmt ")) {
// fmt body: at least 16 bytes (PCM/float common fields).
if (c.bodyOffset + 16 > bytes.size() || c.bodySize < 16) return out;
fmtTag = readU16LE(bytes, c.bodyOffset + 0);
channels = readU16LE(bytes, c.bodyOffset + 2);
sampleRate = readU32LE(bytes, c.bodyOffset + 4);
bitsPerSample = readU16LE(bytes, c.bodyOffset + 14);
// For WAVE_FORMAT_EXTENSIBLE (0xFFFE), read the SubFormat GUID's leading
// 2-byte tag at body offset 24 to distinguish float (0x0003) from PCM
// integer (0x0001) and all other sub-formats. Body must be >= 40 bytes to
// reach GUID offset 24 + 16 bytes of GUID, and the full GUID must fit in
// the buffer; otherwise we leave extensibleSubFormatTag at 0 (rejected).
if (fmtTag == kWaveFormatExtensible) {
if (c.bodySize >= 40 && c.bodyOffset + 40 <= bytes.size()) {
extensibleSubFormatTag = readU16LE(bytes, c.bodyOffset + 24);
}
}
haveFmt = true;
} else if (tagEquals(bytes, c.headerOffset, "data")) {
// The data chunk: PCM starts at bodyOffset, declared length bodySize.
// Reject if it runs past the buffer (truncated / lying header).
if (!c.bodyInBounds) return out;
if (!haveFmt) return out; // data before fmt — not a WAV we parse
// Plain IEEE-float tag (0x0003): accept as-is.
// Extensible tag (0xFFFE): accept only when the SubFormat tag read from
// the GUID at body offset 24 is also 0x0003 (IEEE float). SubFormat tag
// 0x0001 (PCM integer) or anything else with bitsPerSample==32 is NOT
// float and must be rejected to prevent mis-decoding as float.
const bool floatTag = (fmtTag == kWaveFormatIeeeFloat) ||
(fmtTag == kWaveFormatExtensible &&
extensibleSubFormatTag == kWaveFormatIeeeFloat);
if (!floatTag || bitsPerSample != 32 || channels == 0) return out;
out.valid = true;
out.channelCount = channels;
out.sampleRate = sampleRate;
out.dataByteOffset = c.bodyOffset;
out.dataByteLength = c.bodySize;
out.riffSizeFieldOffset = 4;
out.dataSizeFieldOffset = c.headerOffset + 4; // the `data` size field (LE uint32)
return out;
}
}
return out; // no data chunk found -> invalid
}
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
const WavLayout& layout,
std::size_t startFrame,
std::size_t frameCount) {
std::vector<AudioSample> out;
if (!layout.valid) return out;
const std::size_t bytesPerFrame =
static_cast<std::size_t>(layout.channelCount) * 4u;
const std::size_t totalFrames = layout.frameCount();
if (startFrame >= totalFrames) return out;
// Clamp the requested span to the frames that actually exist.
const std::size_t avail = totalFrames - startFrame;
const std::size_t frames = (frameCount < avail) ? frameCount : avail;
if (frames == 0) return out;
const std::size_t firstByte =
layout.dataByteOffset + startFrame * bytesPerFrame;
out.resize(frames * layout.channelCount);
// memcpy each float (LE on target hosts — see header's byte-order note).
for (std::size_t i = 0; i < out.size(); ++i) {
float f = 0.0f;
std::memcpy(&f, bytes.data() + firstByte + i * 4u, 4u);
out[i] = f;
}
return out;
}
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames) {
WavTruncatePlan plan;
if (!layout.valid) return plan;
const std::size_t totalFrames = layout.frameCount();
if (keptFrames > totalFrames) return plan; // never grow
const std::size_t bytesPerFrame =
static_cast<std::size_t>(layout.channelCount) * 4u;
const std::size_t keptDataBytes = keptFrames * bytesPerFrame;
plan.valid = true;
plan.newFileByteLength = layout.dataByteOffset + keptDataBytes;
plan.dataSizeFieldOffset = layout.dataSizeFieldOffset;
plan.newDataSize = static_cast<std::uint32_t>(keptDataBytes);
plan.riffSizeFieldOffset = layout.riffSizeFieldOffset;
// RIFF size counts everything after the 8-byte "RIFF"+size prefix.
plan.newRiffSize = static_cast<std::uint32_t>(plan.newFileByteLength - 8);
return plan;
}
void patchU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v) {
bytes[off + 0] = static_cast<std::uint8_t>(v & 0xFF);
bytes[off + 1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
bytes[off + 2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
bytes[off + 3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
}
std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
std::size_t frameCount,
const std::vector<double>& interleaved) {
const std::size_t sampleCount = frameCount * static_cast<std::size_t>(nch);
const std::size_t dataBytesCount = sampleCount * 4u; // 4 bytes per float32
// The WAV is: RIFF(4)+size(4)+WAVE(4) = 12, fmt (4)+size(4)+16 body = 24, data (4)+size(4)+payload.
// Total = 12 + 24 + 8 + dataBytesCount = 44 + dataBytesCount.
const std::uint32_t riffSize =
static_cast<std::uint32_t>(36u + dataBytesCount); // 4("WAVE")+24(fmt chunk)+8(data hdr)+data
std::vector<std::uint8_t> out;
out.reserve(44u + dataBytesCount);
auto putU16 = [&](std::uint16_t v) {
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
};
auto putU32 = [&](std::uint32_t v) {
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
};
auto putTag = [&](const char* t) {
for (int i = 0; i < 4; ++i)
out.push_back(static_cast<std::uint8_t>(t[i]));
};
auto putF32 = [&](float f) {
std::uint8_t tmp[4];
std::memcpy(tmp, &f, 4);
for (int i = 0; i < 4; ++i) out.push_back(tmp[i]);
};
// RIFF header
putTag("RIFF");
putU32(riffSize);
putTag("WAVE");
// fmt chunk (16-byte body, WAVE_FORMAT_IEEE_FLOAT = 0x0003)
putTag("fmt ");
putU32(16u); // chunk body size
putU16(0x0003u); // WAVE_FORMAT_IEEE_FLOAT
putU16(static_cast<std::uint16_t>(nch));
putU32(rate);
putU32(rate * static_cast<std::uint32_t>(nch) * 4u); // avgBytesPerSec
putU16(static_cast<std::uint16_t>(nch * 4)); // blockAlign
putU16(32u); // bitsPerSample
// data chunk
putTag("data");
putU32(static_cast<std::uint32_t>(dataBytesCount));
for (std::size_t i = 0; i < sampleCount && i < interleaved.size(); ++i)
putF32(static_cast<float>(interleaved[i]));
return out;
}
std::string hashBytes(const std::uint8_t* data, std::size_t len) {
// FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity.
std::uint64_t h = kFnvOffsetBasis;
for (std::size_t i = 0; i < len; ++i) {
h ^= static_cast<std::uint64_t>(data[i]);
h *= kFnvPrime;
}
return fnvHex(h);
}
std::string hashWavContent(const std::vector<std::uint8_t>& bytes) {
// Walk the RIFF/WAVE container (the shared traversal) and feed only the `fmt `
// body and `data` body through FNV-1a, prefixed with the domain-separation tag
// byte 'W' (0x57). Any render-varying metadata chunks (bext, iXML, LIST, SMED,
// etc.) are skipped. If the file does not parse as RIFF/WAVE with both fmt and
// data chunks, fall back to whole-file hashBytes (no prefix) so an unrecognized
// file still gets a hash.
if (isRiffWave(bytes)) {
std::uint64_t h = kFnvOffsetBasis;
auto feedByte = [&](std::uint8_t b) {
h ^= static_cast<std::uint64_t>(b);
h *= kFnvPrime;
};
bool haveFmt = false;
bool haveData = false;
// Domain-separation prefix: 'W' (0x57) distinguishes a content hash from a
// whole-file hash of different bytes that happen to be the same length.
feedByte(static_cast<std::uint8_t>('W'));
std::size_t pos = 12;
WavChunkView c;
while (nextWavChunk(bytes, pos, c)) {
if (tagEquals(bytes, c.headerOffset, "fmt ")) {
// Feed the entire fmt body (all fields, including format tag, channels,
// sample rate, bits-per-sample — everything that defines the audio format).
if (c.bodyInBounds) {
for (std::uint32_t i = 0; i < c.bodySize; ++i)
feedByte(bytes[c.bodyOffset + i]);
haveFmt = true;
}
} else if (tagEquals(bytes, c.headerOffset, "data")) {
// Feed the entire PCM payload.
if (c.bodyInBounds) {
for (std::uint32_t i = 0; i < c.bodySize; ++i)
feedByte(bytes[c.bodyOffset + i]);
haveData = true;
}
}
// All other chunks (bext, iXML, LIST, SMED, cue, etc.) are skipped.
}
if (haveFmt && haveData) return fnvHex(h);
// Falls through to whole-file fallback if chunks were missing/malformed.
}
// Fallback: not a parseable RIFF/WAVE — hash the whole file (identical to
// hashBytes(data, size); no prefix tag).
return hashBytes(bytes.data(), bytes.size());
}
} // namespace reasampler::capture
+81 -10
View File
@@ -1,10 +1,20 @@
#pragma once
// wav_trim — pure parse + truncate-plan for the realtime tail's PCM decay-scan trim.
// wav_codec — the ONE pure owner of the WAV/RIFF byte format (Q-W3, audit §4e:
// T2-08 / T4-10 / T4-23 consolidation). Chunk walker + layout parse + float32
// build + size-field patch + the WAV-aware content hash, in one tested module.
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. Builds and unit-tests without REAPER.
//
// WHY THIS EXISTS (docs/product/capture-tail.md §The realtime path). The realtime
// 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
@@ -12,12 +22,12 @@
// the file is a corrupt / mis-lengthed WAV. That header arithmetic — chunk walking,
// format verification, and the size-field patch offsets — is exactly the fiddly,
// easy-to-get-wrong logic the discipline unit-tests OUTSIDE the DAW. The REAPER
// shell (capture_realtime.cpp) does only the file I/O: read the bytes, call the
// pure parse, run the decay scan, call the pure plan, write the truncated bytes.
// 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). This parser
// 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
@@ -27,11 +37,16 @@
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#include "peaks.h" // AudioSample (float)
#include "core/audio/peaks.h" // AudioSample (float)
namespace reasampler {
namespace reasampler::capture {
using audio::AudioSample;
// --- Layout parse ------------------------------------------------------------
// The parsed geometry of a canonical 32-bit-float WAV. `valid` is false when the
// bytes are not a WAV we can safely trim (see FORMAT ASSUMPTION); every other field
@@ -77,6 +92,8 @@ std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& byt
std::size_t startFrame,
std::size_t frameCount);
// --- Truncate plan + size-field patch ---------------------------------------
// The plan to truncate a parsed WAV to `keptFrames` frames: the new total file byte
// length and the two size-field values to patch. `valid` is false if the layout is
// invalid or keptFrames exceeds the file's frames (never GROW a file — the caller
@@ -94,8 +111,62 @@ struct WavTruncatePlan {
// Computes the truncate plan to keep exactly `keptFrames` frames of a parsed WAV.
// keptFrames == layout.frameCount() is a valid no-op plan (file unchanged). Pure +
// total. The shell applies it: patch the two size fields in the byte buffer, then
// truncate the file to newFileByteLength.
// total. The shell applies it: patch the two size fields in the byte buffer
// (patchU32LE), then truncate the file to newFileByteLength.
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames);
} // namespace reasampler
// Patches a little-endian uint32 into a byte buffer at `off` — the RIFF/data size
// fields the truncate plan names. The caller guarantees off + 4 <= bytes.size()
// (the plan's offsets came from a valid parse of the same buffer).
void patchU32LE(std::vector<std::uint8_t>& bytes, std::size_t off, std::uint32_t v);
// --- Float32 WAV build -------------------------------------------------------
// Builds a minimal canonical 32-bit-float RIFF/WAVE byte buffer from interleaved
// double samples: RIFF chunk, WAVE form, fmt chunk (tag 3 = WAVE_FORMAT_IEEE_FLOAT,
// 16-byte body), data chunk (interleaved little-endian float32). `nch` channels,
// `rate` Hz, `frameCount` frames (total samples = frameCount * nch). Each double is
// narrowed to float by cast — the bank contract is 32-bit float (see FORMAT
// ASSUMPTION above); the reduction is intentional. The output round-trips through
// parseWavLayout/extractFloatFrames. The ingest shell decodes any non-canonical
// source through REAPER's PCM_source, then writes the bank copy with this.
std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
std::size_t frameCount,
const std::vector<double>& interleaved);
// --- Content identity (dedup hashes) -----------------------------------------
// Computes a deterministic FNV-1a 64-bit content hash over `len` bytes at `data`
// and returns it as a 16-character lowercase hex string. Designed to fill
// Sample::contentHash so the confirm-on-last-reference guardrail
// (BankBook::hashReferencedElsewhere) can distinguish "no other bank holds this
// file" from "another bank holds the same file." An empty buffer returns the bare
// FNV-1a 64-bit offset basis in hex (a stable, non-empty sentinel that two empty
// files would share, but real WAV files are never empty).
std::string hashBytes(const std::uint8_t* data, std::size_t len);
// WAV-aware content hash: hashes only the audio-defining content of a 32-bit-float
// RIFF/WAVE file — the `fmt ` chunk body + the `data` chunk payload — skipping all
// other RIFF chunks (e.g. `bext` origination timestamp, `iXML`, `LIST`/`INFO`, SMED).
//
// WHY: REAPER's offline renderer embeds render-varying metadata chunks (at minimum a
// `bext` chunk containing the origination date/time) even when the format config blob
// requests no BWF metadata. Two renders of identical audio therefore differ in those
// bytes, making whole-file hashes diverge and preventing dedup collapse.
//
// DOMAIN SEPARATION: the FNV-1a input is prefixed with the tag byte 'W' (0x57) before
// the fmt/data bytes are fed in, so a content hash can never equal a whole-file
// hashBytes result for a different file of the same size.
//
// FALLBACK: if `bytes` does not parse as a valid RIFF/WAVE with both a `fmt ` and a
// `data` chunk, the function falls back to whole-file hashBytes (no prefix tag) —
// identical to calling hashBytes(bytes.data(), bytes.size()). This ensures that an
// unrecognized or malformed file still gets a non-empty hash rather than silently
// skipping dedup.
//
// Called by both capture commit paths (offline and realtime) and the ingest import
// in place of the raw hashBytes call. Walks the container with the SAME chunk
// traversal parseWavLayout uses, so hashing and decoding can never desynchronize.
std::string hashWavContent(const std::vector<std::uint8_t>& bytes);
} // namespace reasampler::capture
@@ -1,17 +1,17 @@
// master_gain.cpp — see master_gain.h. Pure math; no LICE/VST3/REAPER includes.
#include "master_gain.h"
#include "core/instrument/engine/master_gain.h"
#include "core/util/clamp01.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <limits>
namespace reasampler::vst {
namespace reasampler::instrument::engine {
namespace {
double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
} // namespace
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
double masterGainMaxLinear() { return std::pow(10.0, kMasterGainMaxDb / 20.0); }
@@ -48,4 +48,4 @@ void formatMasterGainLabel(double norm, char* buf, std::size_t len) {
std::snprintf(buf, len, "%+.1fdB", db);
}
} // namespace reasampler::vst
} // namespace reasampler::instrument::engine
@@ -19,7 +19,7 @@
#include <cstddef>
namespace reasampler::vst {
namespace reasampler::instrument::engine {
// The dB taper endpoints. norm 0 is -inf (true zero); norm just above 0 starts at the
// finite floor kMasterGainMinDb and sweeps linearly in dB to kMasterGainMaxDb at norm 1.
@@ -55,4 +55,4 @@ double masterGainNormFromLinear(double linear);
// including the terminator. Pure.
void formatMasterGainLabel(double norm, char* buf, std::size_t len);
} // namespace reasampler::vst
} // namespace reasampler::instrument::engine
@@ -20,13 +20,13 @@
// ratio the delay is frozen mid-band and no splice ever fires: a primed shifter passes the
// stream through with ZERO added latency; a silence-warmed one is a clean window delay.
#include "pitch_shift.h"
#include "core/instrument/engine/pitch_shift.h"
#include <algorithm>
#include <cmath>
#include <limits>
namespace reasampler {
namespace reasampler::instrument::engine {
namespace {
@@ -48,6 +48,7 @@ void PitchShifter::configure(std::int64_t windowFrames) {
filled_ = 0;
ratio_ = 1.0;
tailFrozen_ = false;
lastSplice_ = SpliceEvent{};
return;
}
// 2x-window ring: one window of splice-jump span plus search + fade headroom on each side.
@@ -97,6 +98,7 @@ void PitchShifter::reset() {
filled_ = 0;
ratio_ = 1.0;
tailFrozen_ = false;
lastSplice_ = SpliceEvent{};
}
void PitchShifter::freezeTail() {
@@ -146,6 +148,7 @@ void PitchShifter::prime(const AudioSample* src, std::int64_t count) {
fadeLen_ = 0;
filled_ = count;
tailFrozen_ = false; // a fresh note-on always starts with a live writer
lastSplice_ = SpliceEvent{};
// ratio_ deliberately untouched: the voice sets it per frame around the prime.
}
@@ -163,6 +166,7 @@ void PitchShifter::warm() {
fadeLen_ = 0;
filled_ = window_;
tailFrozen_ = false;
lastSplice_ = SpliceEvent{};
}
void PitchShifter::setShiftRatio(double ratio) {
@@ -197,8 +201,9 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
// search (and the +/-1-lag parabolic refinement calls at bestLag ± 1, and the interpolator's
// read-ahead) can touch is delay d + jump + maxLag + 2 (maxLag from the coarse/fine search,
// +1 for the parabola's outer ± 1 probe, +1 for the interpolator's i1 = i0+1 read-ahead),
// so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample of
// conservative margin, never out-of-range. In steady state (filled_ == ringLen_) this is
// so the tight cap is filled_ - d - maxLag_ - 2. The code uses - 1 here — one sample LOOSER
// than that derived cap (not extra margin); ring indexing wraps via modulo everywhere, so
// this never runs off the physical ring_ array. In steady state (filled_ == ringLen_) this is
// > window_ and the nominal jump is untouched; near a primed onset it shrinks the jump to
// what real history exists (still many source periods with a full-window prime). The floor of
// 1 is only reachable on the documented degenerate reset-without-prime path — garbage-tolerant.
@@ -311,11 +316,42 @@ void PitchShifter::splice(std::int64_t nominalJump, double delay) {
}
fading_ = true;
fadePos_ = 0;
// Record the decision for a linked follower channel (T1-01): the follower applies this
// verbatim so both channels share one lag and one splice schedule.
lastSplice_ = SpliceEvent{true, jump, bestLag, frac, fadeLen_};
}
AudioSample PitchShifter::process(AudioSample in) {
void PitchShifter::applySplice(const SpliceEvent& ev) {
// Follower half of the T1-01 linked lag: relocate + fade with the master's decision, no
// correlation search of our own. The master's jump was clamped against ITS filled_/delay,
// which match ours by the lockstep contract (identical configure/prime/ratio history);
// the fade length likewise derives only from shared geometry + ratio.
posB_ = posA_;
double p = posA_ - static_cast<double>(ev.jump) + static_cast<double>(ev.lag) + ev.frac;
const double len = static_cast<double>(ringLen_);
while (p < 0.0) p += len;
while (p >= len) p -= len;
posA_ = p;
fadeLen_ = std::max<std::int64_t>(1, ev.fadeLen);
fading_ = true;
fadePos_ = 0;
lastSplice_ = ev; // observable mirror (tests assert follower == master per frame)
}
AudioSample PitchShifter::process(AudioSample in) { return processImpl(in, nullptr); }
AudioSample PitchShifter::processLinked(AudioSample in, const SpliceEvent& master) {
return processImpl(in, &master);
}
AudioSample PitchShifter::processImpl(AudioSample in, const SpliceEvent* linked) {
if (window_ <= 1) return in; // pass-through (unconfigured / degenerate)
// Copy the linked decision BEFORE clearing lastSplice_ (guards a self-aliased pointer;
// 5 plain fields, negligible on the RT path).
const SpliceEvent linkedEv = linked != nullptr ? *linked : SpliceEvent{};
lastSplice_ = SpliceEvent{}; // cleared every frame; set again if this frame splices
// 1. Write the incoming sample at the write head (source rate). One more slot of the
// ring now holds valid history (capped at the ring length once it has wrapped).
// TAIL-FROZEN (GA3): the source is exhausted — `in` is padding, not stream. Write
@@ -335,6 +371,33 @@ AudioSample PitchShifter::process(AudioSample in) {
const double gNew = 0.5 * (1.0 - std::cos(kPi * t));
out = gNew * out + (1.0 - gNew) * readTap(posB_);
if (++fadePos_ >= fadeLen_) fading_ = false;
} else if (linked != nullptr) {
// 3a. FOLLOWER (T1-01): no trigger test, no search — splice exactly when and how the
// master channel did this frame. Lockstep state means our own trigger would have
// fired on the same frame; applying the master's decision keeps the two rings
// sample-aligned (one shared lag, one shared schedule).
if (linkedEv.fired) {
applySplice(linkedEv);
} else {
// Self-healing fallback (review rider): the master not firing normally means this
// channel's own trigger wouldn't fire either (lockstep). But if the processor ever
// renders a mono block mid-note, this follower channel is skipped for that block
// while the master keeps advancing — its writePos_/filled_ falls behind and, with
// only the `if (linkedEv.fired)` path above, could never resync. So check this
// follower's OWN tap distance against the safe band and splice via its own search
// when it has left [dLow_, dHigh_], exactly as the master would. Reuses splice() —
// no allocation, no new RT cost. In the normal (non-mono-block) case this branch
// never triggers: the master's trigger fires first and this whole `if` is false.
double d = static_cast<double>(writePos_) - posA_;
const double len = static_cast<double>(ringLen_);
while (d < 0.0) d += len;
while (d >= len) d -= len;
if (d <= static_cast<double>(dLow_)) {
splice(+window_, d);
} else if (d >= static_cast<double>(dHigh_)) {
splice(-window_, d);
}
}
} else {
// 3. Splice scheduling: relocate when the active tap's delay leaves the safe band.
// Up-shifts (ratio > 1) drain the delay toward 0 -> jump one window OLDER; down-
@@ -368,4 +431,4 @@ AudioSample PitchShifter::process(AudioSample in) {
return static_cast<AudioSample>(out);
}
} // namespace reasampler
} // namespace reasampler::instrument::engine
@@ -25,7 +25,7 @@
// #include <windows.h>` unconditionally, which CANNOT enter the pure sampler_core module
// (CLAUDE.md load-bearing split: NO vendor/host/SDK types; sampler_core_tests links neither
// SDK and compiles outside the DAW). So the Preserve DSP lands as route (b): a house-native
// pure module alongside peaks / wav_trim, CTest-testable, RT-disciplined. Same
// pure module alongside peaks / wav_codec, CTest-testable, RT-disciplined. Same
// PitchEngine::Preserve contract behind the seam — if WDL is ever preferred it swaps in at
// the SHELL, never in the pure core.
//
@@ -53,7 +53,7 @@
//
// PURE MODULE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes. Standard library only.
// Shares the `AudioSample` float alias from peaks (the one house precedent — sampler_core /
// wav_trim do the same).
// wav_codec does the same).
//
// RT DISCIPLINE (S16 hard constraint). `configure()` sizes the ring ONCE (off the audio
// thread, at voice allocation). `prime()` / `warm()` only copy into the pre-sized ring
@@ -66,13 +66,31 @@
#include <cstdint>
#include <vector>
#include "peaks.h" // AudioSample (float)
#include "core/audio/peaks.h" // AudioSample (float)
namespace reasampler {
namespace reasampler::instrument::engine {
using audio::AudioSample;
// The splice decision made by the most recent process()/processLinked() call — the LINKED-LAG
// stereo contract (Q-W0 T1-01). A stereo voice runs channel 0 as the MASTER (full correlation
// search) and channel 1 as the FOLLOWER: after the master's process() for a frame, the caller
// passes master.lastSplice() to the follower's processLinked() for the SAME frame, and the
// follower applies exactly this decision instead of running its own search. Both channels
// therefore share one lag and one splice schedule (standard stereo SOLA) — per-channel
// independent searches re-drew an inter-channel offset of up to +/-maxLag at every splice:
// stereo image wander at the splice cadence plus comb coloration on any mono sum.
struct SpliceEvent {
bool fired = false; // a splice was scheduled on this frame
std::int64_t jump = 0; // the CLAMPED nominal jump actually applied (signed)
std::int64_t lag = 0; // correlation best integer lag
double frac = 0.0; // parabolic sub-sample refinement, [-0.5, 0.5]
std::int64_t fadeLen = 0; // live (ratio-scaled) crossfade length chosen
};
// A per-channel time-domain splice-aligned pitch shifter. One instance transposes ONE channel;
// a stereo voice owns two — the algorithm is per-sample and channel-count agnostic, matching
// the S7 "one read head, per-channel value" idiom of the core.
// a stereo voice owns two, LINKED: channel 0 is the master, channel 1 follows its splice
// decisions via processLinked() (see SpliceEvent above) so the two rings stay sample-aligned.
//
// The default-constructed shifter is INERT: with no configure() it passes input through
// unchanged (shift ratio 1.0, empty ring), so a Varispeed voice that never touches it is
@@ -91,10 +109,13 @@ public:
// the tap on src[0] (delay == count, mid safe band at count == window()). The caller then
// feeds process() the stream CONTINUING at src[count]. Output frame 0 is src[0]: ZERO
// structural latency at every ratio, and splices always have `count` frames of real
// history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()]; pass
// the full window (pad the tail with silence yourself if the source is shorter — trailing
// silence IS the true stream there). RT-safe: bounded copy into the pre-sized ring, no
// allocation. No-op when unconfigured. The current shift ratio is left untouched.
// history to land in — the GA2 onset-gap fix. `count` is clamped to [0, window()].
// When the PLAYABLE source is shorter than one window, prime only the real span and call
// freezeTail() immediately after (Q-W0 T1-03): the GA3 machinery then recycles the real
// short tail. Do NOT pad with silence and declare it valid — padded zeros inside the ring
// are splice targets, re-creating the pre-GA2 burst/gap onset on sub-window material.
// RT-safe: bounded copy into the pre-sized ring, no allocation. No-op when unconfigured.
// The current shift ratio is left untouched.
void prime(const AudioSample* src, std::int64_t count);
// prime()-with-silence: zero the ring, park the tap one window behind the writer, and
@@ -118,6 +139,20 @@ public:
// active tap leaves its safe delay band, a correlation-aligned splice is scheduled.
AudioSample process(AudioSample in);
// FOLLOWER-mode process (Q-W0 T1-01, the stereo linked lag): identical to process()
// except the splice decision is NOT computed here — when `master.fired` is true this
// frame splices with exactly the master's jump/lag/frac/fadeLen; otherwise no splice is
// considered. The caller must process the master channel FIRST each frame and pass its
// lastSplice() here, with both shifters configured/primed/ratio'd identically — their
// ring state then advances in lockstep, so the follower's own trigger would have fired
// on the same frame anyway; skipping its search only removes the second correlation
// burst (strictly cheaper, never costlier). RT-safe: same guarantees as process().
AudioSample processLinked(AudioSample in, const SpliceEvent& master);
// The splice decision made by the most recent process()/processLinked() call (fired ==
// false when that frame spliced nothing). Feed to a follower channel's processLinked().
const SpliceEvent& lastSplice() const { return lastSplice_; }
// TAIL WIND-DOWN (GA3, 2026-07). Call when the SOURCE STREAM IS EXHAUSTED — no real frame
// remains to feed process(). Freezes the WRITE head: subsequent process() calls ignore
// their input and write nothing, but read, splice, and crossfade exactly as before over
@@ -150,8 +185,15 @@ private:
double readTap(double pos) const; // fractional ring read, linear interp
// Relocate the active tap by ~`nominalJump` frames of added delay (clamped to the filled
// span for up-jumps) and start the crossfade. `delay` is the tap's current delay behind
// the writer (the caller just computed it for the trigger test).
// the writer (the caller just computed it for the trigger test). Records the decision in
// lastSplice_ for a linked follower channel.
void splice(std::int64_t nominalJump, double delay);
// Apply a master channel's already-computed splice decision verbatim (no search) —
// the follower half of the T1-01 linked-lag contract. Mirrors it into lastSplice_.
void applySplice(const SpliceEvent& ev);
// Shared body of process()/processLinked(); `linked` null = master mode (own trigger +
// search), non-null = follower mode (splice iff linked->fired, with linked's decision).
AudioSample processImpl(AudioSample in, const SpliceEvent* linked);
std::vector<AudioSample> ring_; // delay line, length `ringLen_` == 2 * window_
std::int64_t window_ = 0; // nominal splice jump in frames; <= 1 = pass-through
@@ -176,6 +218,8 @@ private:
// its up-jump to this so no splice lands in unwritten
// silence — the GA2 onset-gap fix.
double ratio_ = 1.0; // current shift ratio (>0)
SpliceEvent lastSplice_{}; // decision of the most recent process*() frame (T1-01):
// cleared at the top of every frame, set on a splice
bool tailFrozen_ = false; // GA3 wind-down: writer frozen (source exhausted); the tap
// recycles the ring's frozen real tail, splices still
// aligned. With the writer parked, a tap drains toward it
@@ -183,4 +227,4 @@ private:
// live fade by that rate.
};
} // namespace reasampler
} // namespace reasampler::instrument::engine
@@ -1,8 +1,18 @@
// sampler_core — pure sampler engine implementation. See sampler_core.h for the
// contract and the design rationale (keymap resolution, pitch ratio, ADSR shape,
// voice allocation + stealing policy). NO VST3 / REAPER / SWELL / vendor includes.
//
// DOCUMENTED HOT-PATH EXCEPTION to the Phase Q ~600-line file ceiling (Q-W2v,
// T4-14/T4-27 — Daniel-settled 2026-07-28): this TU deliberately STAYS WHOLE.
// AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called
// per-voice-per-sample from Voice::advanceFrame, which is called per-sample from
// VoiceEngine::render — same-TU definition is what lets the compiler inline that
// stack (the build configures NO LTO). A by-class TU split would put the hottest
// inner loop across TU boundaries — the exact heuristic-(3) dispatch blowout the
// phase forbids. Do NOT "fix" this file's length; the header is split instead
// (zone_params.h carries the shared value structs).
#include "sampler_core.h"
#include "core/instrument/engine/sampler_core.h"
#include <cmath>
@@ -270,7 +280,7 @@ bool Voice::sustainLoopUsable() const {
}
void Voice::start(int note, int velocity, const SampleData& sample, int rootNote,
double keyTrack, const vst::VelocityCurve& velocityCurve,
double keyTrack, const VelocityCurve& velocityCurve,
bool declickTakeover) {
// Takeover declick (Phase S GA fix, rev 2): BEFORE any state reset, record the PRE-CUT
// REFERENCE — the last rendered output — and mark the compensation PENDING iff this
@@ -297,8 +307,7 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
// Any in-flight ramp is superseded: pending re-derives from the reference, which already
// includes the running declick's contribution via lastOut (it tracks post-declick output).
declickActive_ = false;
declickL_ = 0.0;
declickR_ = 0.0;
declickWeight_ = 0.0;
active_ = true;
releasing_ = false;
@@ -378,25 +387,49 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
const SampleLoop& loop = sample.loop;
const std::int64_t loopLen = loopWrap ? (loop.end - loop.start) : 0;
const bool stereoSample = sample.channelCount() == 2 && shiftR_.configured();
// Q-W0 T1-03: the prime may only carry PLAYABLE source. The per-frame feed stops at
// feedBound (playEnd_ for a bounded Trigger span, the sample end for Gate) and
// freezes the writer there (GA3) — but the prime used to pull a FULL window bounded
// only by frameCount: a Trigger ring held real PCM past the user's chosen stop (an
// up-shifted tap could play it, transposed, before the voice freed), and a
// shorter-than-window sample got zero padding declared as valid history (splices
// landing in silence — the pre-GA2 burst/gap onset, re-entered for sub-window
// material). So bound the prime by the same playable span and, when that span is
// shorter than a window, freeze the tail IMMEDIATELY after the prime — the GA3
// machinery then recycles the real short tail, its designed behavior. The sustain-
// loop path is unbounded by construction (the wrap keeps q inside the loop forever).
const std::int64_t primeBound =
(playMode_ == PlayMode::Trigger && playEnd_ > 0 && playEnd_ < frameCount)
? playEnd_ : frameCount;
const std::int64_t primeCount =
loopWrap ? w : std::min<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 channel 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 < w; ++i) {
for (std::int64_t i = 0; i < primeCount; ++i) {
if (loopWrap) {
while (q >= loop.end) q -= loopLen;
}
// q < frameCount holds by construction on the non-loop path (primeCount is
// bounded); the guard stays as a belt for the loop-wrap walk.
primeBuf_[static_cast<std::size_t>(i)] =
(q < frameCount) ? pcmCh[static_cast<std::size_t>(q)] : 0.0f;
++q;
}
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), w);
(ch == 0 ? shiftL_ : shiftR_).prime(primeBuf_.data(), primeCount);
if (ch == 0) p = q; // capture the end position once from channel 0's walk
}
// Per-frame feed continues at `p`, exactly one window ahead of readPos_.
// Per-frame feed continues at `p` (== the feed bound when the prime exhausted the
// playable span — advanceFrame's own exhaustion test then holds from frame 0).
feedPos_ = p;
if (!loopWrap && primeCount < w) {
// Sub-window playable span: the source is ALREADY exhausted at prime time.
shiftL_.freezeTail();
if (stereoSample) shiftR_.freezeTail();
}
}
ratio_ = baseRatio_; // seeded; advanceFrame recomputes per frame under the active engine.
}
@@ -457,8 +490,7 @@ void Voice::seedDeclick(double newOutL, double newOutR) {
// gone: the blend formula keeps every output within max(|ref|,|outₙ|) by construction.
(void)newOutL; (void)newOutR; // consumed only for the floor guard below
declickPending_ = false;
declickL_ = 1.0;
declickR_ = 1.0;
declickWeight_ = 1.0; // ONE weight for both channels (T1-09: the per-R copy was dead state)
// The reference is already clamped to ±1.0 at start() (lines in start(): the ±1 clamp
// on lastOutL_/R_ before storing into declickRefL_/R_). No secondary clamp needed here.
// Activate only when the ref itself is above the floor — if ref ≈ 0 there is nothing to blend.
@@ -512,11 +544,10 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
if (declickActive_) {
// Bounded blend at silence: outCurrent == 0, so the blend is w*(ref 0) == w*ref.
// The weight decays by kDeclickDecay each frame, floor-checked on the weight itself.
const double l = declickL_ * declickRefL_;
const double r = declickL_ * declickRefR_; // same weight for both channels
declickL_ *= kDeclickDecay;
declickR_ *= kDeclickDecay;
if (declickL_ < kDeclickFloor && declickL_ > -kDeclickFloor) {
const double l = declickWeight_ * declickRefL_;
const double r = declickWeight_ * declickRefR_; // same weight for both channels
declickWeight_ *= kDeclickDecay;
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
declickActive_ = false;
active_ = false;
}
@@ -578,15 +609,21 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
outL = shiftedL * gain;
if (stereo) {
if (haveR && shiftR_.configured()) {
// Genuine stereo: an independent shifter transposes channel 1. Each shifter is
// process()'d EXACTLY ONCE per output frame (never twice — that would advance its
// heads twice and corrupt the OLA state). Gated on haveR so a MONO sample never
// touches shiftR_ — start() only primes it for genuinely stereo samples, and a
// stale un-primed ring must not leak a previous note into this one.
// Genuine stereo (Q-W0 T1-01, linked lag): channel 1's shifter FOLLOWS channel
// 0's splice decisions via processLinked — one correlation search, one lag, one
// splice schedule for both channels (standard stereo SOLA). An independent
// per-channel search re-drew an inter-channel offset of up to +/-maxLag at
// every splice: stereo image wander at the splice cadence + mono-sum combing.
// Each shifter is still processed EXACTLY ONCE per output frame (never twice —
// that would advance its heads twice and corrupt the state). Gated on haveR so
// a MONO sample never touches shiftR_ — start() only primes it for genuinely
// stereo samples, and a stale un-primed ring must not leak a previous note.
if (exhausted) shiftR_.freezeTail();
const AudioSample feedR = feedOk ? pcmR[static_cast<std::size_t>(feedPos_)] : 0.0f;
shiftR_.setShiftRatio(shift);
outRlocal = static_cast<double>(shiftR_.process(feedR)) * gain;
outRlocal =
static_cast<double>(shiftR_.processLinked(feedR, shiftL_.lastSplice())) *
gain;
} else {
// Mono sample in stereo mode (dual-mono): shiftL_ already produced the shifted
// value from the mono feed; mirror it to R. Do NOT call shiftL_.process again
@@ -636,13 +673,12 @@ AudioSample Voice::advanceFrame(bool stereo, AudioSample& outR) {
// Inactive (the common case) costs one branch; the blend itself costs one extra subtract.
if (declickPending_) seedDeclick(outL, stereo ? outRlocal : outL);
if (declickActive_) {
const double addL = declickL_ * (declickRefL_ - outL);
const double addR = declickL_ * (declickRefR_ - (stereo ? outRlocal : outL));
const double addL = declickWeight_ * (declickRefL_ - outL);
const double addR = declickWeight_ * (declickRefR_ - (stereo ? outRlocal : outL));
outL += addL;
if (stereo) outRlocal += addR;
declickL_ *= kDeclickDecay;
declickR_ *= kDeclickDecay; // kept in sync (mirrors L — both channels share one weight)
if (declickL_ < kDeclickFloor && declickL_ > -kDeclickFloor) {
declickWeight_ *= kDeclickDecay; // one shared weight — both channels decay together
if (declickWeight_ < kDeclickFloor && declickWeight_ > -kDeclickFloor) {
declickActive_ = false;
}
}
@@ -13,7 +13,7 @@
// structurally: sampler_core_tests links neither SDK (see CMakeLists §2i).
//
// It shares the `AudioSample` float alias from peaks — the one house precedent for a
// pure module leaning on peaks for the audio-domain type (wav_trim does the same). The
// pure module leaning on peaks for the audio-domain type (wav_codec does the same). The
// S2 seam fields (root note, loop points) enter as plain int / frame-index inputs; the
// core does no file I/O — it is handed decoded sample frames and produces audio frames.
@@ -22,182 +22,24 @@
#include <cstdint>
#include <vector>
#include "peaks.h" // AudioSample (float)
#include "pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core)
#include "velocity_curve.h" // VelocityCurve (S-VIEW-9 velocity->amp transfer curve; eval at start)
#include "core/audio/peaks.h" // AudioSample (float)
#include "core/instrument/engine/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 {
// 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 };
// Q-W1 interim: the engine deps live in their sub-namespace homes now; sampler_core
// re-namespaces in its own split wave (Q-W2v).
using audio::AudioSample;
using instrument::engine::PitchShifter;
using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint;
// The instrument's per-instance 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;
}
};
// 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
@@ -232,7 +74,7 @@ struct KeyZone {
// of keyTrack), carried from PerformanceZone by resolvePerformance and eval'd ONCE in
// Voice::start (never per frame). DEFAULT flat y=1 (R10-F1 Option A) — every velocity plays at
// unity, a deliberate behavior change from the pre-r10 linear map.
vst::VelocityCurve velocityCurve = vst::VelocityCurve::flat();
VelocityCurve velocityCurve = VelocityCurve::flat();
std::size_t sampleIndex = 0; // index into Keymap::samples
};
@@ -439,7 +281,7 @@ public:
// kDeclickDecay). A fresh start never declicks.
void start(int note, int velocity, const SampleData& sample, int rootNote,
double keyTrack = 1.0,
const vst::VelocityCurve& velocityCurve = vst::VelocityCurve::flat(),
const VelocityCurve& velocityCurve = VelocityCurve::flat(),
bool declickTakeover = false);
// MONO LEGATO takeover (Phase S): re-pitch this ACTIVE voice to `note` without touching the
@@ -582,7 +424,9 @@ private:
// recent rendered output (post-gain, incl. any running declick). A takeover/steal start()
// records them as declickRef{L,R}_ (the clamped pre-cut reference) and sets declickPending_;
// the first frame rendered after the restart calls seedDeclick to arm the BOUNDED BLEND:
// outₙ = outₙ*(1w) + ref*w where w = declickL_/R_ starts at 1.0 and decays by
// outₙ = outₙ*(1w) + ref*w where w = declickWeight_ (ONE weight, deliberately shared
// by both channels so L/R can never diverge — Q-W0 T1-09 removed the dead per-R copy)
// starts at 1.0 and decays by
// kDeclickDecay each frame. This is algebraically `outₙ + w*(ref outₙ)`, so the
// boundary frame (w=1) is exactly `ref` and every subsequent output is bounded by
// max(|ref|, |outₙ|) — mid-ramp overshoot is impossible regardless of outₙ rising.
@@ -595,8 +439,7 @@ private:
bool declickActive_ = false;
double declickRefL_ = 0.0; // clamped pre-cut reference (bounded blend target)
double declickRefR_ = 0.0;
double declickL_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
double declickR_ = 0.0;
double declickWeight_ = 0.0; // blend weight w; 1.0 on seed, decays by kDeclickDecay/frame
double lastOutL_ = 0.0;
double lastOutR_ = 0.0;
@@ -1,23 +1,18 @@
// velocity_curve.cpp — see velocity_curve.h. Pure eval + editing/clamp/inverse map; no host types.
#include "velocity_curve.h"
#include "core/instrument/engine/velocity_curve.h"
#include <algorithm> // std::max, std::min, std::abs, std::stable_sort
#include <cmath> // std::fabs
#include <utility> // std::move
namespace reasampler::vst {
namespace reasampler::instrument::engine {
namespace {
double clamp(double v, double lo, double hi) {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
double clampVelocity(double v) { return clamp(v, kVelMin, kVelMax); }
double clampAmp(double a) { return clamp(a, kAmpMin, kAmpMax); }
double clampVelocity(double v) { return std::clamp(v, kVelMin, kVelMax); }
double clampAmp(double a) { return std::clamp(a, kAmpMin, kAmpMax); }
// Pixel<->box maps (mirror of envelope_edit's timeToX/levelToY). X spans the width for [0,127]; Y
// spans (height-1) rows for amp [0,1] with amp 1 at the TOP (y increases downward).
@@ -203,7 +198,7 @@ VelocityPoint VelocityCurve::movePoint(std::size_t index, double velocity, doubl
// Interior point: clamp X strictly within its immediate neighbours so it can't cross them.
const double lo = points_[index - 1].velocity;
const double hi = points_[index + 1].velocity;
newVel = clamp(clampVelocity(velocity), lo, hi);
newVel = std::clamp(clampVelocity(velocity), lo, hi);
}
points_[index] = VelocityPoint{newVel, newAmp};
return points_[index];
@@ -274,4 +269,4 @@ bool VelocityCurve::equals(const VelocityCurve& other, double eps) const {
return true;
}
} // namespace reasampler::vst
} // namespace reasampler::instrument::engine
@@ -38,7 +38,7 @@
// Rect — the future editor shell (S-VIEW-10) passes its box coords directly. Mirror of envelope_edit's
// role, but one layer lower, so the coupling stays out of the engine core.
namespace reasampler::vst {
namespace reasampler::instrument::engine {
// The MIDI velocity domain [0,127] and the amp range [0,1] — the box every point clamps into.
inline constexpr double kVelMin = 0.0;
@@ -168,4 +168,4 @@ private:
std::vector<VelocityPoint> points_;
};
} // namespace reasampler::vst
} // namespace reasampler::instrument::engine
+192
View File
@@ -0,0 +1,192 @@
#pragma once
// zone_params.h — the per-zone play-parameter VALUE STRUCTS + per-instance mode enums the
// sampler engine, the sample_map resolution layer, the ComponentState codec, and the editor
// all share (Q-W2v header split, T4-14/T4-17). Split out of sampler_core.h so a UI or codec
// TU that reads a param struct no longer recompiles when a Voice/VoiceEngine member changes.
// PURE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes — standard library + peaks only.
// The per-frame EVALUATOR classes (AdsrEnvelope / TriggerEnvelope / PitchEnvelope) and the
// engine (Keymap / Voice / VoiceEngine) stay in sampler_core.h.
#include <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
@@ -1,29 +1,22 @@
// bank_sync.cpp — see bank_sync.h. Pure; standard library only.
#include "bank_sync.h"
#include "core/instrument/map/bank_sync.h"
#include <cstdint>
#include <limits>
#include <string>
namespace reasampler::vst {
#include "core/wire/wire.h"
namespace reasampler::instrument::map {
std::int64_t parseBankGeneration(const std::string& raw) {
if (raw.empty()) return kBankGenerationAbsent;
// Whole-string, non-negative decimal parse WITHOUT exceptions or locale surprises.
// A leading '+' / '-' , any non-digit, an empty digit run, or overflow past int64 max
// all reject to the absent default (0). Manual accumulation with an overflow guard so a
// Whole-string, non-negative decimal parse WITHOUT exceptions or locale
// surprises — the shared core/wire accumulate (Q-W1, T2-01b). A leading
// '+' / '-', any non-digit, an empty string, or overflow past int64 max all
// reject to the absent default (0); the guarded accumulate means a
// pathologically long digit run can never wrap into a bogus small value.
std::int64_t value = 0;
constexpr std::int64_t kMax = std::numeric_limits<std::int64_t>::max();
for (const char c : raw) {
if (c < '0' || c > '9') return kBankGenerationAbsent; // any non-digit -> reject whole
const int digit = c - '0';
// Guard value*10 + digit against overflow before performing it.
if (value > (kMax - digit) / 10) return kBankGenerationAbsent; // would overflow -> reject
value = value * 10 + digit;
}
if (!wire::parseUnsignedDecimal(raw, value)) return kBankGenerationAbsent;
return value;
}
@@ -67,4 +60,4 @@ AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& re
return d;
}
} // namespace reasampler::vst
} // namespace reasampler::instrument::map
@@ -21,9 +21,11 @@
#include <optional>
#include <string>
#include "assignment_request.h" // AssignmentRequest (the decoded request this consumes)
#include "core/wire/assignment_request.h" // AssignmentRequest (the decoded request this consumes)
namespace reasampler::vst {
namespace reasampler::instrument::map {
using wire::AssignmentRequest;
// The S9 bank-generation "generation 0 = never stamped" default. A project saved before
// S9 shipped carries no bank_generation key; the bridge read yields an absent/empty value
@@ -102,4 +104,4 @@ AssignConsumeDecision consumeDecision(const std::optional<AssignmentRequest>& re
std::int64_t lastConsumed, bool resolves,
bool isFocusedTarget);
} // namespace reasampler::vst
} // namespace reasampler::instrument::map
@@ -1,8 +1,8 @@
// bridge_marshal.cpp — see bridge_marshal.h. Pure; no host types.
#include "bridge_marshal.h"
#include "core/instrument/map/bridge_marshal.h"
namespace reasampler::vst {
namespace reasampler::instrument::map {
std::optional<std::string> decodeGetProjExtState(int apiReturn,
const std::string& buffer) {
@@ -13,4 +13,4 @@ std::optional<std::string> decodeGetProjExtState(int apiReturn,
return buffer;
}
} // namespace reasampler::vst
} // namespace reasampler::instrument::map
@@ -4,7 +4,7 @@
// The bridge shell (reaper_bridge.cpp) resolves REAPER API functions by name over the
// host callback and invokes them; the one fiddly-and-easy-to-get-wrong part around
// GetProjExtState — interpreting its int return against the buffer it filled — is pure
// and unit-tested here. Mirror of capture_paths / wav_trim splitting the arithmetic out
// and unit-tested here. Mirror of capture_paths / wav_codec splitting the arithmetic out
// of a REAPER-facing shell.
//
// The S1 spike ALSO carried a string-scan JSON reader (extractJsonStringField) as a
@@ -21,8 +21,10 @@
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace reasampler::vst {
namespace reasampler::instrument::map {
// Interpret a GetProjExtState result: the int return value (bytes the API reports for
// the key) and the buffer it filled. Returns the value only when the API reported a
@@ -34,4 +36,8 @@ namespace reasampler::vst {
std::optional<std::string> decodeGetProjExtState(int apiReturn,
const std::string& buffer);
} // namespace reasampler::vst
// The GetProjExtState GROW-LOOP retry policy (T2-04) lived here through Q-W5; it
// was rehomed to core/wire/ext_state_read.h in Q-W6 (its consumers are 2:1
// extension-side, so it belongs on the neutral wire seam, not the instrument map).
} // namespace reasampler::instrument::map
@@ -1,495 +1,33 @@
// sample_map — pure implementation. See sample_map.h. NO VST3 / REAPER / SWELL /
// vendor includes; standard library + the pure bank_book / wav_trim / sampler_core.
// component_state_io — the ComponentState envelope + zones-payload binary codec. See
// component_state_io.h for the format ladders (envelope v1..v11, zones payload v1..v7)
// and the why-a-separate-module note (Q-W2v, T4-13 ≡ T2-07). PURE: standard library +
// the pure sample_map value types + core/wire's LE byte codec (T4-20) + velocity_curve
// + master_gain. Every wire format is FROZEN — byte-identical to the pre-split writer.
#include "sample_map.h"
#include "core/instrument/map/component_state_io.h"
#include <algorithm> // std::min
#include <cassert> // assert
#include <algorithm> // std::min (bounded curve-point reserve)
#include <cassert> // assert (v3-lift projectRate guard)
#include <cmath> // std::isfinite (v8 master-gain validation)
#include <cstring> // std::memcpy
#include <cstring> // std::memcpy (serializeSelection)
#include <utility> // std::move
#include "master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec, T4-20)
namespace reasampler {
namespace reasampler::instrument::map {
using engine::masterGainMaxLinear;
using reasampler::wire::ByteReader;
using reasampler::wire::bitsToDouble;
using reasampler::wire::doubleToBits;
using reasampler::wire::putLE;
namespace {
// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank
// stores loop points as an optional LoopPoints (both-or-neither); the core wants a
// SampleLoop with an explicit hasLoop. Absent -> no loop.
SampleLoop loopFromSample(const Sample& s) {
SampleLoop out;
if (s.loop) {
out.hasLoop = true;
out.start = s.loop->start;
out.end = s.loop->end;
}
return out;
}
// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C
// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on
// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4).
SelectedSample distill(const Sample& s) {
SelectedSample out;
out.relativePath = s.relativePath;
out.rootNote = s.rootNote ? *s.rootNote : 60;
out.loop = loopFromSample(s);
out.channelCount = s.channelCount; // capture intrinsic; 0 = unknown (older entry)
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
std::optional<SelectedSample> selectSample(const std::string& banksJson,
const std::string& sampleId) {
// POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short-
// circuit before parsing — no stored id resolves to nothing to play by design.
if (sampleId.empty()) return std::nullopt;
if (banksJson.empty()) return std::nullopt;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return std::nullopt; // malformed -> nothing to play (never throw)
// Search every bank (pool first, then named — banks() is ordinal order) for the
// stored id. A sample lives in exactly one bank, so first hit wins.
for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(sampleId)) {
return distill(*s);
}
}
// A stale stored id (no longer resolves) is SILENCE, not a substituted first sample:
// the editor reflects the missing pick with its empty state rather than masking it.
return std::nullopt;
}
ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit) {
if (isExplicit) return current; // user's explicit choice is never fought
if (channelCount <= 0) return current; // unknown (0) or pathological -> no change
return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono;
}
// --- Instance-owned sample references (pS self-contained playback) -------------
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId) {
if (sampleId.empty()) return nullptr;
for (const SampleRefEntry& e : refs) {
if (e.sampleId == sampleId) return &e.ref;
}
return nullptr;
}
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
const PerformanceMap& map) {
std::vector<std::string> ids;
const auto addUnique = [&ids](const std::string& id) {
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;
}
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
const std::vector<std::string>& ids) {
if (ids.empty() || banksJson.empty()) return;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return; // malformed blob -> no-op (the instance keeps its own copies)
for (const std::string& id : ids) {
const Sample* found = nullptr;
for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(id)) { found = s; break; }
}
if (!found) continue; // bank miss: NEVER strips a ref — the instance owns its copy
const SelectedSample distilled = distill(*found);
bool updated = false;
for (SampleRefEntry& e : refs) {
if (e.sampleId == id) {
e.ref = distilled;
e.displayName = found->displayName; // rename sync rides the same refresh
updated = true;
break;
}
}
if (!updated) refs.push_back(SampleRefEntry{id, distilled, found->displayName});
}
}
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids) {
if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry;
const std::optional<BankBook> book = BankBook::deserialize(*banksJson);
if (!book) return LegacyLiftDecision::Retry; // present but unparseable: not readable YET
for (const std::string& id : ids) {
for (const Bank& b : book->banks()) {
if (b.index.query(id)) return LegacyLiftDecision::Lift;
}
}
// The blob parses and knows none of the referenced ids (or there are none): provably
// stale — a lift can never make progress against this bank.
return LegacyLiftDecision::Stale;
}
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids) {
refs.erase(std::remove_if(refs.begin(), refs.end(),
[&ids](const SampleRefEntry& e) {
for (const std::string& id : ids) {
if (id == e.sampleId) return false;
}
return true;
}),
refs.end());
}
std::vector<SampleChoice> listSamples(const std::string& banksJson) {
std::vector<SampleChoice> out;
if (banksJson.empty()) return out;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return out;
for (const Bank& b : book->banks()) {
for (const Sample& s : b.index.all()) {
out.push_back(SampleChoice{s.id, s.displayName, s.rootNote, s.key, b.id});
}
}
return out;
}
std::vector<BankChoice> listBanks(const std::string& banksJson) {
std::vector<BankChoice> out;
if (banksJson.empty()) return out;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return out;
for (const Bank& b : book->banks()) {
out.push_back(BankChoice{b.id, b.displayName});
}
return out;
}
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
int channelCount) {
std::vector<AudioSample> out;
if (channelCount <= 0 || interleaved.empty()) return out;
const std::size_t stride = static_cast<std::size_t>(channelCount);
const std::size_t frames = interleaved.size() / stride;
out.resize(frames);
const double inv = 1.0 / static_cast<double>(channelCount);
for (std::size_t f = 0; f < frames; ++f) {
double acc = 0.0;
const std::size_t base = f * stride;
for (std::size_t c = 0; c < stride; ++c) {
acc += static_cast<double>(interleaved[base + c]);
}
out[f] = static_cast<AudioSample>(acc * inv);
}
return out;
}
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which) {
std::vector<AudioSample> out;
if (channelCount <= 0 || interleaved.empty()) return out;
const std::size_t stride = static_cast<std::size_t>(channelCount);
// Clamp the requested channel into the source's range: a channel past the last one reads
// the last channel (a mono source asked for channel 1 yields channel 0 — dual-mono).
std::size_t ch = which < 0 ? 0 : static_cast<std::size_t>(which);
if (ch >= stride) ch = stride - 1;
const std::size_t frames = interleaved.size() / stride;
out.resize(frames);
for (std::size_t f = 0; f < frames; ++f) out[f] = interleaved[f * stride + ch];
return out;
}
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate) {
assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)");
DecodedZonePcm out;
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
out.sampleRate = sampleRate;
if (mode == ChannelMode::Mono) {
// MONO mode: the existing downmix policy (average all source channels), one channel out.
out.monoFrames = downmixToMono(interleaved, sourceChannels);
return out; // framesR stays empty
}
// STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0
// duplicated when the source is mono (dual-mono, centered). extractChannel clamps the
// out-of-range channel request to the last channel, so a mono source yields L == R.
out.monoFrames = extractChannel(interleaved, sourceChannels, 0);
out.framesR = extractChannel(interleaved, sourceChannels, 1);
return out;
}
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
// seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R,
// pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry
// through untouched — they are already source frames / fractions. Non-time fields pass as-is.
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
const auto secToFrames = [sr](double sec) {
double f = sec * sr;
if (f < 0.0) f = 0.0;
return static_cast<std::int64_t>(f + 0.5);
};
ZonePlayParams out;
out.playMode = stored.playMode;
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds);
out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds);
out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time
out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds);
out.trigger = stored.trigger; // source-frame / fraction, unchanged
out.pitchEngine = stored.pitchEngine;
out.pitchEnv.enabled = stored.pitchEnv.enabled;
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
return out;
}
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR, const ZonePlaySeconds& play) {
assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)");
SampleData data;
data.frames = std::move(frames);
// A second channel only counts when it length-matches channel 0 (else the sample stays
// mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays).
if (!framesR.empty() && framesR.size() == data.frames.size()) {
data.framesR = std::move(framesR);
}
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()
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
if (!decoded[i].framesR.empty() &&
decoded[i].framesR.size() == data.frames.size()) {
data.framesR = decoded[i].framesR;
}
assert(decoded[i].sampleRate > 0 &&
"buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)");
if (decoded[i].sampleRate <= 0) continue; // safe skip; assert fires first
data.sampleRate = decoded[i].sampleRate;
data.rootNote = zones[i].rootNote;
data.loop = zones[i].loop;
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
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
data.play = resolvePlay(zones[i].play, data.sampleRate);
const std::size_t sampleIndex = km.samples.size();
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)
}
// --- Performance-map instance state (setState/getState) -----------------------
namespace {
void putU32le(std::vector<std::uint8_t>& out, std::uint32_t v) {
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
}
// 64-bit little-endian, for the S11 loop start/end + start frame (int64 on the wire as
// two's-complement u64, mirroring the u32 signed-int idiom above).
void putU64le(std::vector<std::uint8_t>& out, std::uint64_t v) {
for (int b = 0; b < 8; ++b) out.push_back(static_cast<std::uint8_t>((v >> (b * 8)) & 0xFF));
}
// 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); }
// IEEE-754 double <-> u64 bit-cast for the wire (memcpy is the only defined type-pun in C++).
// Used for the S15/S16 trigger.lengthFraction + pitchEnv.peakSemitones fields.
std::uint64_t doubleToBits(double d) {
std::uint64_t bits;
std::memcpy(&bits, &d, sizeof(bits));
return bits;
}
double bitsToDouble(std::uint64_t bits) {
double d;
std::memcpy(&d, &bits, sizeof(d));
return d;
}
// A bounded little-endian reader over a byte blob. Every read is length-checked; once a
// read runs past the end the reader latches `ok=false` and yields zeros, so a truncated
// blob degrades to a partial/empty parse rather than reading out of bounds.
struct ByteReader {
const std::vector<std::uint8_t>& bytes;
std::size_t pos = 0;
bool ok = true;
explicit ByteReader(const std::vector<std::uint8_t>& b) : bytes(b) {}
std::uint32_t u32() {
if (!ok || pos + 4 > bytes.size()) { ok = false; return 0; }
const std::uint32_t v = static_cast<std::uint32_t>(bytes[pos]) |
(static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
(static_cast<std::uint32_t>(bytes[pos + 3]) << 24);
pos += 4;
return v;
}
std::uint8_t u8() {
if (!ok || pos + 1 > bytes.size()) { ok = false; return 0; }
return bytes[pos++];
}
std::string str(std::uint32_t len) {
if (!ok || pos + len > bytes.size()) { ok = false; return {}; }
std::string s(reinterpret_cast<const char*>(bytes.data() + pos), len);
pos += len;
return s;
}
// Signed ints go on the wire as u32 two's-complement (fixed 32-bit width).
int i32() { return static_cast<int>(static_cast<std::int32_t>(u32())); }
std::uint64_t u64() {
if (!ok || pos + 8 > bytes.size()) { ok = false; return 0; }
std::uint64_t v = 0;
for (int b = 0; b < 8; ++b)
v |= static_cast<std::uint64_t>(bytes[pos + static_cast<std::size_t>(b)]) << (b * 8);
pos += 8;
return v;
}
// Signed 64-bit frame indices go on the wire as u64 two's-complement (fixed width).
std::int64_t i64() { return static_cast<std::int64_t>(u64()); }
// Non-consuming peek of the next u32 (for the zones-payload format-marker probe). Yields
// 0 and latches nothing when fewer than 4 bytes remain — the caller treats a short blob
// as "no marker" and falls through to the (also-guarded) v1 count read.
std::uint32_t peekU32() const {
if (!ok || pos + 4 > bytes.size()) return 0;
return static_cast<std::uint32_t>(bytes[pos]) |
(static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
(static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
(static_cast<std::uint32_t>(bytes[pos + 3]) << 24);
}
};
// Append the zones payload — the shared body of the performance blob and the component blob,
// so both write zones identically. Always emits the CURRENT PAYLOAD version (kZonesPayloadVersion
// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail
@@ -498,57 +36,57 @@ struct ByteReader {
// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip
// through EITHER envelope with no envelope bump.
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
putU32le(out, kZonesFormatMarker);
putU32le(out, kZonesPayloadVersion);
putU32le(out, static_cast<std::uint32_t>(map.zones.size()));
putLE(out, kZonesFormatMarker);
putLE(out, kZonesPayloadVersion);
putLE(out, static_cast<std::uint32_t>(map.zones.size()));
for (const PerformanceZone& z : map.zones) {
putU32le(out, static_cast<std::uint32_t>(z.sampleId.size()));
putLE(out, static_cast<std::uint32_t>(z.sampleId.size()));
out.insert(out.end(), z.sampleId.begin(), z.sampleId.end());
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.lowNote)));
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.highNote)));
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) {
putU32le(out,
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);
putU64le(out, asU64(z.loopOverride->start));
putU64le(out, asU64(z.loopOverride->end));
putLE(out, asU64(z.loopOverride->start));
putLE(out, asU64(z.loopOverride->end));
}
out.push_back(z.startPoint ? 1 : 0);
if (z.startPoint) putU64le(out, asU64(*z.startPoint));
if (z.startPoint) putLE(out, asU64(*z.startPoint));
// S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine).
// Wall-clock times are SECONDS (doubles); trigger %-length + fades stay source frames /
// fraction. Order matches the header's v5 record spec.
const ZonePlaySeconds& pp = z.play;
out.push_back(pp.playMode == PlayMode::Trigger ? 1 : 0);
putU64le(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
putU64le(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
putU64le(out, asU64(pp.trigger.fadeInFrames)); // source frames
putU64le(out, asU64(pp.trigger.fadeOutFrames)); // source frames
putLE(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds
putLE(out, doubleToBits(pp.trigger.lengthFraction)); // fraction
putLE(out, asU64(pp.trigger.fadeInFrames)); // source frames
putLE(out, asU64(pp.trigger.fadeOutFrames)); // source frames
out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0);
out.push_back(pp.pitchEnv.enabled ? 1 : 0);
putU64le(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
putU64le(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
putLE(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds
putLE(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds
putLE(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth
// Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level).
putU64le(out, doubleToBits(pp.adsr.attackSeconds));
putU64le(out, doubleToBits(pp.adsr.decaySeconds));
putU64le(out, doubleToBits(pp.adsr.sustainLevel));
putU64le(out, doubleToBits(pp.adsr.releaseSeconds));
putLE(out, doubleToBits(pp.adsr.attackSeconds));
putLE(out, doubleToBits(pp.adsr.decaySeconds));
putLE(out, doubleToBits(pp.adsr.sustainLevel));
putLE(out, doubleToBits(pp.adsr.releaseSeconds));
// PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar (1.0 = 100% ET).
putU64le(out, doubleToBits(z.keyTrack));
putLE(out, doubleToBits(z.keyTrack));
// PAYLOAD v7 (S-VIEW-9): the per-zone velocity->amp transfer curve, appended last. 4-byte LE
// control-point count, then per point velocity + amp as IEEE-754 doubles (endpoints included).
const std::vector<reasampler::vst::VelocityPoint>& pts = z.velocityCurve.points();
putU32le(out, static_cast<std::uint32_t>(pts.size()));
for (const reasampler::vst::VelocityPoint& p : pts) {
putU64le(out, doubleToBits(p.velocity));
putU64le(out, doubleToBits(p.amp));
const std::vector<VelocityPoint>& pts = z.velocityCurve.points();
putLE(out, static_cast<std::uint32_t>(pts.size()));
for (const VelocityPoint& p : pts) {
putLE(out, doubleToBits(p.velocity));
putLE(out, doubleToBits(p.amp));
}
}
}
@@ -642,7 +180,7 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
// false mid-curve) leaves the flat default and the mid-zone break below drops the rest.
if (curveTail) {
const std::uint32_t ptCount = r.u32();
std::vector<reasampler::vst::VelocityPoint> pts;
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.
@@ -651,9 +189,9 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) {
const double vel = bitsToDouble(r.u64());
const double amp = bitsToDouble(r.u64());
pts.push_back(reasampler::vst::VelocityPoint{vel, amp});
pts.push_back(VelocityPoint{vel, amp});
}
if (r.ok) z.velocityCurve = reasampler::vst::VelocityCurve::fromPoints(std::move(pts));
if (r.ok) z.velocityCurve = reasampler::instrument::engine::VelocityCurve::fromPoints(std::move(pts));
}
// Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
// seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
@@ -666,7 +204,7 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) {
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
std::vector<std::uint8_t> out;
putU32le(out, kPerformanceStateVersion);
putLE(out, kPerformanceStateVersion);
putZonesPayload(out, map);
return out;
}
@@ -704,13 +242,13 @@ PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
std::vector<std::uint8_t> out;
putU32le(out, kComponentStateVersion);
putLE(out, kComponentStateVersion);
// v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body.
out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0);
// v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE
// two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that
// stops at the mode byte is a strict prefix (see the v4 lift below).
putU64le(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
// the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift).
out.push_back(state.previewVelocity);
@@ -729,10 +267,10 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
// negative falls back to unity; above the +24 dB cap clamps to the cap.
{
double g = state.masterGainLinear;
const double maxLin = vst::masterGainMaxLinear();
const double maxLin = masterGainMaxLinear();
if (!std::isfinite(g) || g < 0.0) g = 1.0;
if (g > maxLin) g = maxLin;
putU64le(out, doubleToBits(g));
putLE(out, doubleToBits(g));
}
// v9 envelope addition (GA channel-mode auto-default): the channel-mode-EXPLICIT flag,
// 1 byte, following the gain double so a v8 blob is a strict prefix up to here (see the
@@ -744,29 +282,29 @@ std::vector<std::uint8_t> serializeComponentState(const ComponentState& state) {
// the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per
// entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always
// written), channelCount, displayName (length-prefixed; display-only).
putU32le(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
putLE(out, static_cast<std::uint32_t>(state.sampleRefs.size()));
for (const SampleRefEntry& e : state.sampleRefs) {
putU32le(out, static_cast<std::uint32_t>(e.sampleId.size()));
putLE(out, static_cast<std::uint32_t>(e.sampleId.size()));
out.insert(out.end(), e.sampleId.begin(), e.sampleId.end());
putU32le(out, static_cast<std::uint32_t>(e.ref.relativePath.size()));
putLE(out, static_cast<std::uint32_t>(e.ref.relativePath.size()));
out.insert(out.end(), e.ref.relativePath.begin(), e.ref.relativePath.end());
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(e.ref.rootNote)));
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(e.ref.rootNote)));
out.push_back(e.ref.loop.hasLoop ? 1 : 0);
putU64le(out, asU64(e.ref.loop.start));
putU64le(out, asU64(e.ref.loop.end));
putU32le(out,
putLE(out, asU64(e.ref.loop.start));
putLE(out, asU64(e.ref.loop.end));
putLE(out,
static_cast<std::uint32_t>(static_cast<std::int32_t>(e.ref.channelCount)));
putU32le(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());
}
// v11 envelope addition (pS-usage instance identity): the minted per-instance guid,
// length-prefixed, following the refs table so a v10 blob is a strict prefix up to
// here (see the v10 lift). Empty = never published — legal, round-trips as empty.
putU32le(out, static_cast<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());
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
// unlike the v1 selection blob where the id ran to end-of-stream).
putU32le(out, static_cast<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());
putZonesPayload(out, state.map);
return out;
@@ -887,7 +425,7 @@ ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
if (!r.ok) return out; // truncated inside the gain double — out already carries
// mode/marker/velocity/voice fields from above; unity holds
out.masterGainLinear =
(std::isfinite(g) && g >= 0.0 && g <= vst::masterGainMaxLinear() * (1.0 + 1e-9))
(std::isfinite(g) && g >= 0.0 && g <= masterGainMaxLinear() * (1.0 + 1e-9))
? g
: 1.0;
}
@@ -967,4 +505,4 @@ std::string deserializeSelection(const std::vector<std::uint8_t>& bytes) {
bytes.size() - 4);
}
} // namespace reasampler
} // namespace reasampler::instrument::map
@@ -0,0 +1,326 @@
#pragma once
// component_state_io — the ComponentState ENVELOPE + zones-payload binary codec for the
// ReaSampler 9000 instrument (Q-W2v split out of sample_map, T4-13 ≡ T2-07). PURE: NO
// VST3, NO REAPER, NO SWELL, NO vendor/ includes — the same boundary sample_map keeps.
//
// WHY A SEPARATE MODULE. The codec grows on EVERY ComponentState envelope bump (v6→v11
// in one quarter), and it is deliberately shared across BOTH artifacts: the instrument's
// processor reads/writes it at setState/getState, and the EXTENSION's instrument-drop
// path (core/wire/instrument_drop) serializes the same bytes into a transient .vstpreset
// so the payload and the instrument's reader can never drift. Housing it inside
// sample_map made the extension link the whole voice engine (sampler_core + pitch_shift)
// to serialize one preset blob; split out, both artifacts link the codec and only the
// VST links the engine. The codec's own links are velocity_curve + master_gain (wire
// value validation) — never the engine.
//
// EVERY wire format below is FROZEN (byte-identical to the pre-split writer); the full
// version ladders (envelope v1..v11, zones payload v1..v7) are preserved exactly.
#include <cstdint>
#include <string>
#include <vector>
#include "core/instrument/map/sample_map.h" // PerformanceMap / SampleRefs / SelectedSample (+ zone_params via sampler_core)
namespace reasampler::instrument::map {
// --- Performance-map instance state (VST3 setState/getState) -----------------
//
// The performance map is the instrument's OWN state (D-B), serialized to the VST3
// component-state IBStream — NOT written to the "reasampler" bank ext-state (the
// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of
// truncation/wrong-version by design (bounded reads, never throws across the host).
//
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
// ZONES PAYLOAD.
//
// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones
// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides)
// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState
// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the
// key composition property: the zone-record extension is versioned inside the map blob, not on
// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not
// collide on a single version number.
// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone:
// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote,
// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride).
// A payload starting with a small u32 (the zone count) is v1 — there is no marker.
// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone
// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended
// to each zone record after rootOverride:
// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start,
// 8-byte LE loop.end (both two's-complement int64);
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64).
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope.
// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload
// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint
// tail (the S15/S16 per-zone play params — always present, NOT flag-gated):
// 1 byte playMode (0 = Gate, 1 = Trigger);
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal;
// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE);
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve);
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom);
// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double.
// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve +
// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved
// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest.
// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS
// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds
// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed
// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames.
// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060).
// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5),
// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full
// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles):
// 1 byte playMode (0 = Gate, 1 = Trigger);
// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double);
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
// 1 byte pitchEngine; 1 byte pitchEnv.enabled;
// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double);
// 8-byte LE pitchEnv.peakSemitones (double);
// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double);
// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double).
// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4
// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader
// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds
// to frames at the LIVE sample rate; no rate is baked into storage or the program.
// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is
// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved
// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes
// to an EMPTY map.
//
// These two functions serialize the ZONES only. Since S10 the instrument's full component
// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState
// below, the v3 format the processor actually reads/writes. serializePerformance/
// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift.
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9).
// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 —
// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock
// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides
// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker +
// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts)
// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The
// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice,
// always tiny) can never collide with.
// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the
// full v5 play-params tail:
// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET).
// A v1v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone
// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the
// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
// transfer curve appended to each zone record after the v6 keyTrack field:
// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp
// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2.
// A v1v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1
// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change:
// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A
// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed.
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are
// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a
// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap build
// already receives, so the seconds domain is consistent across both paths. No constant is baked in.
// The performance map serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
// The performance map parsed back from IBStream bytes (setState). A v2 blob parses
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
double projectRate);
// --- Combined component state (VST3 setState/getState, v3 — S10) -------------
//
// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that
// BOTH persist: the default face is one picked capture (the selection id), and zones are a
// demoted opt-in overlay (the performance map). The component state carries both so a saved
// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
// state), never auto-playing sample #1.
//
// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono,
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a
// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system
// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono
// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754
// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte
// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the
// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the
// instance-owned path + intrinsics + display name per referenced sample; wire shape at
// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE
// length + guid bytes; the minted per-instance identity the usage publisher keys its
// "rsusage_<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
// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user
// already got and then manually changed away from: on re-open the instance re-reads the pending
// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign
// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the
// bank — the extension owns the assign_request key; the instrument only tracks what it consumed.
// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no
// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default.
inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
struct ComponentState {
std::string selectionId; // the single-capture pick; "" = no pick
PerformanceMap map; // the opt-in zones; empty = no zones
ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E)
// GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle).
// While false (implicit), the shell auto-defaults the mode from the loaded capture's
// channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the
// user's choice is never fought. Pre-v9 blobs lift to false (implicit).
bool channelModeExplicit = false;
std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed
// S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling
// of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's
// chosen strike velocity across saves. Defaults to kPreviewVelocityDefault.
std::uint8_t previewVelocity = kPreviewVelocityDefault;
// Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an
// older blob lifting to these plays byte-identically.
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
// FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity;
// up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output
// trim applied by process() AFTER the voice sum (engine + drain + preview) — never per
// voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically,
// so an older blob lifting to 1.0 plays exactly as it did.
double masterGainLinear = 1.0;
// pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics
// for every bank sample this instance plays (see the SampleRefs block above). setState
// decodes straight from these; NO bridge/extension read is required for playback. A
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
// path once (then re-saves self-contained).
SampleRefs sampleRefs;
// pS-usage (v11): the minted per-instance identity the usage publisher keys its
// "rsusage_<guid>" ext-state record under (see sample_usage.h — the prune-protection
// seam). Persisted so the key is stable across sessions (records do not proliferate
// per reopen). Empty = never published (a fresh or pre-v11 instance); the processor
// mints one on first publish, and RE-mints when the publish plan detects this state
// was cloned onto another track (FX copy / track duplication — planUsagePublish).
std::string instanceGuid;
};
inline constexpr std::uint32_t kComponentStateVersion = 11;
// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed
// after the refs table). Mirrors the v10/v9/… series so the version branches in
// deserializeComponentState stay self-describing.
inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table).
// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection
// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE
// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop,
// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of
// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length +
// displayName bytes (display-only; the editor label's extension-absent fallback).
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode
// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8;
// The GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the
// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9;
// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker +
// preview velocity + voice system, no master gain). Retained so deserializeComponentState can
// lift a v7 blob to unity master gain.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7;
// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker +
// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a
// v6 blob to the voice defaults {16, Poly, Retrigger}.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6;
// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no
// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity.
inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5;
// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed
// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named
// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}.
inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
// The full instance state serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state);
// The full instance state parsed back from IBStream bytes (setState). Tolerant of
// truncation/wrong-version (bounded reads, never throws); older blobs lift per the table
// above so already-saved instances restore cleanly.
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
double projectRate);
// --- Instance state (VST3 setState/getState) --------------------------------
//
// The instrument's OWN state is which bank sample it plays (D-B: the selection is a
// performance choice, held by the instrument, never written back to the bank). It is a
// single string id. serialize/deserialize keep the on-the-wire form explicit and
// versioned so a future Tier can extend it without breaking already-saved instances.
//
// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No
// length prefix is needed — the id runs to the end of the stream (the host tells us the
// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob
// by returning "" (no selection — under the S10 policy reversal an empty selection is
// SILENCE + the "pick a capture" empty state, not the bank's first sample), never
// throwing across the host boundary. Retained for the v1→v3 back-compat lift in
// deserializeComponentState; the processor's live state is the v3 ComponentState above.
inline constexpr std::uint32_t kSelectionStateVersion = 1;
// The selected-sample id serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId);
// The selected-sample id parsed back from IBStream bytes (setState). Unknown version,
// too-short, or empty -> "" (graceful no-selection).
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
} // namespace reasampler::instrument::map
@@ -1,11 +1,11 @@
// note_entry.cpp — see note_entry.h. PURE text->MIDI-note parse for the S12 numeric entry.
#include "note_entry.h"
#include "core/instrument/map/note_entry.h"
#include <algorithm>
#include <cctype>
namespace reasampler::vst {
namespace reasampler::instrument::map {
namespace {
char asciiUpper(char c) {
@@ -110,4 +110,4 @@ std::optional<int> parseNoteEntry(const std::string& text) {
return parseNoteName(s);
}
} // namespace reasampler::vst
} // namespace reasampler::instrument::map
@@ -22,7 +22,7 @@
#include <optional>
#include <string>
namespace reasampler::vst {
namespace reasampler::instrument::map {
// Parse a typed low/high/root field into a clamped MIDI note [0,127]. Accepts a decimal
// integer OR a note name (see the header notes). Leading/trailing ASCII whitespace is
@@ -30,4 +30,4 @@ namespace reasampler::vst {
// into [0,127]; empty or unparseable input returns nullopt (no change). Pure — no host types.
std::optional<int> parseNoteEntry(const std::string& text);
} // namespace reasampler::vst
} // namespace reasampler::instrument::map
+404
View File
@@ -0,0 +1,404 @@
// sample_map — pure implementation (the RESOLUTION half; the ComponentState codec
// lives in component_state_io.cpp since Q-W2v). See sample_map.h. NO VST3 / REAPER /
// SWELL / vendor includes; standard library + the pure bank_book / wav_codec / sampler_core.
#include "core/instrument/map/sample_map.h"
#include <algorithm> // std::min
#include <cassert> // assert
#include <utility> // std::move
namespace reasampler::instrument::map {
namespace {
// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank
// stores loop points as an optional LoopPoints (both-or-neither); the core wants a
// SampleLoop with an explicit hasLoop. Absent -> no loop.
SampleLoop loopFromSample(const Sample& s) {
SampleLoop out;
if (s.loop) {
out.hasLoop = true;
out.start = s.loop->start;
out.end = s.loop->end;
}
return out;
}
// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C
// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on
// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4).
SelectedSample distill(const Sample& s) {
SelectedSample out;
out.relativePath = s.relativePath;
out.rootNote = s.rootNote ? *s.rootNote : 60;
out.loop = loopFromSample(s);
out.channelCount = s.channelCount; // capture intrinsic; 0 = unknown (older entry)
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
std::optional<SelectedSample> selectSample(const std::string& banksJson,
const std::string& sampleId) {
// POLICY REVERSAL (S10): an empty selection is SILENCE, not the first sample. Short-
// circuit before parsing — no stored id resolves to nothing to play by design.
if (sampleId.empty()) return std::nullopt;
if (banksJson.empty()) return std::nullopt;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return std::nullopt; // malformed -> nothing to play (never throw)
// Search every bank (pool first, then named — banks() is ordinal order) for the
// stored id. A sample lives in exactly one bank, so first hit wins.
for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(sampleId)) {
return distill(*s);
}
}
// A stale stored id (no longer resolves) is SILENCE, not a substituted first sample:
// the editor reflects the missing pick with its empty state rather than masking it.
return std::nullopt;
}
ChannelMode channelModeFor(int channelCount, ChannelMode current, bool isExplicit) {
if (isExplicit) return current; // user's explicit choice is never fought
if (channelCount <= 0) return current; // unknown (0) or pathological -> no change
return channelCount >= 2 ? ChannelMode::Stereo : ChannelMode::Mono;
}
// --- Instance-owned sample references (pS self-contained playback) -------------
const SelectedSample* findRef(const SampleRefs& refs, const std::string& sampleId) {
if (sampleId.empty()) return nullptr;
for (const SampleRefEntry& e : refs) {
if (e.sampleId == sampleId) return &e.ref;
}
return nullptr;
}
std::vector<std::string> referencedSampleIds(const std::string& selectionId,
const PerformanceMap& map) {
std::vector<std::string> ids;
const auto addUnique = [&ids](const std::string& id) {
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;
}
void refreshRefsFromBank(SampleRefs& refs, const std::string& banksJson,
const std::vector<std::string>& ids) {
if (ids.empty() || banksJson.empty()) return;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return; // malformed blob -> no-op (the instance keeps its own copies)
for (const std::string& id : ids) {
const Sample* found = nullptr;
for (const Bank& b : book->banks()) {
if (const Sample* s = b.index.query(id)) { found = s; break; }
}
if (!found) continue; // bank miss: NEVER strips a ref — the instance owns its copy
const SelectedSample distilled = distill(*found);
bool updated = false;
for (SampleRefEntry& e : refs) {
if (e.sampleId == id) {
e.ref = distilled;
e.displayName = found->displayName; // rename sync rides the same refresh
updated = true;
break;
}
}
if (!updated) refs.push_back(SampleRefEntry{id, distilled, found->displayName});
}
}
LegacyLiftDecision legacyLiftDecision(const std::optional<std::string>& banksJson,
const std::vector<std::string>& ids) {
if (!banksJson || banksJson->empty()) return LegacyLiftDecision::Retry;
const std::optional<BankBook> book = BankBook::deserialize(*banksJson);
if (!book) return LegacyLiftDecision::Retry; // present but unparseable: not readable YET
for (const std::string& id : ids) {
for (const Bank& b : book->banks()) {
if (b.index.query(id)) return LegacyLiftDecision::Lift;
}
}
// The blob parses and knows none of the referenced ids (or there are none): provably
// stale — a lift can never make progress against this bank.
return LegacyLiftDecision::Stale;
}
void retainRefs(SampleRefs& refs, const std::vector<std::string>& ids) {
refs.erase(std::remove_if(refs.begin(), refs.end(),
[&ids](const SampleRefEntry& e) {
for (const std::string& id : ids) {
if (id == e.sampleId) return false;
}
return true;
}),
refs.end());
}
std::vector<SampleChoice> listSamples(const std::string& banksJson) {
std::vector<SampleChoice> out;
if (banksJson.empty()) return out;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return out;
for (const Bank& b : book->banks()) {
for (const Sample& s : b.index.all()) {
out.push_back(SampleChoice{s.id, s.displayName, s.rootNote, s.key, b.id});
}
}
return out;
}
std::vector<BankChoice> listBanks(const std::string& banksJson) {
std::vector<BankChoice> out;
if (banksJson.empty()) return out;
std::optional<BankBook> book = BankBook::deserialize(banksJson);
if (!book) return out;
for (const Bank& b : book->banks()) {
out.push_back(BankChoice{b.id, b.displayName});
}
return out;
}
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
int channelCount) {
std::vector<AudioSample> out;
if (channelCount <= 0 || interleaved.empty()) return out;
const std::size_t stride = static_cast<std::size_t>(channelCount);
const std::size_t frames = interleaved.size() / stride;
out.resize(frames);
const double inv = 1.0 / static_cast<double>(channelCount);
for (std::size_t f = 0; f < frames; ++f) {
double acc = 0.0;
const std::size_t base = f * stride;
for (std::size_t c = 0; c < stride; ++c) {
acc += static_cast<double>(interleaved[base + c]);
}
out[f] = static_cast<AudioSample>(acc * inv);
}
return out;
}
std::vector<AudioSample> extractChannel(const std::vector<AudioSample>& interleaved,
int channelCount, int which) {
std::vector<AudioSample> out;
if (channelCount <= 0 || interleaved.empty()) return out;
const std::size_t stride = static_cast<std::size_t>(channelCount);
// Clamp the requested channel into the source's range: a channel past the last one reads
// the last channel (a mono source asked for channel 1 yields channel 0 — dual-mono).
std::size_t ch = which < 0 ? 0 : static_cast<std::size_t>(which);
if (ch >= stride) ch = stride - 1;
const std::size_t frames = interleaved.size() / stride;
out.resize(frames);
for (std::size_t f = 0; f < frames; ++f) out[f] = interleaved[f * stride + ch];
return out;
}
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate) {
assert(sampleRate > 0 && "decodeChannels: sampleRate must be > 0 (programming error)");
DecodedZonePcm out;
if (sampleRate <= 0) return out; // safe early-return; caller supplied an invalid rate
out.sampleRate = sampleRate;
if (mode == ChannelMode::Mono) {
// MONO mode: the existing downmix policy (average all source channels), one channel out.
out.monoFrames = downmixToMono(interleaved, sourceChannels);
return out; // framesR stays empty
}
// STEREO mode: channel 0 = source channel 0; channel 1 = source channel 1, or channel 0
// duplicated when the source is mono (dual-mono, centered). extractChannel clamps the
// out-of-range channel request to the last channel, so a mono source yields L == R.
out.monoFrames = extractChannel(interleaved, sourceChannels, 0);
out.framesR = extractChannel(interleaved, sourceChannels, 1);
return out;
}
ZonePlayParams resolvePlay(const ZonePlaySeconds& stored, int sampleRate) {
// seconds -> frames at the LIVE rate (round-to-nearest). Wall-clock quantities (AHDSR A/H/D/R,
// pitch env A/D) resolve here; source-timeline quantities (trigger %-length + fades) carry
// through untouched — they are already source frames / fractions. Non-time fields pass as-is.
assert(sampleRate > 0 && "resolvePlay: sampleRate must be > 0 (programming error)");
const double sr = sampleRate > 0 ? static_cast<double>(sampleRate) : 1.0; // 1.0 avoids div-by-zero; assert fires first
const auto secToFrames = [sr](double sec) {
double f = sec * sr;
if (f < 0.0) f = 0.0;
return static_cast<std::int64_t>(f + 0.5);
};
ZonePlayParams out;
out.playMode = stored.playMode;
out.adsr.attackFrames = secToFrames(stored.adsr.attackSeconds);
out.adsr.holdFrames = secToFrames(stored.adsr.holdSeconds);
out.adsr.decayFrames = secToFrames(stored.adsr.decaySeconds);
out.adsr.sustainLevel = stored.adsr.sustainLevel; // level, not a time
out.adsr.releaseFrames = secToFrames(stored.adsr.releaseSeconds);
out.trigger = stored.trigger; // source-frame / fraction, unchanged
out.pitchEngine = stored.pitchEngine;
out.pitchEnv.enabled = stored.pitchEnv.enabled;
out.pitchEnv.attackFrames = secToFrames(stored.pitchEnv.attackSeconds);
out.pitchEnv.decayFrames = secToFrames(stored.pitchEnv.decaySeconds);
out.pitchEnv.peakSemitones = stored.pitchEnv.peakSemitones; // depth, not a time
return out;
}
Keymap buildTier0Keymap(std::vector<AudioSample> frames, int sampleRate,
int rootNote, const SampleLoop& loop,
std::vector<AudioSample> framesR, const ZonePlaySeconds& play) {
assert(sampleRate > 0 && "buildTier0Keymap: sampleRate must be > 0 (programming error)");
SampleData data;
data.frames = std::move(frames);
// A second channel only counts when it length-matches channel 0 (else the sample stays
// mono — SampleData::channelCount() enforces the same rule, so a bad pair never half-plays).
if (!framesR.empty() && framesR.size() == data.frames.size()) {
data.framesR = std::move(framesR);
}
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()
// enforces the same rule; a mismatched pair falls back to mono rather than half-play).
if (!decoded[i].framesR.empty() &&
decoded[i].framesR.size() == data.frames.size()) {
data.framesR = decoded[i].framesR;
}
assert(decoded[i].sampleRate > 0 &&
"buildZonedKeymap: DecodedZonePcm::sampleRate must be > 0 (programming error)");
if (decoded[i].sampleRate <= 0) continue; // safe skip; assert fires first
data.sampleRate = decoded[i].sampleRate;
data.rootNote = zones[i].rootNote;
data.loop = zones[i].loop;
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
// actual rate; source-timeline params (trigger %-length + fades, start) carry through.
data.play = resolvePlay(zones[i].play, data.sampleRate);
const std::size_t sampleIndex = km.samples.size();
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
@@ -3,7 +3,7 @@
// "reasampler" bank ext-state + a decoded WAV into the plain data the sampler core
// plays, and (de)serialize the instance's selected-sample choice for VST3 component
// state. NO VST3, NO REAPER, NO SWELL, NO vendor/ includes at the boundary — the
// mirror of capture_paths / wav_trim / bridge_marshal splitting the fiddly, testable
// mirror of capture_paths / wav_codec / bridge_marshal splitting the fiddly, testable
// arithmetic out of a host-facing shell.
//
// WHY IT EXISTS (S4 seams). The instrument reads the bank over the live-state seam
@@ -14,7 +14,7 @@
// downmix its decoded PCM to the core's mono contract, and build the Tier-0 chromatic
// Keymap — is pure and unit-tested here.
//
// It links bank_book (the shared BankBook::deserialize) and wav_trim (the shared
// It links bank_book (the shared BankBook::deserialize) and wav_codec (the shared
// 32-bit-float WAV parse — no third WAV reader) and sampler_core (the Keymap /
// SampleData it produces). All three are pure; this stays pure.
@@ -23,11 +23,17 @@
#include <string>
#include <vector>
#include "bank_book.h" // BankBook::deserialize (shared bank JSON parse)
#include "sampler_core.h" // Keymap, SampleData, SampleLoop
#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
#include "core/model/bank_book.h" // BankBook::deserialize (shared bank JSON parse)
#include "core/instrument/engine/sampler_core.h" // Keymap, SampleData, SampleLoop
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
namespace reasampler {
namespace reasampler::instrument::map {
// Cross-subsystem deps by their real namespace homes (Q-W2v: sample_map now lives in
// instrument::map; the engine family stays in flat `reasampler` until its own wave).
using audio::AudioSample;
using instrument::engine::VelocityCurve;
using instrument::engine::VelocityPoint;
// The bank sample this instance is bound to, distilled from the live "banks" blob:
// the project-relative WAV path the file seam must resolve+decode, plus the S2 bank
@@ -161,7 +167,7 @@ struct BankChoice {
};
std::vector<BankChoice> listBanks(const std::string& banksJson);
// Downmix interleaved float frames (the shape wav_trim::extractFloatFrames yields:
// Downmix interleaved float frames (the shape wav_codec's extractFloatFrames yields:
// [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per
// frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0,
// documented + surfaced): the S3 core is mono-per-sample by design; bank WAVs preserve
@@ -287,7 +293,7 @@ struct PerformanceZone {
// already-saved zone's soft hits play LOUDER than under the old linear map. Intended; do NOT
// preserve the linear response. Carried to KeyZone by resolvePerformance, eval'd in Voice::start.
// Sequenced on the zones-payload axis AFTER keyTrack (payload v6 -> v7).
vst::VelocityCurve velocityCurve = vst::VelocityCurve::flat();
VelocityCurve velocityCurve = VelocityCurve::flat();
// S15/S16 per-zone play parameters (play mode + AHDSR + Trigger %-length/fades; pitch
// engine + AD pitch envelope). Instrument-owned (D-B), never a bank fact — mirror of the
@@ -341,7 +347,7 @@ struct ResolvedZone {
int highNote = 127;
int rootNote = 60; // effective: override, else bank intrinsic, else 60
double keyTrack = 1.0; // S-VIEW-6 key-tracking scalar, carried from PerformanceZone (1.0 = 100% ET)
vst::VelocityCurve velocityCurve = vst::VelocityCurve::flat(); // S-VIEW-9 velocity->amp curve, carried from PerformanceZone
VelocityCurve velocityCurve = VelocityCurve::flat(); // S-VIEW-9 velocity->amp curve, carried from PerformanceZone
SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11)
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11)
ZonePlaySeconds play; // S15/S16 per-zone play params (SECONDS; resolved to frames at build)
@@ -407,302 +413,10 @@ Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& interleaved,
int sourceChannels, ChannelMode mode, int sampleRate);
// --- Performance-map instance state (VST3 setState/getState) -----------------
//
// The performance map is the instrument's OWN state (D-B), serialized to the VST3
// component-state IBStream — NOT written to the "reasampler" bank ext-state (the
// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of
// truncation/wrong-version by design (bounded reads, never throws across the host).
//
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
// ZONES PAYLOAD.
//
// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones
// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides)
// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState
// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the
// key composition property: the zone-record extension is versioned inside the map blob, not on
// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not
// collide on a single version number.
// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone:
// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote,
// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride).
// A payload starting with a small u32 (the zone count) is v1 — there is no marker.
// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone
// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended
// to each zone record after rootOverride:
// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start,
// 8-byte LE loop.end (both two's-complement int64);
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64).
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope.
// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload
// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint
// tail (the S15/S16 per-zone play params — always present, NOT flag-gated):
// 1 byte playMode (0 = Gate, 1 = Trigger);
// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal;
// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE);
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve);
// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom);
// 8-byte LE pitchEnv.decayFrames (int64, FRAMES 44.1k nom); 8-byte LE peakSemitones double.
// A v1/v2 payload (no v3 tail) lifts each zone to the PRODUCT defaults (Gate + Preserve +
// no fades + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved
// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest.
// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS
// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds
// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed
// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames.
// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060).
// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5),
// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full
// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles):
// 1 byte playMode (0 = Gate, 1 = Trigger);
// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double);
// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64);
// 1 byte pitchEngine; 1 byte pitchEnv.enabled;
// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double);
// 8-byte LE pitchEnv.peakSemitones (double);
// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double);
// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double).
// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4
// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader
// — a v4 blob cannot exist outside this branch. The keymap builders resolve the stored seconds
// to frames at the LIVE sample rate; no rate is baked into storage or the program.
// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is
// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved
// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes
// to an EMPTY map.
//
// These two functions serialize the ZONES only. Since S10 the instrument's full component
// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState
// below, the v3 format the processor actually reads/writes. serializePerformance/
// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift.
// The ComponentState envelope + zones-payload binary codec (serializePerformance /
// serializeComponentState / serializeSelection + the deserializers and every version
// constant) lives in component_state_io.h (Q-W2v split, T4-13 ≡ T2-07): the codec grows
// on every envelope bump and is consumed by the EXTENSION's preset-blob path too — the
// split lets both artifacts share the codec while only the VST links the voice engine.
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9).
// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 —
// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock
// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides
// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker +
// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with wall-clock frame counts)
// for back-compat, lifting missing fields to defaults. v4 was never shipped and is not read. The
// marker is a high sentinel that a legitimate zone count (bounded by 128 MIDI zones in practice,
// always tiny) can never collide with.
// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the
// full v5 play-params tail:
// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET).
// A v1v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone
// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the
// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
// transfer curve appended to each zone record after the v6 keyTrack field:
// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp
// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2.
// A v1v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1
// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change:
// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A
// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed.
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are
// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a
// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap build
// already receives, so the seconds domain is consistent across both paths. No constant is baked in.
// The performance map serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
// The performance map parsed back from IBStream bytes (setState). A v2 blob parses
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
double projectRate);
// --- Combined component state (VST3 setState/getState, v3 — S10) -------------
//
// Since S10 the single-capture SELECTION and the opt-in ZONES are distinct concepts that
// BOTH persist: the default face is one picked capture (the selection id), and zones are a
// demoted opt-in overlay (the performance map). The component state carries both so a saved
// project restores an instance's pick AND its zones — and, per the S10 policy reversal, an
// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty
// state), never auto-playing sample #1.
//
// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono,
// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a
// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system
// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono
// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754
// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte
// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the
// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the
// instance-owned path + intrinsics + display name per referenced sample; wire shape at
// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 4-byte LE
// length + guid bytes; the minted per-instance identity the usage publisher keys its
// "rsusage_<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
// the disambiguator that stops a re-opened instance re-applying a stale assign_request the user
// already got and then manually changed away from: on re-open the instance re-reads the pending
// request, and only a generation STRICTLY GREATER than this stored marker re-applies (see
// bank_sync::consumeDecision). A fresh instance defaults to 0, so a genuinely new first assign
// (generation >= 1) still applies. It is the instrument's OWN state (D-B), never written to the
// bank — the extension owns the assign_request key; the instrument only tracks what it consumed.
// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no
// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default.
inline constexpr std::uint8_t kPreviewVelocityDefault = 64;
struct ComponentState {
std::string selectionId; // the single-capture pick; "" = no pick
PerformanceMap map; // the opt-in zones; empty = no zones
ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E)
// GA (v9): whether channelMode was DELIBERATELY set by the user (the editor toggle).
// While false (implicit), the shell auto-defaults the mode from the loaded capture's
// channel count on reload (stereo capture -> Stereo, mono -> Mono); once true, the
// user's choice is never fought. Pre-v9 blobs lift to false (implicit).
bool channelModeExplicit = false;
std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed
// S-VIEW-4 preview-trigger velocity (MIDI 1..127): a PER-INSTANCE performance choice (sibling
// of channelMode, NOT per-zone), persisted so the Sample-view preview button retains the user's
// chosen strike velocity across saves. Defaults to kPreviewVelocityDefault.
std::uint8_t previewVelocity = kPreviewVelocityDefault;
// Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT
// per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S behavior exactly, so an
// older blob lifting to these plays byte-identically.
int voiceCount = kDefaultVoiceCount; // polyphony bound, kMinVoiceCount..kMaxVoiceCount
VoiceMode voiceMode = VoiceMode::Poly; // Poly | Mono (last-note-priority held stack)
MonoTrigger monoTrigger = MonoTrigger::Retrigger; // mono takeover: Retrigger | Legato
// FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity;
// up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output
// trim applied by process() AFTER the voice sum (engine + drain + preview) — never per
// voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically,
// so an older blob lifting to 1.0 plays exactly as it did.
double masterGainLinear = 1.0;
// pS self-contained playback (v10): the instance-OWNED sample refs — path + intrinsics
// for every bank sample this instance plays (see the SampleRefs block above). setState
// decodes straight from these; NO bridge/extension read is required for playback. A
// pre-v10 blob lifts to an EMPTY table, and the shell falls back to the bridge-resolve
// path once (then re-saves self-contained).
SampleRefs sampleRefs;
// pS-usage (v11): the minted per-instance identity the usage publisher keys its
// "rsusage_<guid>" ext-state record under (see sample_usage.h — the prune-protection
// seam). Persisted so the key is stable across sessions (records do not proliferate
// per reopen). Empty = never published (a fresh or pre-v11 instance); the processor
// mints one on first publish, and RE-mints when the publish plan detects this state
// was cloned onto another track (FX copy / track duplication — planUsagePublish).
std::string instanceGuid;
};
inline constexpr std::uint32_t kComponentStateVersion = 11;
// The pS-usage combined-state version (v10 + the minted instance guid, length-prefixed
// after the refs table). Mirrors the v10/v9/… series so the version branches in
// deserializeComponentState stay self-describing.
inline constexpr std::uint32_t kSelectionZonesRefsIdentityV11Version = 11;
// The pS self-contained combined-state version (v9 + the instance-owned sample-refs table).
// Wire shape of the refs block (inserted after the v9 explicit flag, before the selection
// id): 4-byte LE entry count, then per entry: 4-byte LE id length + id bytes, 4-byte LE
// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop,
// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of
// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length +
// displayName bytes (display-only; the editor label's extension-absent fallback).
inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10;
// The pre-GA combined-state version (everything through the FB1 master gain, no channel-mode
// explicit flag). Retained so deserializeComponentState can lift a v8 blob to implicit mode.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainV8Version = 8;
// The GA combined-state version (v8 + the channel-mode-EXPLICIT flag). Mirrors the
// v8/v7/v6/… series so the v9-branch check in deserializeComponentState is self-describing.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version = 9;
// The pre-FB1 combined-state version (selection + zones + channel mode + consumed marker +
// preview velocity + voice system, no master gain). Retained so deserializeComponentState can
// lift a v7 blob to unity master gain.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelVoiceV7Version = 7;
// The pre-Phase-S combined-state version (selection + zones + channel mode + consumed marker +
// preview velocity, no voice-system fields). Retained so deserializeComponentState can lift a
// v6 blob to the voice defaults {16, Poly, Retrigger}.
inline constexpr std::uint32_t kSelectionZonesModeMarkerVelV6Version = 6;
// The pre-S-VIEW-4 combined-state version (selection + zones + channel mode + consumed marker, no
// preview velocity). Retained so deserializeComponentState can lift a v5 blob to a mid velocity.
inline constexpr std::uint32_t kSelectionZonesModeMarkerV5Version = 5;
// The pre-S8/S9-reader combined-state version (selection + zones + channel mode, no consumed
// marker). Retained so deserializeComponentState can lift a v4 blob to {mode, 0, sel, zones}.
inline constexpr std::uint32_t kSelectionZonesModeV4Version = 4;
// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named
// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}.
inline constexpr std::uint32_t kSelectionZonesV3Version = 3;
// The full instance state serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializeComponentState(const ComponentState& state);
// The full instance state parsed back from IBStream bytes (setState). Tolerant of
// truncation/wrong-version (bounded reads, never throws); older blobs lift per the table
// above so already-saved instances restore cleanly.
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& bytes,
double projectRate);
// --- Instance state (VST3 setState/getState) --------------------------------
//
// The instrument's OWN state is which bank sample it plays (D-B: the selection is a
// performance choice, held by the instrument, never written back to the bank). It is a
// single string id. serialize/deserialize keep the on-the-wire form explicit and
// versioned so a future Tier can extend it without breaking already-saved instances.
//
// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No
// length prefix is needed — the id runs to the end of the stream (the host tells us the
// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob
// by returning "" (no selection — under the S10 policy reversal an empty selection is
// SILENCE + the "pick a capture" empty state, not the bank's first sample), never
// throwing across the host boundary. Retained for the v1→v3 back-compat lift in
// deserializeComponentState; the processor's live state is the v3 ComponentState above.
inline constexpr std::uint32_t kSelectionStateVersion = 1;
// The selected-sample id serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId);
// The selected-sample id parsed back from IBStream bytes (setState). Unknown version,
// too-short, or empty -> "" (graceful no-selection).
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
} // namespace reasampler
} // namespace reasampler::instrument::map
@@ -1,10 +1,10 @@
// trigger_seam.cpp — PURE Trigger-mode frames↔fraction converter (see trigger_seam.h).
#include "trigger_seam.h"
#include "core/instrument/map/trigger_seam.h"
#include <algorithm>
namespace reasampler::vst {
namespace reasampler::instrument::map {
std::int64_t triggerPlayLength(double lengthFraction,
std::int64_t frameCount,
@@ -24,4 +24,4 @@ std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength)
return static_cast<std::int64_t>(fadeFraction * static_cast<double>(playLength) + 0.5);
}
} // namespace reasampler::vst
} // namespace reasampler::instrument::map
@@ -25,7 +25,7 @@
#include <cstdint>
namespace reasampler::vst {
namespace reasampler::instrument::map {
// The source-frame length of the Trigger played span:
// postStart = max(0, frameCount - startFrame)
@@ -48,4 +48,4 @@ double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength);
// Rounds to nearest integer frame. Returns 0 when playLength == 0.
std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength);
} // namespace reasampler::vst
} // namespace reasampler::instrument::map
@@ -1,12 +1,14 @@
// browser_scroll.cpp — see browser_scroll.h. PURE scroll + search geometry over the S10
// capture_browser. No host types; only the shared Rect + BrowserLayout.
#include "browser_scroll.h"
#include "core/instrument/ui/browser_scroll.h"
#include "core/instrument/ui/editor_geometry.h" // kPad / kTitleHeight / kNavButtonWidth (Q-W2v hoist)
#include <algorithm>
#include <cctype>
namespace reasampler::vst {
namespace reasampler::instrument::ui {
namespace {
// The minimum thumb height so a very long bank still yields a grabbable thumb.
@@ -26,7 +28,7 @@ int scrollContentHeight(const BrowserLayout& layout, int cardCount) {
int scrollMaxOffset(const BrowserLayout& layout, int cardCount) {
const int content = scrollContentHeight(layout, cardCount);
const int gridH = (std::max)(0, layout.grid.height());
const int gridH = (std::max)(0, layout.grid.height);
return (std::max)(0, content - gridH);
}
@@ -41,7 +43,7 @@ VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int of
VisibleRange vr;
if (cardCount <= 0) return vr;
const int columns = (std::max)(1, layout.columns);
const int gridH = (std::max)(0, layout.grid.height());
const int gridH = (std::max)(0, layout.grid.height);
if (gridH <= 0 || kBrowserCardHeight <= 0) {
vr.first = 0;
vr.last = 0;
@@ -66,21 +68,21 @@ VisibleRange visibleCardRange(const BrowserLayout& layout, int cardCount, int of
Rect scrolledCardCellRect(const BrowserLayout& layout, int index, int offset) {
Rect r = cardCellRect(layout, index);
if (r.right <= r.left && r.bottom <= r.top) return r; // empty (negative index) stays empty
return Rect{r.left, r.top - offset, r.right, r.bottom - offset};
if (r.right() <= r.x && r.bottom() <= r.y) return r; // empty (negative index) stays empty
return Rect::ltrb(r.x, r.y - offset, r.right(), r.bottom() - offset);
}
Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) {
const int content = scrollContentHeight(layout, cardCount);
const int gridH = (std::max)(0, layout.grid.height());
const int gridH = (std::max)(0, layout.grid.height);
if (content <= gridH || gridH <= 0) return Rect{}; // fits -> no scrollbar
const int maxOff = content - gridH;
if (offset < 0) offset = 0;
if (offset > maxOff) offset = maxOff;
const int trackRight = layout.grid.right;
const int trackRight = layout.grid.right();
const int trackLeft = trackRight - kScrollbarWidth;
const int trackTop = layout.grid.top;
const int trackTop = layout.grid.y;
// Thumb height proportional to the visible fraction, floored at a grabbable minimum but
// never taller than the track.
@@ -95,13 +97,13 @@ Rect scrollThumbRect(const BrowserLayout& layout, int cardCount, int offset) {
thumbTop = trackTop + static_cast<int>(
static_cast<long long>(offset) * trackSpan / maxOff);
}
return Rect{trackLeft, thumbTop, trackRight, thumbTop + thumbH};
return Rect::ltrb(trackLeft, thumbTop, trackRight, thumbTop + thumbH);
}
int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffset,
int dyPixels) {
const int content = scrollContentHeight(layout, cardCount);
const int gridH = (std::max)(0, layout.grid.height());
const int gridH = (std::max)(0, layout.grid.height);
if (content <= gridH || gridH <= 0) return clampScrollOffset(layout, cardCount, startOffset);
// Thumb height (same formula as scrollThumbRect) -> movable track span in thumb pixels.
@@ -124,7 +126,7 @@ int thumbDragToOffset(const BrowserLayout& layout, int cardCount, int startOffse
Rect searchBoxRect(int w) {
if (w <= 0) return Rect{};
return Rect{0, 0, w, kSearchBoxHeight};
return Rect::ltrb(0, 0, w, kSearchBoxHeight);
}
bool nameMatchesQuery(const std::string& name, const std::string& query) {
@@ -155,4 +157,25 @@ std::vector<int> filterNameIndices(const std::vector<std::string>& names,
return out;
}
} // namespace reasampler::vst
// The Browse-modal regions (hoisted from the editor shell, Q-W2v/T2-06 — body verbatim;
// the band metrics come from editor_geometry, the search height from searchBoxRect).
BrowseModal computeBrowseModal(int w, int h) {
constexpr int kBrowseFooterH = 30;
BrowseModal m;
const int titleH = (std::min)(kTitleHeight, h);
m.title = Rect::ltrb(0, 0, w, titleH);
m.back = Rect::ltrb(w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, titleH - 2));
// Search box below the title, spanning the width (searchBoxRect lays it out from 0).
const Rect sb = searchBoxRect(w);
m.search = Rect::ltrb(kPad, titleH, w - kPad, titleH + sb.height);
const int footerTop = (std::max)(m.search.bottom(), h - kBrowseFooterH);
m.content = Rect::ltrb(0, m.search.bottom(), w, footerTop);
// Footer: Cancel (left) + Load (right).
const int fTop = footerTop + 3;
const int fBot = (std::max)(fTop, h - 3);
m.cancel = Rect::ltrb(kPad, fTop, kPad + 90, fBot);
m.confirm = Rect::ltrb(w - kPad - 90, fTop, w - kPad, fBot);
return m;
}
} // namespace reasampler::instrument::ui
@@ -24,9 +24,9 @@
#include <string>
#include <vector>
#include "capture_browser.h" // BrowserLayout, cardCellRect, kBrowserCardHeight, Rect
#include "core/instrument/ui/capture_browser.h" // BrowserLayout, cardCellRect, kBrowserCardHeight, Rect
namespace reasampler::vst {
namespace reasampler::instrument::ui {
// The width (px) of the vertical scrollbar gutter at the right edge of the grid. The shell
// draws the track + thumb here and hit-tests thumb grabs against scrollThumbRect. Exposed so
@@ -104,4 +104,21 @@ bool nameMatchesQuery(const std::string& name, const std::string& query);
std::vector<int> filterNameIndices(const std::vector<std::string>& names,
const std::string& query);
} // namespace reasampler::vst
// --- The Browse-modal (S-VIEW-5) top-level regions (Q-W2v hoist, T2-06) -------
//
// A title band with a Back button, the search box, the browser sub-area (tabs + card
// grid — layoutBrowser's origin), and a footer with Cancel / Load-confirm. The picker
// covers the full window (F3: full-window overlay). Draw + hit-test both derive from
// this single layout so they never drift. Homed here (not editor_geometry) because the
// search-box height feeds it — browser_scroll already owns the search/scroll geometry.
struct BrowseModal {
Rect title;
Rect back; // the "Back" title-band button
Rect search; // the type-to-filter box (absolute)
Rect content; // the browser sub-area (tabs + grid) — layoutBrowser's origin
Rect cancel; // footer Cancel
Rect confirm; // footer Load (confirm)
};
BrowseModal computeBrowseModal(int w, int h);
} // namespace reasampler::instrument::ui
@@ -1,10 +1,10 @@
// capture_browser.cpp — see capture_browser.h. Pure math; no host types.
#include "capture_browser.h"
#include "core/instrument/ui/capture_browser.h"
#include <algorithm>
namespace reasampler::vst {
namespace reasampler::instrument::ui {
namespace {
@@ -23,10 +23,10 @@ BrowserLayout layoutBrowser(int w, int h) {
BrowserLayout out;
const int tabH = std::min(kBrowserTabHeight, ch);
out.tabStrip = Rect{0, 0, cw, tabH};
out.grid = Rect{0, tabH, cw, ch};
out.tabStrip = Rect::ltrb(0, 0, cw, tabH);
out.grid = Rect::ltrb(0, tabH, cw, ch);
const int gridW = std::max(0, out.grid.width());
const int gridW = std::max(0, out.grid.width);
out.columns = std::max(1, gridW / kBrowserCardWidth);
return out;
}
@@ -36,38 +36,38 @@ Rect cardCellRect(const BrowserLayout& layout, int index) {
const int cols = std::max(1, layout.columns);
const int col = index % cols;
const int row = index / cols;
const int left = layout.grid.left + col * kBrowserCardWidth;
const int top = layout.grid.top + row * kBrowserCardHeight;
return Rect{left, top, left + kBrowserCardWidth, top + kBrowserCardHeight};
const int left = layout.grid.x + col * kBrowserCardWidth;
const int top = layout.grid.y + row * kBrowserCardHeight;
return Rect::ltrb(left, top, left + kBrowserCardWidth, top + kBrowserCardHeight);
}
Rect cardContentRect(const BrowserLayout& layout, int index) {
if (index < 0) return Rect{};
const Rect cell = cardCellRect(layout, index);
return Rect{cell.left + kBrowserCardGutter, cell.top + kBrowserCardGutter,
cell.right - kBrowserCardGutter, cell.bottom - kBrowserCardGutter};
return Rect::ltrb(cell.x + kBrowserCardGutter, cell.y + kBrowserCardGutter,
cell.right() - kBrowserCardGutter, cell.bottom() - kBrowserCardGutter);
}
Rect cardThumbnailRect(const BrowserLayout& layout, int index) {
if (index < 0) return Rect{};
const Rect content = cardContentRect(layout, index);
const int thumbH = std::min(kBrowserThumbHeight, std::max(0, content.height()));
return Rect{content.left, content.top, content.right, content.top + thumbH};
const int thumbH = std::min(kBrowserThumbHeight, std::max(0, content.height));
return Rect::ltrb(content.x, content.y, content.right(), content.y + thumbH);
}
Rect cardLabelRect(const BrowserLayout& layout, int index) {
if (index < 0) return Rect{};
const Rect content = cardContentRect(layout, index);
const Rect thumb = cardThumbnailRect(layout, index);
return Rect{content.left, thumb.bottom, content.right, content.bottom};
return Rect::ltrb(content.x, thumb.bottom(), content.right(), content.bottom());
}
int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y) {
if (cardCount <= 0) return -1;
if (!contains(layout.grid, x, y)) return -1;
const int cols = std::max(1, layout.columns);
const int col = (x - layout.grid.left) / kBrowserCardWidth;
const int row = (y - layout.grid.top) / kBrowserCardHeight;
const int col = (x - layout.grid.x) / kBrowserCardWidth;
const int row = (y - layout.grid.y) / kBrowserCardHeight;
if (col < 0 || col >= cols) return -1; // past the last column (right dead-zone)
const int index = row * cols + col;
if (index < 0 || index >= cardCount) return -1;
@@ -79,9 +79,9 @@ int cardHitTest(const BrowserLayout& layout, int cardCount, int x, int y) {
Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index) {
if (tabCount <= 0 || index < 0 || index >= tabCount) return Rect{};
const Rect& strip = layout.tabStrip;
const int left = tabEdge(strip.left, std::max(0, strip.width()), index, tabCount);
const int right = tabEdge(strip.left, std::max(0, strip.width()), index + 1, tabCount);
return Rect{left, strip.top, right, strip.bottom};
const int left = tabEdge(strip.x, std::max(0, strip.width), index, tabCount);
const int right = tabEdge(strip.x, std::max(0, strip.width), index + 1, tabCount);
return Rect::ltrb(left, strip.y, right, strip.bottom());
}
int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y) {
@@ -93,4 +93,4 @@ int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y) {
return -1;
}
} // namespace reasampler::vst
} // namespace reasampler::instrument::ui
@@ -20,9 +20,9 @@
#pragma once
#include "editor_geometry.h" // Rect, contains — one shared geometry idiom
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
namespace reasampler::vst {
namespace reasampler::instrument::ui {
// Fixed browser metrics, exposed so the shell and tests agree. The card is sized to show a
// peak thumbnail with a name + badge line under it — scannable by eye, not a dense list.
@@ -37,12 +37,12 @@ inline constexpr int kBrowserThumbHeight = 44; // the peak-thumbnail band inside
struct BrowserLayout {
Rect tabStrip; // top: the bank-filter tabs
Rect grid; // below the tabs: where the capture cards tile
int columns = 1; // cards per row in `grid` (>= 1); derived from grid.width()
int columns = 1; // cards per row in `grid` (>= 1); derived from grid.width
};
// Divide a (w x h) browser area into its regions and compute the column count. Pure: same
// inputs -> same layout. The tab strip takes a fixed height at the top (clamped so it never
// exceeds the area); the grid takes the rest. columns = max(1, grid.width()/cardWidth) so a
// exceeds the area); the grid takes the rest. columns = max(1, grid.width/cardWidth) so a
// browser narrower than one card still lays out a single column. A zero/negative size
// yields empty rects + columns==1.
BrowserLayout layoutBrowser(int w, int h);
@@ -89,4 +89,4 @@ Rect filterTabRect(const BrowserLayout& layout, int tabCount, int index);
// tab strip. Pure.
int filterTabHitTest(const BrowserLayout& layout, int tabCount, int x, int y);
} // namespace reasampler::vst
} // namespace reasampler::instrument::ui
+41
View File
@@ -0,0 +1,41 @@
// curve_popup.cpp — see curve_popup.h. Pure arithmetic; no LICE/VST3/REAPER includes.
#include "core/instrument/ui/curve_popup.h"
#include <algorithm>
namespace reasampler::instrument::ui {
namespace {
int clampDim(int want, int lo, int hi, int windowDim) {
const int clamped = (std::max)(lo, (std::min)(hi, want));
return (std::min)(clamped, (std::max)(0, windowDim));
}
} // namespace
CurvePopupLayout computeCurvePopup(int w, int h) {
CurvePopupLayout out;
const int sheetW = clampDim((w * 60) / 100, kCurvePopupMinW, kCurvePopupMaxW, w);
const int sheetH = clampDim((h * 55) / 100, kCurvePopupMinH, kCurvePopupMaxH, h);
const int left = (w - sheetW) / 2;
const int top = (h - sheetH) / 2;
out.sheet = Rect::ltrb(left, top, left + sheetW, top + sheetH);
const int titleBottom = out.sheet.y + kCurvePopupTitleH;
const int closeTop = out.sheet.y + (kCurvePopupTitleH - kCurvePopupCloseSize) / 2;
out.close = Rect::ltrb(out.sheet.right() - kCurvePopupPad - kCurvePopupCloseSize, closeTop,
out.sheet.right() - kCurvePopupPad, closeTop + kCurvePopupCloseSize);
out.title = Rect::ltrb(out.sheet.x + kCurvePopupPad, out.sheet.y,
out.close.x - kCurvePopupPad, titleBottom);
out.curveBox = Rect::ltrb(out.sheet.x + kCurvePopupPad, titleBottom + 2,
out.sheet.right() - kCurvePopupPad,
out.sheet.bottom() - kCurvePopupPad);
return out;
}
bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y) {
return !contains(layout.sheet, x, y);
}
} // namespace reasampler::instrument::ui
@@ -15,9 +15,9 @@
#pragma once
#include "editor_geometry.h" // Rect, contains
#include "core/instrument/ui/editor_geometry.h" // Rect, contains
namespace reasampler::vst {
namespace reasampler::instrument::ui {
// Fixed popup metrics (spec r11), exposed so the shell and tests agree.
inline constexpr int kCurvePopupMinW = 360;
@@ -45,4 +45,4 @@ CurvePopupLayout computeCurvePopup(int w, int h);
// The shell additionally gates on "no drag in flight" (spec). Pure.
bool popupOutsideSheet(const CurvePopupLayout& layout, int x, int y);
} // namespace reasampler::vst
} // namespace reasampler::instrument::ui
+308
View File
@@ -0,0 +1,308 @@
// 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
@@ -12,24 +12,17 @@
#pragma once
namespace reasampler::vst {
#include "core/ui/rect.h"
// A plain integer rectangle. left/top inclusive, right/bottom exclusive — the same
// half-open convention LICE/SWELL RECTs use, kept REAPER-free here.
struct Rect {
int left = 0;
int top = 0;
int right = 0;
int bottom = 0;
namespace reasampler::instrument::ui {
int width() const { return right - left; }
int height() const { return bottom - top; }
};
// Returns true if (x, y) falls inside r under the half-open convention
// (left <= x < right, top <= y < bottom). A zero-or-negative-area rect contains
// nothing.
bool contains(const Rect& r, int x, int y);
// The shared pixel rectangle + containment test (Q-W1, T2-05 ≡ T4-21): the former
// LTRB Rect defined here is folded into the ONE concrete ui::Rect (XYWH storage,
// right()/bottom() accessors, Rect::ltrb() for edge-wise construction, same
// half-open convention). Aliased here so every instrument-ui call site keeps its
// established `Rect` / `contains` spelling.
using Rect = ::reasampler::ui::Rect;
using ::reasampler::ui::contains;
// The regions the spike editor draws, derived from the current view size. All are
// clamped to the client area so a degenerate (too-small) view never yields a region
@@ -93,7 +86,7 @@ inline constexpr int kAddZoneHeight = 22; // the "Add Zone" button band heig
// The keymap editor's regions, derived from the (w x h) client area. All clamp to the
// canvas so a degenerate view yields in-bounds rects.
struct KeymapEditorLayout {
EditorLayout base; // title bar + canvas (the sample list uses base.canvas.left half)
EditorLayout base; // title bar + canvas (the sample list uses base.canvas.x half)
Rect sampleList; // LEFT column: the bank-sample rows (sampleRowRect is relative here)
Rect zonePanel; // RIGHT column: the "Add Zone" button + the zone rows
Rect addZoneButton; // top of the zone panel
@@ -146,4 +139,86 @@ ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int
// True if (x, y) lands on the "Add Zone" button. Pure.
bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y);
} // namespace reasampler::vst
// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ----------------------
//
// The capture-first editor's band/cluster/zone-surface layout math, hoisted out of the
// reasampler_editor shell where it had accreted untestable (the §2 scope gap). Draw and
// hit-test both derive every rect from these ONE formulas so they can never drift; the
// shell only draws + routes. The Browse-modal layout lives in browser_scroll (its search
// box height feeds it — dependency-clean placement beside its scroll/search siblings).
// Shared band metrics (the shell's remaining direct uses: horizontal padding + the
// title-band height; everything else is internal to the layout functions below).
inline constexpr int kPad = 8;
inline constexpr int kTitleHeight = 26;
inline constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons
// The r11 Sample-face bands (top->bottom): a TITLE band (name + Browse/Zone nav), the
// FULL-WIDTH ELASTIC HERO (absorbs all height left after the fixed bands, floor
// kHeroMinHeight), the ROOT + PREVIEW CLUSTER, and the bottom-anchored KNOB DECK
// (height `deckH` from the pure knob_deck wrap). When the window is too short for the
// hero floor (below the checkSizeConstraint minimum — defensive), the hero keeps its
// floor and the lower bands clip past the window bottom gracefully.
struct SampleBands {
Rect title; // top: name + Browse/Zone nav buttons
Rect navBrowse; // the "Browse" title-band button
Rect navZone; // the "Zone" title-band button
Rect hero; // the FULL-WIDTH ELASTIC hero waveform + S-VIEW-3 envelope overlay
Rect cluster; // root strip + preview + vel knob + curve button + channel toggle
Rect deck; // the bottom-anchored knob deck (height from the pure knob_deck wrap)
};
SampleBands computeSampleBands(int w, int h, int deckH);
// The r11 cluster sub-rects: the root strip keeps the left side at REMAINDER width; the
// right side is the fixed-width right-anchored run (Preview 64 · Vel knob cell 48 · curve
// preview button 28 · Mono|Stereo). `knobSize` is the deck knob square (knob_deck's
// kDeckKnobSize — passed in so this module does not depend on knob_deck).
struct ClusterRects {
Rect rootStrip; // remainder-width fenced root strip
Rect preview; // the preview-trigger button
Rect velCell; // the radial preview-velocity knob cell (knob + label band)
Rect velKnob; // the knob square at the cell's top
Rect velLabel; // the label band beneath it
Rect curveBtn; // the mini curve-preview button (opens the popup)
};
ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize);
// The S7 mono/stereo toggle: a two-segment control right-anchored in `area`, vertically
// centered. Returns {mono-segment, stereo-segment}, side by side.
struct ChannelToggleRects {
Rect mono;
Rect stereo;
};
ChannelToggleRects channelToggleRects(const Rect& area);
// The Zone-view (S-VIEW-8) content area: the whole window below the title band.
Rect zoneContentArea(int w, int h);
// The Zone/Browse "Back" title-band button (right-anchored — the same slot the Sample
// face's Zone nav button occupies).
Rect zoneBackRect(int w, int h);
// The "+ Add Zone" affordance at the top of the Zone content, and the "Delete" button
// beside it (Delete only draws/hits when a zone is selected).
Rect zoneAddRect(const Rect& content);
Rect zoneDeleteRect(const Rect& addR);
// The Zone-view keyboard strip rect: below the "+ Add Zone" affordance with a 12px gap,
// padded kPad horizontally.
Rect zonesStripArea(const Rect& content);
// The S12 numeric-entry field ROW area inside the Zones legend (a band to the right of
// the sample label), and the rect of field `f` (0=low, 1=high, 2=root) within it —
// three equal segments left-to-right. An out-of-range index yields an empty rect.
Rect noteEntryFieldsArea(const Rect& content);
Rect noteEntryFieldRect(const Rect& fields, int f);
// The per-zone parameter panel below the strip + the one-line legend, running to the
// content bottom; the FB2 knob-deck area within it (a column at the right reserved for
// the mini curve-preview button); and that button's rect (the cluster's 28px square,
// right-anchored at the panel top).
Rect zonesControlPanel(const Rect& content);
Rect zonesDeckArea(const Rect& content);
Rect zonesCurveButton(const Rect& content);
} // namespace reasampler::instrument::ui
@@ -1,10 +1,10 @@
// embed_strip.cpp — see embed_strip.h. Pure math; no host types.
#include "embed_strip.h"
#include "core/instrument/ui/embed_strip.h"
#include <algorithm>
namespace reasampler::vst {
namespace reasampler::instrument::ui {
namespace {
@@ -42,22 +42,22 @@ EmbedLayout layoutEmbed(int w, int h) {
}
const int keymapBottom = ch - bandH;
out.keymap = Rect{0, 0, cw, keymapBottom};
out.levelBand = Rect{0, keymapBottom, cw, ch};
out.keymap = Rect::ltrb(0, 0, cw, keymapBottom);
out.levelBand = Rect::ltrb(0, keymapBottom, cw, ch);
return out;
}
Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote) {
const Rect& band = layout.keymap;
const int bandWidth = std::max(0, band.width());
const int bandWidth = std::max(0, band.width);
int lo = clampNote(lowNote);
int hi = clampNote(highNote);
if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts
const int leftX = keyEdgeToX(band.left, bandWidth, lo);
const int rightX = keyEdgeToX(band.left, bandWidth, hi + 1);
return Rect{leftX, band.top, std::max(leftX, rightX), band.bottom};
const int leftX = keyEdgeToX(band.x, bandWidth, lo);
const int rightX = keyEdgeToX(band.x, bandWidth, hi + 1);
return Rect::ltrb(leftX, band.y, std::max(leftX, rightX), band.bottom());
}
int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x,
@@ -74,13 +74,13 @@ int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount
Rect levelFillRect(const EmbedLayout& layout, double level) {
const Rect& band = layout.levelBand;
if (band.width() <= 0 || band.height() <= 0) return Rect{};
if (band.width <= 0 || band.height <= 0) return Rect{};
double l = level;
if (l < 0.0) l = 0.0;
if (l > 1.0) l = 1.0;
const int fillW = static_cast<int>(l * band.width());
const int fillW = static_cast<int>(l * band.width);
if (fillW <= 0) return Rect{};
return Rect{band.left, band.top, band.left + fillW, band.bottom};
return Rect::ltrb(band.x, band.y, band.x + fillW, band.bottom());
}
} // namespace reasampler::vst
} // namespace reasampler::instrument::ui
@@ -18,9 +18,9 @@
#pragma once
#include "editor_geometry.h" // Rect, contains — one shared geometry idiom
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
namespace reasampler::vst {
namespace reasampler::instrument::ui {
// The full MIDI key span the strip maps across its width. 128 keys (0..127); the strip's
// horizontal axis is this range, so a zone [lowNote, highNote] becomes a sub-rectangle.
@@ -54,7 +54,7 @@ struct EmbedLayout {
EmbedLayout layoutEmbed(int w, int h);
// The horizontal sub-rectangle of the keymap band for a zone spanning [lowNote, highNote]
// (inclusive). The 128-key span maps linearly across keymap.width(); the returned rect
// (inclusive). The 128-key span maps linearly across keymap.width; the returned rect
// spans the half-open pixel range [x(lowNote), x(highNote+1)) so adjacent zones (e.g.
// 0..59 and 60..127) tile without a gap or overlap. Notes are clamped to [0,127] and low
// is clamped to <= high, so a malformed zone yields an in-band (possibly zero-width) rect,
@@ -73,4 +73,4 @@ int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount
// (rounded down). level <= 0 -> empty rect; level >= 1 -> the whole band. Pure.
Rect levelFillRect(const EmbedLayout& layout, double level);
} // namespace reasampler::vst
} // namespace reasampler::instrument::ui
@@ -1,24 +1,19 @@
// envelope_edit.cpp — see envelope_edit.h. Pure inverse map + hit-test; no host types.
#include "envelope_edit.h"
#include "core/instrument/ui/envelope_edit.h"
#include <algorithm>
#include <cstdlib> // std::abs
namespace reasampler::vst {
namespace reasampler::instrument::ui {
namespace {
double clamp(double v, double lo, double hi) {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
// Seconds represented by one horizontal pixel under the overlay's linear time base. Zero when the
// area is degenerate (the caller then produces no motion). Matches envelope_overlay::timeToX.
double secondsPerPixel(const Rect& area, double totalSeconds) {
const int w = std::max(0, area.width());
const int w = std::max(0, area.width);
if (w <= 0 || totalSeconds <= 0.0) return 0.0;
return totalSeconds / static_cast<double>(w);
}
@@ -36,7 +31,7 @@ double gateSecondsPerPixel(const Rect& area) {
// Level (0..1) represented by one vertical pixel. levelToY spans (height-1) rows for [0,1], so one
// pixel is 1/(height-1). Zero when degenerate. Matches envelope_overlay::levelToY.
double levelPerPixel(const Rect& area) {
const int h = std::max(0, area.height());
const int h = std::max(0, area.height);
if (h <= 1) return 0.0;
return 1.0 / static_cast<double>(h - 1);
}
@@ -118,23 +113,23 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
// because every segment stays >= 0), so the [0, max] clamp is the whole constraint.
case EnvNode::AttackEnd:
out.attackSeconds =
clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
std::clamp(grabEnv.attackSeconds + gateDSec, 0.0, bounds.maxAttackSeconds);
break;
case EnvNode::HoldEnd:
out.holdSeconds = clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
out.holdSeconds = std::clamp(grabEnv.holdSeconds + gateDSec, 0.0, bounds.maxHoldSeconds);
break;
case EnvNode::DecayEnd: {
// Sustain node: X sets decay time, Y sets sustain level (drag DOWN = higher y = lower
// level, so subtract the level delta).
out.decaySeconds = clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
out.decaySeconds = std::clamp(grabEnv.decaySeconds + gateDSec, 0.0, bounds.maxDecaySeconds);
const double lvlPerPx = levelPerPixel(area);
const double dLevel = -static_cast<double>(dyPixels) * lvlPerPx;
out.sustainLevel = clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
out.sustainLevel = std::clamp(grabEnv.sustainLevel + dLevel, 0.0, 1.0);
break;
}
case EnvNode::ReleaseEnd:
out.releaseSeconds =
clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds);
std::clamp(grabEnv.releaseSeconds + gateDSec, 0.0, bounds.maxReleaseSeconds);
break;
// --- Trigger: fades + length are FRACTIONS. X pixels convert to a fraction of the PLAYED
@@ -154,7 +149,7 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
const double dFrac = playSeconds > 0.0 ? dSec / playSeconds : 0.0;
const double hi = std::min(bounds.maxFadeInFraction,
1.0 - std::max(0.0, grabEnv.fadeOutFraction));
out.fadeInFraction = clamp(grabEnv.fadeInFraction + dFrac, 0.0, std::max(0.0, hi));
out.fadeInFraction = std::clamp(grabEnv.fadeInFraction + dFrac, 0.0, std::max(0.0, hi));
break;
}
case EnvNode::FadeOutStart: {
@@ -165,13 +160,13 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
const double dFrac = playSeconds > 0.0 ? -dSec / playSeconds : 0.0;
const double hi = std::min(bounds.maxFadeOutFraction,
1.0 - std::max(0.0, grabEnv.fadeInFraction));
out.fadeOutFraction = clamp(grabEnv.fadeOutFraction + dFrac, 0.0, std::max(0.0, hi));
out.fadeOutFraction = std::clamp(grabEnv.fadeOutFraction + dFrac, 0.0, std::max(0.0, hi));
break;
}
case EnvNode::LengthEnd: {
// LengthEnd sits at lengthFraction of the WHOLE sample; X maps to a fraction of it.
const double dFrac = totalSeconds > 0.0 ? dSec / totalSeconds : 0.0;
out.lengthFraction = clamp(grabEnv.lengthFraction + dFrac, 0.0, bounds.maxLengthFraction);
out.lengthFraction = std::clamp(grabEnv.lengthFraction + dFrac, 0.0, bounds.maxLengthFraction);
break;
}
@@ -182,4 +177,4 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
return out;
}
} // namespace reasampler::vst
} // namespace reasampler::instrument::ui
@@ -42,10 +42,10 @@
#include <cstdint>
#include <vector>
#include "editor_geometry.h" // Rect
#include "envelope_overlay.h" // EnvNode, EnvMode, AmpEnvelope, EnvVertex, timeToX/levelToY
#include "core/instrument/ui/editor_geometry.h" // Rect
#include "core/instrument/ui/envelope_overlay.h" // EnvNode, EnvMode, AmpEnvelope, EnvVertex, timeToX/levelToY
namespace reasampler::vst {
namespace reasampler::instrument::ui {
// The pick radius (px) around a node's drawn point: a grab within this many pixels (in BOTH x and
// y) of a node handle grabs it. Mirrors waveform_view's kMarkerGrabWidth — wide enough to grab a
@@ -105,4 +105,4 @@ AmpEnvelope resolveNodeDrag(const AmpEnvelope& grabEnv, EnvNode node, const Rect
double totalSeconds, const EnvClampBounds& bounds,
int dxPixels, int dyPixels);
} // namespace reasampler::vst
} // namespace reasampler::instrument::ui
@@ -1,25 +1,29 @@
// envelope_overlay.cpp — see envelope_overlay.h. Pure geometry; no host types.
#include "envelope_overlay.h"
#include "core/instrument/ui/envelope_overlay.h"
#include "core/util/clamp01.h"
#include <algorithm>
namespace reasampler::vst {
namespace reasampler::instrument::ui {
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
int timeToX(const Rect& area, double totalSeconds, double t) {
const int w = std::max(0, area.width());
if (w <= 0 || totalSeconds <= 0.0) return area.left;
const int w = std::max(0, area.width);
if (w <= 0 || totalSeconds <= 0.0) return area.x;
if (t < 0.0) t = 0.0;
// Linear map, clamped on BOTH sides (FA2 bounds invariant): t past totalSeconds pins to the
// last in-bounds column area.right-1. Clamp in DOUBLE space BEFORE the integer cast — a huge
// last in-bounds column area.right()-1. Clamp in DOUBLE space BEFORE the integer cast — a huge
// t would overflow a 32-bit long (Windows) and wrap to the WRONG edge — then round.
double px = (t / totalSeconds) * static_cast<double>(w);
if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1);
return area.left + static_cast<int>(px + 0.5);
return area.x + static_cast<int>(px + 0.5);
}
int gateTimedWidth(const Rect& area) {
const int w = std::max(0, area.width());
const int w = std::max(0, area.width);
if (w <= 0) return 0;
const int sustainPx =
static_cast<int>(kGateSustainDisplayFraction * static_cast<double>(w) + 0.5);
@@ -38,25 +42,19 @@ double gatePxPerSecond(const Rect& area) {
}
int levelToY(const Rect& area, double level) {
const int h = std::max(0, area.height());
if (h <= 0) return area.top;
const int h = std::max(0, area.height);
if (h <= 0) return area.y;
if (level < 0.0) level = 0.0;
if (level > 1.0) level = 1.0;
// Level 1 -> top row, level 0 -> bottom row (bottom-1 under the half-open convention). The
// range spans (h-1) pixels so both endpoints land ON a drawable row.
const int span = h - 1;
const long dy = static_cast<long>((1.0 - level) * static_cast<double>(span) + 0.5);
return area.top + static_cast<int>(dy);
return area.y + static_cast<int>(dy);
}
namespace {
double clamp01(double v) {
if (v < 0.0) return 0.0;
if (v > 1.0) return 1.0;
return v;
}
EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, double level) {
EnvVertex v;
v.node = node;
@@ -71,12 +69,12 @@ EnvVertex vtx(EnvNode node, const Rect& area, double totalSeconds, double t, dou
// DOUBLE space to the last in-bounds column BEFORE the integer cast (FA2 bounds invariant; a
// huge px would overflow a 32-bit long on Windows and wrap to the WRONG edge).
EnvVertex gateVtx(EnvNode node, const Rect& area, double px, double level) {
const int w = std::max(1, area.width());
const int w = std::max(1, area.width);
if (px < 0.0) px = 0.0;
if (px > static_cast<double>(w - 1)) px = static_cast<double>(w - 1);
EnvVertex v;
v.node = node;
v.x = area.left + static_cast<int>(px + 0.5);
v.x = area.x + static_cast<int>(px + 0.5);
v.y = levelToY(area, level);
v.level = level;
return v;
@@ -95,7 +93,7 @@ std::vector<EnvVertex> gatePolyline(const AmpEnvelope& env, const Rect& area) {
// gets a kGateNodeSepPx base so consecutive nodes never coincide (every node individually
// grabbable at any params, incl. the tier-0 zero-hold/zero-decay defaults). The sustain
// plateau is the fixed reserve between DecayEnd and ReleaseStart.
const int W = std::max(1, area.width());
const int W = std::max(1, area.width);
const double sustainPx = static_cast<double>(W - gateTimedWidth(area));
const double sep = static_cast<double>(kGateNodeSepPx);
const double pps = gatePxPerSecond(area);
@@ -162,7 +160,7 @@ std::vector<EnvVertex> triggerPolyline(const AmpEnvelope& env, const Rect& area,
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds) {
if (area.width() <= 0 || area.height() <= 0 || totalSeconds <= 0.0) {
if (area.width <= 0 || area.height <= 0 || totalSeconds <= 0.0) {
// Degenerate surface: a two-point flat baseline at level 0 so the shell always has a line.
return {vtx(EnvNode::Origin, area, 1.0, 0.0, 0.0),
vtx(EnvNode::ReleaseEnd, area, 1.0, 1.0, 0.0)};
@@ -173,4 +171,4 @@ std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect&
: triggerPolyline(env, area, totalSeconds);
}
} // namespace reasampler::vst
} // namespace reasampler::instrument::ui
@@ -25,7 +25,7 @@
// the vertical axis is LEVEL (0 at rect bottom, 1 at rect top).
//
// BOUNDS INVARIANT (FA2). EVERY vertex of EVERY polyline is clamped inside the canvas:
// x in [area.left, area.right-1], y in [area.top, area.bottom-1] (half-open rect convention).
// x in [area.x, area.right()-1], y in [area.y, area.bottom()-1] (half-open rect convention).
// No node and no drawn segment ever exceeds the canvas — paint-time clipping of handles is no
// longer needed (and never fires) in the shell.
//
@@ -33,8 +33,8 @@
// * The EnvNode enum is UNCHANGED (same node set, same draggable set — Origin + ReleaseStart
// remain the only non-draggable anchors).
// * ALL vertices are now in-bounds (see above). The shell's previous "skip handle when
// v.x >= waveArea.right" clip is dead code: ReleaseEnd (Gate) and FadeOutStart/LengthEnd
// (Trigger, at full length / zero fade-out) now land at area.right-1 and MUST get handles.
// v.x >= waveArea.right()" clip is dead code: ReleaseEnd (Gate) and FadeOutStart/LengthEnd
// (Trigger, at full length / zero fade-out) now land at area.right()-1 and MUST get handles.
// * Gate's x-axis is SCHEMATIC, not PCM-aligned: the timed region is scaled to the param
// domain (4 x kGateStageMaxSeconds), the sustain reserve is a fixed width, and every
// segment carries a kGateNodeSepPx pixel base. The Gate curve does NOT line up with the
@@ -46,7 +46,7 @@
// right edge and wins the tie, so the fade can be dragged open from zero).
//
// DELIBERATELY ENGINE-FREE (house pattern — param_slider does the same). It does NOT depend on
// sample_map / sampler_core (which would drag bank_book / wav_trim in). The shell reads the
// sample_map / sampler_core (which would drag bank_book / wav_codec in). The shell reads the
// zone's AdsrSeconds / TriggerParams and packs them into the small AmpEnvelope view struct here.
// AHDSR times are wall-clock SECONDS (rate-free, matching the stored domain — Daniel's no-
// hardcoded-rate ruling); Trigger fades are FRACTIONS of the play span. The one rate-bound input
@@ -60,9 +60,9 @@
#include <cstdint>
#include <vector>
#include "editor_geometry.h" // Rect — the shared geometry idiom
#include "core/instrument/ui/editor_geometry.h" // Rect — the shared geometry idiom
namespace reasampler::vst {
namespace reasampler::instrument::ui {
// The play mode the overlay draws — a LOCAL mirror of sampler_core's PlayMode kept here so the
// geometry module stays engine-free (the shell maps the zone's PlayMode to this). Same two cases.
@@ -173,7 +173,7 @@ inline constexpr int kGateNodeSepPx = 8;
// (param clamps are caller-supplied in envelope_edit); only layout does.
inline constexpr double kGateStageMaxSeconds = 2.0;
// The pixel width of the Gate timed region: area.width() minus the sustain-plateau reserve,
// The pixel width of the Gate timed region: area.width minus the sustain-plateau reserve,
// floored at 1 px so the px<->seconds scale never degenerates for a non-empty area. Returns 0
// for a zero/negative-width area. Shared by gatePolyline and envelope_edit's gate drag scale.
int gateTimedWidth(const Rect& area);
@@ -188,7 +188,7 @@ double gatePxPerSecond(const Rect& area);
// Map an amp envelope to its polyline vertices inside `area`, over a sample of `totalSeconds`
// wall-clock duration. `area` is the waveform rect (left/top inclusive, right/bottom exclusive);
// y maps level 0..1 across [area.bottom-1 .. area.top] (level 1 at the TOP). The polyline reads
// y maps level 0..1 across [area.bottom()-1 .. area.y] (level 1 at the TOP). The polyline reads
// left-to-right in draw order, Origin first.
//
// TIME BASE (FA2).
@@ -205,25 +205,25 @@ double gatePxPerSecond(const Rect& area);
// lengthFraction * totalSeconds; fade-in/out are fractions OF that played span. Nodes past
// the played span never appear (FadeOutStart/LengthEnd sit at the played span's right edge).
//
// BOUNDS: every vertex is inside the canvas — x in [area.left, area.right-1], y in
// [area.top, area.bottom-1]. Nothing maps past area.right (the pre-FA2 release tail is gone). A
// BOUNDS: every vertex is inside the canvas — x in [area.x, area.right()-1], y in
// [area.y, area.bottom()-1]. Nothing maps past area.right() (the pre-FA2 release tail is gone). A
// degenerate area (zero width/height) or totalSeconds <= 0 yields the two-point flat baseline
// [Origin, end at level 0] so the shell always has a drawable line. Pure — same inputs, same
// polyline.
std::vector<EnvVertex> buildEnvelopePolyline(const AmpEnvelope& env, const Rect& area,
double totalSeconds);
// Map a time (seconds) to a pixel x inside `area`: t=0 -> area.left, t=totalSeconds ->
// area.right-1, linear, CLAMPED on both sides (t < 0 pins to area.left; t past totalSeconds pins
// to area.right-1 — the in-bounds invariant, FA2). A zero-width area or totalSeconds <= 0 yields
// area.left. Pure — the shared time->x map the Trigger polyline and the node hit-test
// Map a time (seconds) to a pixel x inside `area`: t=0 -> area.x, t=totalSeconds ->
// area.right()-1, linear, CLAMPED on both sides (t < 0 pins to area.x; t past totalSeconds pins
// to area.right()-1 — the in-bounds invariant, FA2). A zero-width area or totalSeconds <= 0 yields
// area.x. Pure — the shared time->x map the Trigger polyline and the node hit-test
// (envelope_edit) use, so the drawn handle and its grab region agree.
int timeToX(const Rect& area, double totalSeconds, double t);
// Map a level (0..1) to a pixel y inside `area`: level 1 -> area.top, level 0 -> area.bottom-1
// Map a level (0..1) to a pixel y inside `area`: level 1 -> area.y, level 0 -> area.bottom()-1
// (so the full-amplitude line sits at the top edge and silence at the bottom pixel row). level is
// clamped to [0,1]. A zero-height area yields area.top. Pure — the shared level->y map the polyline
// clamped to [0,1]. A zero-height area yields area.y. Pure — the shared level->y map the polyline
// and the node hit-test share.
int levelToY(const Rect& area, double level);
} // namespace reasampler::vst
} // namespace reasampler::instrument::ui
@@ -1,10 +1,10 @@
// keyboard_strip.cpp — see keyboard_strip.h. Pure math; no host types.
#include "keyboard_strip.h"
#include "core/instrument/ui/keyboard_strip.h"
#include <algorithm>
namespace reasampler::vst {
namespace reasampler::instrument::ui {
namespace {
@@ -30,24 +30,24 @@ StripLayout layoutStrip(int w, int h) {
const int cw = std::max(0, w);
const int ch = std::max(0, h);
StripLayout out;
out.keys = Rect{0, 0, cw, ch};
out.keys = Rect::ltrb(0, 0, cw, ch);
return out;
}
int keyLeftX(const StripLayout& layout, int note) {
const Rect& band = layout.keys;
const int bandWidth = std::max(0, band.width());
const int bandWidth = std::max(0, band.width);
// note is a KEY here (0..127); its left edge is boundary `note`. Callers pass note+1 to
// get a key's right edge, and 128 maps to the band right.
const int edge = note < 0 ? 0 : (note > kStripKeyCount ? kStripKeyCount : note);
return keyEdgeToX(band.left, bandWidth, edge);
return keyEdgeToX(band.x, bandWidth, edge);
}
Rect keyRect(const StripLayout& layout, int note) {
const int n = clampNote(note);
const int leftX = keyLeftX(layout, n);
const int rightX = keyLeftX(layout, n + 1);
return Rect{leftX, layout.keys.top, std::max(leftX, rightX), layout.keys.bottom};
return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom());
}
Rect rootMarkerRect(const StripLayout& layout, int rootNote) {
@@ -57,11 +57,11 @@ Rect rootMarkerRect(const StripLayout& layout, int rootNote) {
int keyAtPoint(const StripLayout& layout, int x, int y) {
const Rect& band = layout.keys;
if (!contains(band, x, y)) return -1;
const int bandWidth = std::max(0, band.width());
const int bandWidth = std::max(0, band.width);
if (bandWidth <= 0) return -1;
// Invert keyEdgeToX: the key whose half-open [leftX, rightX) contains x. Floor-divide
// the pixel offset back to a key; clamp defensively (a point on band.right-1 maps to 127).
const int offset = x - band.left;
// the pixel offset back to a key; clamp defensively (a point on band.right()-1 maps to 127).
const int offset = x - band.x;
int note = (offset * kStripKeyCount) / bandWidth;
return clampNote(note);
}
@@ -72,22 +72,22 @@ Rect zoneBarRect(const StripLayout& layout, int lowNote, int highNote) {
if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts
const int leftX = keyLeftX(layout, lo);
const int rightX = keyLeftX(layout, hi + 1);
return Rect{leftX, layout.keys.top, std::max(leftX, rightX), layout.keys.bottom};
return Rect::ltrb(leftX, layout.keys.y, std::max(leftX, rightX), layout.keys.bottom());
}
ZoneGrab zoneGrabAt(const StripLayout& layout, int lowNote, int highNote, int x, int y) {
const Rect bar = zoneBarRect(layout, lowNote, highNote);
if (!contains(bar, x, y)) return ZoneGrab::kNone;
const int barW = bar.width();
const int barW = bar.width;
// A narrow bar (< 2*edge) has no body: split at the midpoint, LOW edge wins the tie so
// a click exactly on the midpoint resizes low (deterministic).
if (barW < 2 * kStripEdgeGrabWidth) {
const int mid = bar.left + barW / 2;
const int mid = bar.x + barW / 2;
return x <= mid ? ZoneGrab::kLowEdge : ZoneGrab::kHighEdge;
}
if (x < bar.left + kStripEdgeGrabWidth) return ZoneGrab::kLowEdge;
if (x >= bar.right - kStripEdgeGrabWidth) return ZoneGrab::kHighEdge;
if (x < bar.x + kStripEdgeGrabWidth) return ZoneGrab::kLowEdge;
if (x >= bar.right() - kStripEdgeGrabWidth) return ZoneGrab::kHighEdge;
return ZoneGrab::kBody;
}
@@ -127,7 +127,7 @@ bool isNaturalKey(int note) {
int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) {
if (dxPixels == 0) return clampNote(startNote);
const int bandWidth = std::max(0, layout.keys.width());
const int bandWidth = std::max(0, layout.keys.width);
if (bandWidth <= 0) return clampNote(startNote); // zero-width -> no motion
// Proportional shift: same linear mapping as keyAtPoint/keyEdgeToX so click and drag
// agree across the full strip, even on non-divisible-by-128 widths. The proportional
@@ -146,4 +146,4 @@ int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels) {
return clampNote(startNote + shift);
}
} // namespace reasampler::vst
} // namespace reasampler::instrument::ui
@@ -23,9 +23,9 @@
#pragma once
#include "editor_geometry.h" // Rect, contains — one shared geometry idiom
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
namespace reasampler::vst {
namespace reasampler::instrument::ui {
// The full MIDI key span the strip maps across its width: 128 keys (0..127). Named
// distinctly from embed_strip's kEmbedKeyCount (same value) so the two strips stay
@@ -42,7 +42,7 @@ inline constexpr int kStripEdgeGrabWidth = 6;
// takes the whole area today (a future octave-label lane can carve a sub-band here without
// changing callers). Clamped so a degenerate (tiny/zero) size never yields an inverted rect.
struct StripLayout {
Rect keys; // the key band: the 128-key span maps linearly across keys.width()
Rect keys; // the key band: the 128-key span maps linearly across keys.width
};
// Divide a (w x h) strip area into its regions. Pure: same inputs -> same layout. A zero or
@@ -50,7 +50,7 @@ struct StripLayout {
StripLayout layoutStrip(int w, int h);
// The x pixel (inside the keys band) of the LEFT edge of key `note` (0..127). The 128-key
// span maps linearly across keys.width(); key N occupies the half-open pixel range
// span maps linearly across keys.width; key N occupies the half-open pixel range
// [keyLeftX(N), keyLeftX(N+1)). Notes are clamped to [0,127]; note==128 maps to the band's
// right edge (so a key's right edge is keyLeftX(note+1)). Pure.
int keyLeftX(const StripLayout& layout, int note);
@@ -128,4 +128,4 @@ int resolveDragNote(const StripLayout& layout, int startNote, int dxPixels);
// pastel spectral fill (S-VIEW-7). Pure — no layout required, no host types.
bool isNaturalKey(int note);
} // namespace reasampler::vst
} // namespace reasampler::instrument::ui
@@ -1,10 +1,10 @@
// knob_deck.cpp — see knob_deck.h. Pure arithmetic; no LICE/VST3/REAPER includes.
#include "knob_deck.h"
#include "core/instrument/ui/knob_deck.h"
#include <algorithm>
namespace reasampler::vst {
namespace reasampler::instrument::ui {
namespace {
@@ -33,19 +33,20 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
out.id = g.id;
out.box = box;
const int captionTop = box.top + kDeckGroupPadY;
const int innerLeft = box.left + kDeckGroupPadX;
const int innerRight = box.right - kDeckGroupPadX;
const int captionTop = box.y + kDeckGroupPadY;
const int innerLeft = box.x + kDeckGroupPadX;
const int innerRight = box.right() - kDeckGroupPadX;
// Caption row: text left, compact toggle right-anchored (r11 — the not-full-width home).
out.caption = Rect{innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH};
out.caption = Rect::ltrb(innerLeft, captionTop, innerRight, captionTop + kDeckCaptionH);
if (g.captionToggle.id >= 0) {
const int segW = g.captionToggle.segWidth;
const int togTop = captionTop + (kDeckCaptionH - kDeckToggleH) / 2;
const Rect seg1{innerRight - segW, togTop, innerRight, togTop + kDeckToggleH};
const Rect seg0{seg1.left - segW, togTop, seg1.left, togTop + kDeckToggleH};
const Rect seg1 = Rect::ltrb(innerRight - segW, togTop, innerRight, togTop + kDeckToggleH);
const Rect seg0 = Rect::ltrb(seg1.x - segW, togTop, seg1.x, togTop + kDeckToggleH);
out.captionToggle = DeckToggleLayout{g.captionToggle.id, seg0, seg1};
out.caption.right = seg0.left - kDeckToggleGap; // caption text stops at the toggle
// Caption text stops at the toggle: pull the right edge in (XYWH: shrink width).
out.caption.width = (seg0.x - kDeckToggleGap) - out.caption.x;
}
// Knob row: fixed cells left-to-right, then the optional row toggle.
@@ -54,12 +55,12 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
for (int id : g.cellIds) {
DeckCellLayout c;
c.id = id;
c.cell = Rect{x, cellTop, x + kDeckCellW, cellTop + kDeckCellH};
c.cell = Rect::ltrb(x, cellTop, x + kDeckCellW, cellTop + kDeckCellH);
const int knobLeft = x + (kDeckCellW - kDeckKnobSize) / 2;
const int knobTop = cellTop + 4;
c.knob = Rect{knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize};
c.knob = Rect::ltrb(knobLeft, knobTop, knobLeft + kDeckKnobSize, knobTop + kDeckKnobSize);
const int labelTop = knobTop + kDeckKnobSize + 4;
c.label = Rect{c.cell.left, labelTop, c.cell.right, labelTop + kDeckCellLabelH};
c.label = Rect::ltrb(c.cell.x, labelTop, c.cell.right(), labelTop + kDeckCellLabelH);
out.cells.push_back(c);
x += kDeckCellW;
}
@@ -67,8 +68,8 @@ DeckGroupLayout layoutGroup(const DeckGroupDesc& g, const Rect& box) {
if (!g.cellIds.empty()) x += kDeckToggleGap;
const int segW = g.rowToggle.segWidth;
const int togTop = cellTop + (kDeckCellH - kDeckToggleH) / 2;
const Rect seg0{x, togTop, x + segW, togTop + kDeckToggleH};
const Rect seg1{seg0.right, togTop, seg0.right + segW, togTop + kDeckToggleH};
const Rect seg0 = Rect::ltrb(x, togTop, x + segW, togTop + kDeckToggleH);
const Rect seg1 = Rect::ltrb(seg0.right(), togTop, seg0.right() + segW, togTop + kDeckToggleH);
out.rowToggle = DeckToggleLayout{g.rowToggle.id, seg0, seg1};
}
return out;
@@ -120,9 +121,9 @@ DeckLayout layoutDeck(const std::vector<DeckGroupDesc>& groups, int left, int to
rowHasGroup = false;
}
if (rowHasGroup) x += kDeckGroupGap;
const Rect box{x, y, x + w, y + kDeckGroupH};
const Rect box = Rect::ltrb(x, y, x + w, y + kDeckGroupH);
out.groups.push_back(layoutGroup(g, box));
x = box.right;
x = box.right();
rowHasGroup = true;
}
out.height = out.rowCount * kDeckGroupH + (out.rowCount - 1) * kDeckRowGap;
@@ -152,4 +153,4 @@ DeckHit hitTestDeck(const DeckLayout& layout, int x, int y) {
return {};
}
} // namespace reasampler::vst
} // namespace reasampler::instrument::ui
@@ -27,9 +27,9 @@
#include <vector>
#include "editor_geometry.h" // Rect, contains — the shared geometry idiom
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — the shared geometry idiom
namespace reasampler::vst {
namespace reasampler::instrument::ui {
// Fixed deck metrics (spec r11), exposed so the shell and tests agree.
inline constexpr int kDeckCellW = 48; // one knob cell
@@ -130,4 +130,4 @@ struct DeckHit {
// Pure — the shell's routing entry point.
DeckHit hitTestDeck(const DeckLayout& layout, int x, int y);
} // namespace reasampler::vst
} // namespace reasampler::instrument::ui
@@ -1,30 +1,34 @@
// param_slider.cpp — see param_slider.h. PURE control-surface geometry for the S12/S15/S16
// editor parameter panel. No host types; only the shared Rect + contains().
#include "param_slider.h"
#include "core/instrument/ui/param_slider.h"
#include "core/util/clamp01.h"
#include <algorithm>
#include <cmath>
namespace reasampler::vst {
namespace reasampler::instrument::ui {
using util::clamp01; // the ONE unit-interval clamp (Q-W1, T4-24)
std::vector<ControlRow> layoutControls(const Rect& panel,
const std::vector<ControlDesc>& controls) {
std::vector<ControlRow> out;
if (controls.empty() || panel.width() <= 0 || panel.height() <= 0) return out;
if (controls.empty() || panel.width <= 0 || panel.height <= 0) return out;
out.reserve(controls.size());
// The label column is clamped so a narrow panel still leaves a control column.
const int labelW = (std::min)(kControlLabelWidth, (std::max)(0, panel.width() / 2));
int rowTop = panel.top;
const int labelW = (std::min)(kControlLabelWidth, (std::max)(0, panel.width / 2));
int rowTop = panel.y;
for (const ControlDesc& d : controls) {
ControlRow r;
r.id = d.id;
r.kind = d.kind;
const int rowBottom = rowTop + kControlRowHeight;
r.row = Rect{panel.left, rowTop, panel.right, rowBottom};
r.label = Rect{panel.left, rowTop, panel.left + labelW, rowBottom};
r.control = Rect{panel.left + labelW, rowTop, panel.right, rowBottom};
r.row = Rect::ltrb(panel.x, rowTop, panel.right(), rowBottom);
r.label = Rect::ltrb(panel.x, rowTop, panel.x + labelW, rowBottom);
r.control = Rect::ltrb(panel.x + labelW, rowTop, panel.right(), rowBottom);
out.push_back(r);
rowTop = rowBottom + kControlRowGap;
}
@@ -33,13 +37,13 @@ std::vector<ControlRow> layoutControls(const Rect& panel,
Rect toggleSegmentRect(const Rect& control, int seg) {
if (seg < 0 || seg >= kToggleSegments) return Rect{};
const int w = control.width();
if (w <= 0 || control.height() <= 0) return Rect{};
const int w = control.width;
if (w <= 0 || control.height <= 0) return Rect{};
const int segW = w / kToggleSegments;
const int left = control.left + seg * segW;
const int left = control.x + seg * segW;
// The last segment absorbs the width remainder so the segments tile the whole control.
const int right = (seg == kToggleSegments - 1) ? control.right : left + segW;
return Rect{left, control.top, right, control.bottom};
const int right = (seg == kToggleSegments - 1) ? control.right() : left + segW;
return Rect::ltrb(left, control.y, right, control.bottom());
}
int toggleSegmentHitTest(const Rect& control, int x, int y) {
@@ -52,31 +56,31 @@ int toggleSegmentHitTest(const Rect& control, int x, int y) {
Rect sliderTrackRect(const Rect& control) {
// Inset a half-handle at each end so the handle stays fully inside the control at value
// 0 and 1. The handle CENTER ranges across [track.left, track.right].
// 0 and 1. The handle CENTER ranges across [track.x, track.right()].
const int half = kSliderHandleWidth / 2;
if (control.width() <= kSliderHandleWidth || control.height() <= 0) return Rect{};
return Rect{control.left + half, control.top, control.right - half, control.bottom};
if (control.width <= kSliderHandleWidth || control.height <= 0) return Rect{};
return Rect::ltrb(control.x + half, control.y, control.right() - half, control.bottom());
}
Rect sliderHandleRect(const Rect& control, double value) {
const Rect track = sliderTrackRect(control);
if (track.width() <= 0) return Rect{};
if (track.width <= 0) return Rect{};
if (value < 0.0) value = 0.0;
if (value > 1.0) value = 1.0;
const int span = track.width(); // handle-center movable span
const int centerX = track.left + static_cast<int>(value * span + 0.5);
const int span = track.width; // handle-center movable span
const int centerX = track.x + static_cast<int>(value * span + 0.5);
const int half = kSliderHandleWidth / 2;
return Rect{centerX - half, control.top, centerX - half + kSliderHandleWidth,
control.bottom};
return Rect::ltrb(centerX - half, control.y, centerX - half + kSliderHandleWidth,
control.bottom());
}
double valueAtPoint(const Rect& control, int x) {
const Rect track = sliderTrackRect(control);
const int span = track.width();
const int span = track.width;
if (span <= 0) return 0.0;
if (x <= track.left) return 0.0;
if (x >= track.right) return 1.0;
return static_cast<double>(x - track.left) / static_cast<double>(span);
if (x <= track.x) return 0.0;
if (x >= track.right()) return 1.0;
return static_cast<double>(x - track.x) / static_cast<double>(span);
}
// --- Radial knob (Wave A FA4) ---------------------------------------------------------
@@ -95,16 +99,14 @@ double normDeg(double deg) {
return deg;
}
double clamp01(double v) { return (std::min)(1.0, (std::max)(0.0, v)); }
} // namespace
KnobGeometry computeKnob(const Rect& cell) {
if (cell.width() <= 0 || cell.height() <= 0) return KnobGeometry{};
if (cell.width <= 0 || cell.height <= 0) return KnobGeometry{};
KnobGeometry g;
g.centerX = (cell.left + cell.right) / 2.0;
g.centerY = (cell.top + cell.bottom) / 2.0;
g.radius = (std::min)(cell.width(), cell.height()) / 2.0;
g.centerX = (cell.x + cell.right()) / 2.0;
g.centerY = (cell.y + cell.bottom()) / 2.0;
g.radius = (std::min)(cell.width, cell.height) / 2.0;
return g;
}
@@ -154,4 +156,4 @@ int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y) {
return -1;
}
} // namespace reasampler::vst
} // namespace reasampler::instrument::ui
@@ -24,9 +24,9 @@
#include <vector>
#include "editor_geometry.h" // Rect, contains — one shared geometry idiom
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
namespace reasampler::vst {
namespace reasampler::instrument::ui {
// Fixed control-panel metrics, exposed so the shell and tests agree.
inline constexpr int kControlRowHeight = 22; // one control row (incl. its inter-row gap)
@@ -81,7 +81,7 @@ int toggleSegmentHitTest(const Rect& control, int x, int y);
// The slider track sub-rect inside a slider control's `control` rect: the control inset so the
// handle (kSliderHandleWidth) stays fully within the control at value 0 and 1 (a half-handle
// margin at each end). The handle CENTER ranges across [track.left, track.right] as the value
// margin at each end). The handle CENTER ranges across [track.x, track.right()] as the value
// ranges [0,1]. The shell draws the track fill + handle here. A degenerate control yields an
// empty rect. Pure.
Rect sliderTrackRect(const Rect& control);
@@ -177,4 +177,4 @@ double knobDragValue(double startValue, int dyPixels,
// toggleSegmentHitTest / knobDragValue over the ensuing drag) and commits.
int controlAtPoint(const std::vector<ControlRow>& rows, int x, int y);
} // namespace reasampler::vst
} // namespace reasampler::instrument::ui
@@ -1,11 +1,11 @@
// waveform_view.cpp — see waveform_view.h. Pure math; no host types.
#include "waveform_view.h"
#include "core/instrument/ui/waveform_view.h"
#include <algorithm>
#include <cstdlib> // std::abs (int overload)
namespace reasampler::vst {
namespace reasampler::instrument::ui {
namespace {
@@ -18,21 +18,21 @@ std::int64_t clampFrame(std::int64_t f, std::int64_t frameCount) {
} // namespace
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame) {
const int w = std::max(0, area.width());
if (frameCount <= 0 || w <= 0) return area.left;
const int w = std::max(0, area.width);
if (frameCount <= 0 || w <= 0) return area.x;
const std::int64_t f = clampFrame(frame, frameCount);
// Linear map: x = left + round(f * w / frameCount). Rounding keeps the marker line
// visually centered on its frame; the divide is exact rational (multiply first).
const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2;
return area.left + static_cast<int>(num / frameCount);
return area.x + static_cast<int>(num / frameCount);
}
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x) {
const int w = std::max(0, area.width());
const int w = std::max(0, area.width);
if (frameCount <= 0 || w <= 0) return 0;
if (x <= area.left) return 0;
if (x >= area.right) return frameCount;
const std::int64_t dx = static_cast<std::int64_t>(x - area.left);
if (x <= area.x) return 0;
if (x >= area.right()) return frameCount;
const std::int64_t dx = static_cast<std::int64_t>(x - area.x);
// Inverse of frameToX: frame = round(dx * frameCount / w). Round so click and marker draw
// agree at bin granularity.
const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2;
@@ -54,7 +54,7 @@ std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::in
int dxPixels) {
const std::int64_t start = clampFrame(startFrame, frameCount);
if (dxPixels == 0) return start;
const int w = std::max(0, area.width());
const int w = std::max(0, area.width);
if (frameCount <= 0 || w <= 0) return start; // no room to move
// Proportional shift, rounded to the nearest frame (same linear map as frameToX/xToFrame).
const std::int64_t magnitude =
@@ -96,4 +96,4 @@ std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
return t; // no sign change in the whole buffer -> keep the raw (clamped) target
}
} // namespace reasampler::vst
} // namespace reasampler::instrument::ui
@@ -17,17 +17,19 @@
//
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom), so
// this header depends on editor_geometry.h rather than redefining a rectangle type. Audio
// is the peaks AudioSample float alias (the one house precedent — sampler_core / wav_trim do
// is the peaks AudioSample float alias (the one house precedent — sampler_core / wav_codec do
// the same), so the zero-crossing helper takes the same mono PCM the shell already decoded.
#pragma once
#include <cstdint>
#include "editor_geometry.h" // Rect, contains — one shared geometry idiom
#include "peaks.h" // AudioSample (float), the mono PCM the snap scans
#include "core/instrument/ui/editor_geometry.h" // Rect, contains — one shared geometry idiom
#include "core/audio/peaks.h" // AudioSample (float), the mono PCM the snap scans
namespace reasampler::vst {
namespace reasampler::instrument::ui {
using audio::AudioSample;
// The width (px) of a marker's grab region either side of its x line: a grab within this many
// pixels of a marker's drawn x is a grab OF that marker. Mirrors keyboard_strip's edge-grab
@@ -35,14 +37,14 @@ namespace reasampler::vst {
// distinguishable.
inline constexpr int kMarkerGrabWidth = 5;
// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.left,
// frame frameCount -> area.right. A frame is clamped to [0, frameCount] before mapping, so an
// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.x,
// frame frameCount -> area.right(). A frame is clamped to [0, frameCount] before mapping, so an
// out-of-range frame pins to an edge rather than escaping the rect. frameCount <= 0 or a
// zero-width area pins every frame to area.left (a degenerate, non-inverting result). Pure.
// zero-width area pins every frame to area.x (a degenerate, non-inverting result). Pure.
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame);
// The frame a point x (inside `area`) maps to under the inverse linear map, clamped to
// [0, frameCount]. A point left of area.left yields 0; right of area.right yields frameCount.
// [0, frameCount]. A point left of area.x yields 0; right of area.right() yields frameCount.
// frameCount <= 0 or a zero-width area yields 0. Pure — the inverse of frameToX (round-trips
// to the same frame at bin granularity).
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x);
@@ -80,4 +82,4 @@ std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::in
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
std::int64_t target);
} // namespace reasampler::vst
} // namespace reasampler::instrument::ui
+319
View File
@@ -0,0 +1,319 @@
// core/json implementation — see json.h. The bodies are the (previously
// quintuplicated) bank_model / view_mode_model lexical layer, verbatim; any
// behavioral change here changes five persisted-blob parsers at once.
#include "core/json/json.h"
#include <cerrno>
#include <climits>
#include <cstdio>
#include <cstdlib>
namespace reasampler::json {
// ---------------------------------------------------------------------------
// emit helpers
// ---------------------------------------------------------------------------
void writeEscaped(std::string& out, const std::string& s) {
out += '"';
for (char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\b': out += "\\b"; break;
case '\f': out += "\\f"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (static_cast<unsigned char>(c) < 0x20) {
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(c));
out += buf;
} else {
out += c;
}
}
}
out += '"';
}
std::string numToStr(double v) {
char buf[32];
std::snprintf(buf, sizeof(buf), "%.17g", v);
return buf;
}
std::string numToStr(std::int64_t v) {
char buf[32];
std::snprintf(buf, sizeof(buf), "%lld", static_cast<long long>(v));
return buf;
}
std::string numToStr(int v) {
char buf[16];
std::snprintf(buf, sizeof(buf), "%d", v);
return buf;
}
void writeStringArray(std::string& out, const std::vector<std::string>& v) {
out += '[';
for (std::size_t i = 0; i < v.size(); ++i) {
if (i) out += ',';
writeEscaped(out, v[i]);
}
out += ']';
}
void writeIntArray(std::string& out, const std::vector<int>& v) {
out += '[';
for (std::size_t i = 0; i < v.size(); ++i) {
if (i) out += ',';
out += numToStr(v[i]);
}
out += ']';
}
// ---------------------------------------------------------------------------
// Reader
// ---------------------------------------------------------------------------
void Reader::skipWs() {
while (!eof()) {
char c = s_[pos_];
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_;
else break;
}
}
bool Reader::consume(char c) {
skipWs();
if (eof() || s_[pos_] != c) return false;
++pos_;
return true;
}
// Parses a JSON string literal (with the escapes our writers emit, plus \uXXXX
// for control chars). Positioned before the opening quote (skips leading ws).
bool Reader::parseString(std::string& out) {
skipWs();
if (eof() || s_[pos_] != '"') return false;
++pos_;
out.clear();
while (!eof()) {
char c = s_[pos_++];
if (c == '"') return true;
if (c == '\\') {
if (eof()) return false;
char e = s_[pos_++];
switch (e) {
case '"': out += '"'; break;
case '\\': out += '\\'; break;
case '/': out += '/'; break;
case 'b': out += '\b'; break;
case 'f': out += '\f'; break;
case 'n': out += '\n'; break;
case 'r': out += '\r'; break;
case 't': out += '\t'; break;
case 'u': {
// Decode a \uXXXX escape to its code point.
auto readHex4 = [&](unsigned int& cp) -> bool {
if (pos_ + 4 > s_.size()) return false;
cp = 0;
for (int i = 0; i < 4; ++i) {
char h = s_[pos_++];
cp <<= 4;
if (h >= '0' && h <= '9') cp |= static_cast<unsigned>(h - '0');
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(h - 'A' + 10);
else return false;
}
return true;
};
unsigned int hi = 0;
if (!readHex4(hi)) return false;
unsigned int codePoint = hi;
if (hi >= 0xD800 && hi <= 0xDBFF) {
// High surrogate — must be followed by \uDC00\uDFFF.
if (pos_ + 6 > s_.size()) return false;
if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false;
pos_ += 2;
unsigned int lo = 0;
if (!readHex4(lo)) return false;
if (lo < 0xDC00 || lo > 0xDFFF) return false; // unpaired high surrogate
codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00);
} else if (hi >= 0xDC00 && hi <= 0xDFFF) {
return false; // unpaired low surrogate — malformed
}
// Encode codePoint as UTF-8.
if (codePoint <= 0x7F) {
out += static_cast<char>(codePoint);
} else if (codePoint <= 0x7FF) {
out += static_cast<char>(0xC0 | (codePoint >> 6));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else if (codePoint <= 0xFFFF) {
out += static_cast<char>(0xE0 | (codePoint >> 12));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
} else {
out += static_cast<char>(0xF0 | (codePoint >> 18));
out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
out += static_cast<char>(0x80 | (codePoint & 0x3F));
}
break;
}
default: return false;
}
} else {
out += c;
}
}
return false; // unterminated string
}
bool Reader::parseRawScalar(std::string& out) {
skipWs();
std::size_t start = pos_;
while (!eof()) {
char c = s_[pos_];
if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' ||
c == '\n' || c == '\r')
break;
++pos_;
}
if (pos_ == start) return false;
out.assign(s_, start, pos_ - start);
return true;
}
bool Reader::parseDouble(double& out) {
std::string tok;
if (!parseRawScalar(tok)) return false;
const char* b = tok.c_str();
char* end = nullptr;
errno = 0;
double v = std::strtod(b, &end);
if (end != b + tok.size()) return false;
if (errno == ERANGE) return false; // overflow / underflow -> malformed
out = v;
return true;
}
bool Reader::parseInt64(std::int64_t& out) {
std::string tok;
if (!parseRawScalar(tok)) return false;
const char* b = tok.c_str();
char* end = nullptr;
errno = 0;
long long v = std::strtoll(b, &end, 10);
if (end != b + tok.size()) return false;
if (errno == ERANGE) return false; // overflow -> malformed
out = static_cast<std::int64_t>(v);
return true;
}
bool Reader::parseInt(int& out) {
std::int64_t v = 0;
if (!parseInt64(v)) return false;
if (v < INT_MIN || v > INT_MAX) return false;
out = static_cast<int>(v);
return true;
}
bool Reader::parseBool(bool& out) {
std::string tok;
if (!parseRawScalar(tok)) return false;
if (tok == "true") { out = true; return true; }
if (tok == "false") { out = false; return true; }
return false;
}
bool Reader::expectNullOr(bool& wasNull) {
skipWs();
if (eof()) return false;
if (s_.compare(pos_, 4, "null") == 0) {
pos_ += 4;
wasNull = true;
} else {
wasNull = false;
}
return true;
}
bool Reader::parseKey(std::string& key) {
if (!parseString(key)) return false;
return consume(':');
}
bool Reader::parseStringArray(std::vector<std::string>& out) {
if (!consume('[')) return false;
skipWs();
if (consume(']')) return true; // empty array
do {
std::string s;
if (!parseString(s)) return false;
out.push_back(std::move(s));
} while (consume(','));
return consume(']');
}
bool Reader::parseIntArray(std::vector<int>& out) {
if (!consume('[')) return false;
skipWs();
if (consume(']')) return true;
do {
int v = 0;
if (!parseInt(v)) return false;
out.push_back(v);
} while (consume(','));
return consume(']');
}
bool Reader::skipValue() {
std::string raw;
return captureValue(raw);
}
// Records the raw source span of one JSON value starting at the current position
// (after whitespace). Handles nested objects/arrays with string-aware brace
// matching (braces inside strings ignored).
bool Reader::captureValue(std::string& raw) {
skipWs();
if (eof()) return false;
std::size_t start = pos_;
char c = s_[pos_];
if (c == '"') {
std::string tmp;
if (!parseString(tmp)) return false;
raw.assign(s_, start, pos_ - start);
return true;
}
if (c == '{' || c == '[') {
char open = c, close = (c == '{') ? '}' : ']';
++pos_;
int depth = 1;
while (!eof() && depth > 0) {
char d = s_[pos_];
if (d == '"') {
std::string tmp;
if (!parseString(tmp)) return false; // advances past the string
continue;
}
if (d == open) ++depth;
else if (d == close) --depth;
++pos_;
}
if (depth != 0) return false;
raw.assign(s_, start, pos_ - start);
return true;
}
// bare scalar (number / true / false / null)
return parseRawScalar(raw);
}
} // namespace reasampler::json
+149
View File
@@ -0,0 +1,149 @@
// core/json — the ONE hand-rolled JSON lexical layer (Q-W1; audit T2-02 / §2
// "Parser ×4"). Pure: standard library only — NO REAPER, NO SWELL, NO VST3.
//
// This module owns the lexical half of the house JSON dialect: the escape-aware
// string literal (incl. \uXXXX + surrogate pairs re-encoded as UTF-8), the bare
// scalar tokens, the number parses (strtod/strtoll with full-token + ERANGE
// rejection), key+':' consumption, unknown-value skipping, and the emit side
// (escaping, %.17g / %d / %lld number rendering, the scoped object writer).
// The DOMAIN grammars — which keys exist, what shape each value takes, what is
// rejected at the model boundary — stay in the consumers (bank_model, bank_book,
// view_mode_model, owned_manifest, tail_control). One lexical definition means
// the five decoders can no longer drift on tolerance or escaping.
//
// Byte-compatibility contract (load-bearing): the emit helpers reproduce the
// prior per-module writers EXACTLY — writeEscaped's escape set, %.17g for
// doubles (shortest form that round-trips every IEEE-754 double bit-for-bit),
// plain decimal for ints — so a re-serialized blob is byte-identical to what
// the pre-extraction writers produced. This was a structural dedupe, not a
// format change; persisted .rpp ext-state must not shift by a byte.
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace reasampler::json {
// ---------------------------------------------------------------------------
// emit helpers (writer side)
// ---------------------------------------------------------------------------
// Appends `s` as a quoted JSON string literal: the seven short escapes, \uXXXX
// for remaining control chars, everything else verbatim (UTF-8 passes through).
void writeEscaped(std::string& out, const std::string& s);
// Number rendering. %.17g is the shortest form that round-trips every IEEE-754
// double exactly, so deserialize(serialize(x)) == x holds bit-for-bit.
std::string numToStr(double v);
std::string numToStr(std::int64_t v);
std::string numToStr(int v);
// Flat homogeneous arrays: ["a","b"] / [1,2]. Empty vector -> "[]".
void writeStringArray(std::string& out, const std::vector<std::string>& v);
void writeIntArray(std::string& out, const std::vector<int>& v);
// Scoped object writer: appends '{' on construction and '}' on destruction, with
// comma separation handled internally. Nested values are written by keyBegin()
// followed by the caller emitting the value (e.g. a nested Writer scope or an
// array). NOTE the destructor-close means an enclosing scope must END (brace
// block) before the built string is returned — see the NRVO note in the
// consumers' serialize() implementations.
class Writer {
public:
explicit Writer(std::string& out) : out_(out) { out_ += '{'; }
~Writer() { out_ += '}'; }
Writer(const Writer&) = delete;
Writer& operator=(const Writer&) = delete;
// "key":<rawValue> — rawValue appended verbatim (numbers, bools, null,
// pre-serialized nested blobs).
void keyRaw(const char* key, const std::string& rawValue) {
sep();
writeEscaped(out_, key);
out_ += ':';
out_ += rawValue;
}
// "key":"value" — value escaped.
void keyStr(const char* key, const std::string& value) {
sep();
writeEscaped(out_, key);
out_ += ':';
writeEscaped(out_, value);
}
// "key": — caller writes the value immediately after.
void keyBegin(const char* key) {
sep();
writeEscaped(out_, key);
out_ += ':';
}
private:
void sep() {
if (first_) first_ = false; else out_ += ',';
}
std::string& out_;
bool first_ = true;
};
// ---------------------------------------------------------------------------
// Reader — the lexical cursor (parser side)
// ---------------------------------------------------------------------------
//
// Every method returns false on malformed input and never reads out of bounds.
// Only the subset the house writers emit is supported. The reader borrows the
// input string — it must outlive the Reader.
class Reader {
public:
explicit Reader(const std::string& s) : s_(s) {}
bool eof() const { return pos_ >= s_.size(); }
void skipWs();
// Consumes `c` (after whitespace). False without advancing past `c` if the
// next non-ws char differs.
bool consume(char c);
// JSON string literal (escapes + \uXXXX incl. surrogate pairs -> UTF-8).
bool parseString(std::string& out);
// Bare token (number / true / false / null) up to the next structural char.
bool parseRawScalar(std::string& out);
// Numbers: full-token parse; trailing bytes or ERANGE reject. parseInt
// additionally rejects values outside [INT_MIN, INT_MAX].
bool parseDouble(double& out);
bool parseInt64(std::int64_t& out);
bool parseInt(int& out);
bool parseBool(bool& out);
// Peeks for the `null` token; consumes it if present (wasNull=true),
// otherwise leaves the position untouched (wasNull=false). Returns false
// only on eof.
bool expectNullOr(bool& wasNull);
// An object member key + ':'.
bool parseKey(std::string& key);
// Homogeneous arrays. Appends to `out`; empty array is valid.
bool parseStringArray(std::vector<std::string>& out);
bool parseIntArray(std::vector<int>& out);
// Skips one value of any shape (string / object / array / bare scalar) —
// forward-compat for unknown keys.
bool skipValue();
// Captures the raw source text of one value verbatim (string-aware brace
// matching), so a nested blob can be handed to its own parser — the
// bank_book -> BankModel::deserialize seam.
bool captureValue(std::string& raw);
private:
const std::string& s_;
std::size_t pos_ = 0;
};
} // namespace reasampler::json
+462
View File
@@ -0,0 +1,462 @@
#include "core/model/bank_book.h"
#include <algorithm>
#include <unordered_set>
// bank_book implementation — the registry RULES half: construction, pool
// privileges, bank lifecycle, active bank, sample movement/removal, slot order,
// and the reference queries. The JSON round-trip half (serialize / deserialize —
// Q-W1's golden-literal-pinned byte format) lives in bank_book_json.cpp, compiled
// into the same bank_book target (the slot_map extraction shape: same header, a
// second TU). The one symbol both halves share is the private static
// BankBook::nameKey display-name folding rule (declared in bank_book.h).
namespace reasampler {
// SlotMap lives in core/model/slot_map.cpp (extracted Q-W1, T4-05).
// ---------------------------------------------------------------------------
// BankBook — construction + bank lookup
// ---------------------------------------------------------------------------
BankBook::BankBook() {
Bank pool;
pool.id = kPoolBankId;
pool.displayName = kPoolBankName;
pool.ordinal = 0;
banks_.push_back(std::move(pool));
activeBankId_ = kPoolBankId;
}
Bank* BankBook::bank(const std::string& id) {
for (auto& b : banks_)
if (b.id == id) return &b;
return nullptr;
}
const Bank* BankBook::bank(const std::string& id) const {
for (const auto& b : banks_)
if (b.id == id) return &b;
return nullptr;
}
BankModel* BankBook::index(const std::string& id) {
Bank* b = bank(id);
return b ? &b->index : nullptr;
}
const BankModel* BankBook::index(const std::string& id) const {
const Bank* b = bank(id);
return b ? &b->index : nullptr;
}
Bank& BankBook::pool() {
// The pool is seeded on construction and is un-deletable, so it always exists.
return *bank(kPoolBankId);
}
const Bank& BankBook::pool() const {
return *bank(kPoolBankId);
}
// ---------------------------------------------------------------------------
// Ordinal normalization
// ---------------------------------------------------------------------------
void BankBook::normalizeOrdinals() {
// Stable-sort by ordinal with the pool pinned first, then rewrite ordinals to a
// contiguous 0..N-1. Stability preserves the caller's relative order among banks
// that share (or, after a reorder shuffle, tie on) an ordinal.
std::stable_sort(banks_.begin(), banks_.end(), [](const Bank& a, const Bank& b) {
if (a.isPool() != b.isPool()) return a.isPool(); // pool always first
return a.ordinal < b.ordinal;
});
for (std::size_t i = 0; i < banks_.size(); ++i)
banks_[i].ordinal = static_cast<int>(i);
}
// ---------------------------------------------------------------------------
// Display-name uniqueness (trimmed + case-insensitive, ASCII)
// ---------------------------------------------------------------------------
// Folds a display name to its uniqueness key: strip leading/trailing ASCII
// whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share one
// key and cannot coexist. ASCII-only by design — the pure core carries no locale
// facility and must not grow one; bank names are short user labels, not full Unicode
// case-folding candidates. Private static member (Q-W5): the one folding rule shared
// with bank_book_json.cpp's parse-time duplicate-display-name coalesce.
std::string BankBook::nameKey(const std::string& s) {
std::size_t b = 0, e = s.size();
auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; };
while (b < e && isWs(s[b])) ++b;
while (e > b && isWs(s[e - 1])) --e;
std::string out;
out.reserve(e - b);
for (std::size_t i = b; i < e; ++i) {
char c = s[i];
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
out += c;
}
return out;
}
// True if any bank OTHER than `exceptId` already carries `name`'s uniqueness key. The
// exception lets renameBank accept a bank keeping (or re-casing/-spacing) its own name.
bool BankBook::displayNameTaken(const std::string& name, const std::string& exceptId) const {
const std::string key = nameKey(name);
for (const auto& b : banks_)
if (b.id != exceptId && nameKey(b.displayName) == key) return true;
return false;
}
// ---------------------------------------------------------------------------
// Bank lifecycle
// ---------------------------------------------------------------------------
bool BankBook::createBank(const std::string& id, const std::string& displayName) {
if (id.empty()) return false; // ids key the registry
if (id == kPoolBankId) return false; // reserved pool id
if (bank(id) != nullptr) return false; // duplicate id
// Display names are unique (trimmed + case-insensitive); the pool's "Pool" is a
// reserved name and is caught here like any other collision.
if (displayNameTaken(displayName, /*exceptId=*/id)) return false;
Bank b;
b.id = id;
b.displayName = displayName;
b.ordinal = static_cast<int>(banks_.size()); // append; normalize compacts it
banks_.push_back(std::move(b));
normalizeOrdinals();
return true;
}
bool BankBook::renameBank(const std::string& id, const std::string& displayName) {
if (id == kPoolBankId) return false; // pool is un-renamable
Bank* b = bank(id);
if (b == nullptr) return false;
// Reject a name already used by a DIFFERENT bank. Renaming a bank to its own
// current name (or a case/space variant of it) is a no-op success, not a
// rejection — exceptId=id excludes the bank itself from the collision scan.
if (displayNameTaken(displayName, /*exceptId=*/id)) return false;
b->displayName = displayName;
return true;
}
bool BankBook::deleteBank(const std::string& id) {
if (id == kPoolBankId) return false; // pool is un-deletable
auto it = std::find_if(banks_.begin(), banks_.end(),
[&](const Bank& b) { return b.id == id; });
if (it == banks_.end()) return false;
banks_.erase(it);
// If the active bank was the one deleted, fall back to the pool (invariant: the
// active id always names a live bank).
if (activeBankId_ == id) activeBankId_ = kPoolBankId;
normalizeOrdinals();
return true;
}
bool BankBook::reorderBank(const std::string& id, int newOrdinal) {
if (id == kPoolBankId) return false; // pool is pinned at ordinal 0
if (bank(id) == nullptr) return false;
// Work on the named banks as an ordered list (banks_ is already ordinal-sorted
// with the pool first, so named banks are banks_[1..]). Pull the target out and
// re-insert it at the requested position, clamped into the named-bank range
// [1..N], then rewrite ordinals contiguously. This is O(N) and obviously correct.
std::vector<Bank> named;
named.reserve(banks_.size());
for (auto& b : banks_)
if (!b.isPool()) named.push_back(std::move(b));
auto it = std::find_if(named.begin(), named.end(),
[&](const Bank& b) { return b.id == id; });
Bank moved = std::move(*it);
named.erase(it);
// Named ordinals are 1..N; convert to a 0-based insertion index into `named`.
const int hi = static_cast<int>(named.size()); // insert-at range is [0..size]
int insertAt = std::max(0, std::min(newOrdinal - 1, hi));
named.insert(named.begin() + insertAt, std::move(moved));
// Rebuild banks_: pool first, then the reordered named banks. Assign ordinals
// directly by position here — NOT via normalizeOrdinals(), whose stable_sort keys
// on the (now stale) ordinals and would undo the reinsertion order.
std::vector<Bank> rebuilt;
rebuilt.reserve(named.size() + 1);
rebuilt.push_back(std::move(pool()));
for (auto& b : named) rebuilt.push_back(std::move(b));
banks_ = std::move(rebuilt);
for (std::size_t i = 0; i < banks_.size(); ++i)
banks_[i].ordinal = static_cast<int>(i);
return true;
}
bool BankBook::evacuate(const std::string& id) {
if (id == kPoolBankId) return false; // pool is un-evacuable (it is the target)
Bank* src = bank(id);
if (src == nullptr) return false;
// Move every member into the pool, index-only, observing destination collapse.
// Snapshot the members first, then clear the source — BankModel has no bulk move,
// and adding into the pool must not alias the vector we are draining.
BankModel& poolIndex = pool().index;
const std::vector<Sample> members = src->index.all(); // copy
for (const auto& s : members)
poolIndex.add(s); // Added or Collapsed; either way the pool now holds the hash
src->index = BankModel{}; // leave the evacuated bank empty
return true;
}
// ---------------------------------------------------------------------------
// Active bank
// ---------------------------------------------------------------------------
bool BankBook::setActiveBank(const std::string& id) {
if (bank(id) == nullptr) return false; // unknown id never corrupts state
activeBankId_ = id;
return true;
}
BankModel& BankBook::activeIndex() {
// activeBankId_ always names a live bank; it falls back to the pool on delete.
return bank(activeBankId_)->index;
}
const BankModel& BankBook::activeIndex() const {
return bank(activeBankId_)->index;
}
// ---------------------------------------------------------------------------
// Sample movement (index-only)
// ---------------------------------------------------------------------------
namespace {
// Adds `s` to `dest` and maps the BankModel outcome onto the transfer outcome for
// the "gained a NEW entry" case (`gained`) vs the collapse case. Rejected outcomes
// (absolute path / empty id) cannot occur here: the sample already passed add() on
// the source side, so its path and id are already valid.
TransferResult applyDestAdd(BankModel& dest, const Sample& s, TransferResult gained) {
return dest.add(s) == AddResult::Collapsed ? TransferResult::Collapsed : gained;
}
} // namespace
TransferResult BankBook::moveSample(const std::string& sampleId,
const std::string& fromBankId,
const std::string& toBankId) {
Bank* from = bank(fromBankId);
Bank* to = bank(toBankId);
if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank;
if (fromBankId == toBankId) return TransferResult::RejectedSameBank;
const Sample* s = from->index.query(sampleId);
if (s == nullptr) return TransferResult::RejectedSampleAbsent;
// Copy the sample out before removing it: query returns a pointer into the
// source vector that remove() invalidates.
const Sample moved = *s;
from->index.remove(sampleId); // source loses the entry unconditionally on a move
return applyDestAdd(to->index, moved, TransferResult::Moved);
}
TransferResult BankBook::copySample(const std::string& sampleId,
const std::string& fromBankId,
const std::string& toBankId) {
Bank* from = bank(fromBankId);
Bank* to = bank(toBankId);
if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank;
if (fromBankId == toBankId) return TransferResult::RejectedSameBank;
const Sample* s = from->index.query(sampleId);
if (s == nullptr) return TransferResult::RejectedSampleAbsent;
const Sample copy = *s; // source entry is left intact
return applyDestAdd(to->index, copy, TransferResult::Copied);
}
// ---------------------------------------------------------------------------
// Sample removal (index-only) + the last-reference query
// ---------------------------------------------------------------------------
RemoveResult BankBook::removeSample(const std::string& sampleId,
const std::string& fromBankId,
RemoveScope scope) {
if (scope == RemoveScope::AllBanks) {
// Latent seam: purge the id from every bank that holds it. fromBankId is
// ignored (the id is dropped book-wide). Removed iff at least one drop landed.
bool any = false;
for (auto& b : banks_)
if (b.index.remove(sampleId)) {
b.slots.remove(sampleId); // keep SlotMap in sync: leave an empty gap
any = true;
}
return any ? RemoveResult::Removed : RemoveResult::RejectedSampleAbsent;
}
// ThisBank (default, the only surfaced verb): drop from the one named source bank.
Bank* from = bank(fromBankId);
if (from == nullptr) return RemoveResult::RejectedUnknownBank;
if (!from->index.remove(sampleId)) return RemoveResult::RejectedSampleAbsent;
from->slots.remove(sampleId); // keep SlotMap in sync: the removed sample's slot becomes a gap
return RemoveResult::Removed;
}
bool BankBook::updateSampleInPlace(const std::string& sampleId, const Sample& updated) {
for (auto& b : banks_)
if (b.index.query(sampleId) != nullptr)
return b.index.updateInPlace(sampleId, updated);
return false; // no bank holds the id
}
// ---------------------------------------------------------------------------
// Sample display order (L7) — SlotMap driven, index membership untouched
// ---------------------------------------------------------------------------
namespace {
// The bank's live sample ids in INDEX (insertion) order — the reconcile/migration seed.
std::vector<std::string> indexIds(const BankModel& idx) {
std::vector<std::string> ids;
for (const auto& s : idx.all()) ids.push_back(s.id);
return ids;
}
// Squares one bank's SlotMap with its index membership. A map with NO overlap with the
// index (the pre-L7 migration case, or a freshly-constructed bank) is seeded dense from
// insertion order; an existing map is reconciled (drop stale markers, append unmapped).
void reconcileBankSlots(Bank& b) {
const std::vector<std::string> live = indexIds(b.index);
if (b.slots.empty()) {
b.slots.resetDense(live); // migration / first-population default: dense, no gaps
return;
}
b.slots.reconcile(live); // partial map: keep positions, drop stale, append new
}
} // namespace
void BankBook::reconcileSlots() {
for (auto& b : banks_) reconcileBankSlots(b);
}
std::vector<std::string> BankBook::orderedSampleIds(const std::string& bankId) {
Bank* b = bank(bankId);
if (b == nullptr) return {};
reconcileBankSlots(*b); // ensure the map covers all live members
return b->slots.orderedIds();
}
bool BankBook::reorderSample(const std::string& id, const std::string& bankId,
int targetSlot) {
Bank* b = bank(bankId);
if (b == nullptr) return false;
if (b->index.query(id) == nullptr) return false; // bank does not hold the sample
reconcileBankSlots(*b); // complete the target space first
return b->slots.reorder(id, targetSlot); // gap-preserving; index untouched
}
bool BankBook::replaceSample(const std::string& newId, const std::string& oldId,
const std::string& bankId) {
if (newId == oldId) return false;
Bank* b = bank(bankId);
if (b == nullptr) return false;
// Both the dragged sample and the occupant must live in this bank.
if (b->index.query(newId) == nullptr) return false;
if (b->index.query(oldId) == nullptr) return false;
reconcileBankSlots(*b); // complete the map so oldId's slot is known
// Capture the target slot BEFORE any mutation so the position survives the removal.
const int targetSlot = b->slots.slotOf(oldId);
if (targetSlot < 0) return false; // occupant not positioned (shouldn't happen post-reconcile)
// POOL GUARD (settled): the occupant's index-removal must pass the SAME guard the
// remove verb applies. Commit the removal FIRST so a rejection is a true no-op (no
// slot mutation happened yet). removeSample(ThisBank) permits per-sample removal from
// any bank incl. the pool (per-sample remove is not a pool privilege), so it succeeds
// whenever the occupant exists — which we verified — but routing through it means a
// future pool-floor guard added to remove governs replace identically, one rule.
const RemoveResult r = removeSample(oldId, bankId, RemoveScope::ThisBank);
if (r != RemoveResult::Removed) return false; // guard rejected -> nothing changed
// Occupant gone from the index; now update the slot markers. Drop oldId's now-dangling
// marker to free the target slot, then move newId onto it. reorder onto an EMPTY slot
// places newId there exactly and empties newId's own (source) slot — the slot position
// is preserved and only its occupant changed, exactly the replace contract.
b->slots.remove(oldId);
b->slots.reorder(newId, targetSlot);
return true;
}
bool BankBook::hashReferencedElsewhere(const std::string& hash,
const std::string& exceptBankId) const {
if (hash.empty()) return false; // empty hashes never dedup (mirror findByHash)
for (const auto& b : banks_) {
if (b.id == exceptBankId) continue; // the removed-from bank is excluded
if (b.index.findByHash(hash) != nullptr) return true;
}
return false;
}
std::vector<std::string> BankBook::referencedPaths() const {
// Union across the whole book (pool first, then named banks in ordinal order —
// banks_ is kept ordinal-sorted). De-duplicate by exact string so a file a copy
// put in two banks appears once. Skip empty paths (they reference no file).
std::vector<std::string> paths;
std::unordered_set<std::string> seen;
for (const auto& b : banks_) {
for (const auto& s : b.index.all()) {
if (s.relativePath.empty()) continue;
if (seen.insert(s.relativePath).second)
paths.push_back(s.relativePath);
}
}
return paths;
}
// ===========================================================================
// JSON — serialize / deserialize / adoptBanks live in bank_book_json.cpp
// (Q-W5 extraction; byte-identical format, golden-literal-pinned by the Q-W1
// test). loadFromPersisted stays here: it is the load-source PRECEDENCE rule
// (banks-blob vs legacy vs empty), not the codec.
// ===========================================================================
// ---------------------------------------------------------------------------
// Active-bank cycle ordering (pure, free function — mirror of nextModeId)
// ---------------------------------------------------------------------------
std::string nextBankId(const std::vector<std::string>& orderedBankIds,
const std::string& currentBankId) {
if (orderedBankIds.empty()) return {}; // nothing to cycle to
for (std::size_t i = 0; i < orderedBankIds.size(); ++i) {
if (orderedBankIds[i] == currentBankId)
return orderedBankIds[(i + 1) % orderedBankIds.size()]; // wrap past the last
}
// Active id not in the list (stale/unknown) — jump to the first id as a sane
// home rather than returning "" (matches nextModeId's fallback).
return orderedBankIds.front();
}
BankBook BankBook::loadFromPersisted(const std::string& banksJson,
const std::string& legacyJson) {
// Precedence 1: the authoritative `banks` blob. A present-but-malformed blob is
// an error, not an absence — degrade to an empty book rather than falling through
// to a stale legacy key (which would resurrect superseded single-bank state).
if (!banksJson.empty()) {
auto book = deserialize(banksJson);
return book ? std::move(*book) : BankBook{};
}
// Precedence 2: no `banks` yet, but a legacy `bank_index` — one-way pool migration
// (deserialize's parse-time legacy path promotes it into the pool). A malformed
// legacy blob likewise degrades to empty.
if (!legacyJson.empty()) {
auto book = deserialize(legacyJson);
return book ? std::move(*book) : BankBook{};
}
// Precedence 3: a brand-new / never-captured project — a fresh empty book.
return BankBook{};
}
} // namespace reasampler
+29 -102
View File
@@ -10,9 +10,9 @@
// -- What it is --------------------------------------------------------------
//
// An ordered registry of banks. Each bank = { stable id, display name, ordinal,
// BankIndex }. The book WRAPS N BankIndex instances — bank_model / BankIndex are
// BankModel }. The book WRAPS N BankModel instances — bank_model / BankModel are
// UNTOUCHED (additive: no bankId on Sample). Movement of samples between banks is
// index-only (remove from source's BankIndex, add to destination's); files never
// index-only (remove from source's BankModel, add to destination's); files never
// relocate — banks are logical groupings over one shared file pool.
//
// -- The pool (privileged, not special-cased) --------------------------------
@@ -42,113 +42,30 @@
#include <utility>
#include <vector>
#include "bank_model.h"
#include "core/model/bank_model.h"
#include "core/model/slot_map.h"
namespace reasampler {
// Q-W1 interim: this god module re-namespaces in its own split wave; until then the
// clean model types it wraps live in reasampler::model.
using namespace model;
// The pool's fixed identity. The id is reserved: createBank rejects it, and the
// pool is always bank-zero. The name is fixed: renameBank rejects the pool.
inline constexpr const char* kPoolBankId = "pool";
inline constexpr const char* kPoolBankName = "Pool";
// SlotMap — the L7 gap-preserving display-position carrier for ONE bank (F2 settled:
// plain interchangeable slots, NOT M9 fixed/addressable slots). A slot is just a
// display position a sample id occupies; the map is sample id -> slot (>= 0). Gaps
// are first-class: a bank may have a sample at slot 1 with slot 0 empty (an empty
// first row above an occupied second row). At most one id per slot (a slot is never
// double-occupied) and at most one slot per id (an id sits in exactly one place).
//
// Position lives HERE, not on Sample (CLAUDE.md wrapping discipline): a copy of one
// sample into two banks may sit at different slots, so position is a per-bank display
// concern owned by the bank's membership. bank_model / Sample stay untouched.
//
// PURE: standard library only. Hard-tested to the bar of BankIndex's round-trip.
class SlotMap {
public:
// The slot an id occupies, or -1 if the id is not mapped. O(N).
int slotOf(const std::string& id) const;
// The id occupying `slot`, or "" if the slot is empty. O(N).
std::string idAt(int slot) const;
// The highest occupied slot, or -1 when the map is empty. Defines the append
// frontier and (with trailing-empty trim) the content extent.
int maxSlot() const;
// Ids in ASCENDING slot order (the deterministic display order). Empty slots
// produce no entry — the caller iterates occupants; sparse layout is a draw
// concern that reads slotOf/idAt, not this list.
std::vector<std::string> orderedIds() const;
// Places `id` at the next free slot after the last occupied one (append). If the
// id is already mapped it is first removed (leaving its old slot empty), then
// appended — an append never fills an earlier gap. No-op guard: empty id ignored.
void append(const std::string& id);
// Drops `id`'s mapping, LEAVING ITS SLOT EMPTY (no re-pack) so every other id
// keeps its position. Returns true if the id was mapped.
bool remove(const std::string& id);
// Moves `id` to `targetSlot`, gap-preserving (F3 reorder semantics):
// * target slot EMPTY -> `id` moves there; its old slot is left empty.
// * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and
// every occupant at slot >= targetSlot (except `id` itself) shifts up by one,
// preserving their relative order and never colliding. Matches file-manager
// reorder. Interior gaps between shifted occupants are preserved as-is
// (shift is +1 on each occupant, so the gap structure above the target is kept).
// * negative targetSlot is clamped to 0.
// Returns false (no mutation) if `id` is not mapped. Deterministic.
bool reorder(const std::string& id, int targetSlot);
// Rebuilds the map densely from `ids` in the given order (slot i = ids[i]),
// dropping any prior state. The migration path: a pre-L7 bank with no persisted
// slot data is seeded from its BankIndex insertion order, densely packed (no gaps),
// so it is visually identical on first post-L7 load. Empty/duplicate ids skipped.
void resetDense(const std::vector<std::string>& ids);
// Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left
// the index) and appends any live id that has NO mapping yet (a sample the index
// gained out-of-band). Slots of surviving ids are untouched (gaps preserved). Keeps
// the map consistent with the bank's membership without a re-pack. Deterministic:
// orphan appends follow `liveIds` order.
void reconcile(const std::vector<std::string>& liveIds);
bool empty() const { return entries_.empty(); }
std::size_t size() const { return entries_.size(); }
bool operator==(const SlotMap& o) const;
// JSON fragment (an array of {id, slot} objects, ascending slot). Emitted as the
// bank envelope's "slots" member by BankBook::serialize; parsed back by its parser.
// Round-trips losslessly with the rest of the bank.
std::string serialize() const;
// Builds a map from explicit (id, slot) pairs parsed from persisted JSON. Enforces
// the map invariants defensively against a hand-edited blob: a duplicate id keeps
// its FIRST occurrence; a slot already taken by a kept id drops the later pair
// (never double-occupies); an empty id or negative slot is dropped. The result is
// sorted ascending by slot. reconcile() against live membership runs afterward, so
// a lossy repair here degrades gracefully rather than corrupting lookup.
static SlotMap fromEntries(const std::vector<std::pair<std::string, int>>& pairs);
private:
struct Entry {
std::string id;
int slot = 0;
bool operator==(const Entry& o) const { return id == o.id && slot == o.slot; }
};
std::vector<Entry> entries_; // kept sorted ascending by slot (invariant)
void sortBySlot();
};
// SlotMap — extracted to its own TU/header pair (Q-W1, T4-05): core/model/slot_map.h.
// Included above because Bank carries one per bank.
// One bank: a stable id, a display name, an ordinal (tab/display order), and its
// own BankIndex. The pool is the bank whose id == kPoolBankId.
// own BankModel. The pool is the bank whose id == kPoolBankId.
struct Bank {
std::string id; // stable, persisted; the pool's is kPoolBankId
std::string displayName; // mutable for named banks; fixed "Pool" for the pool
int ordinal = 0; // display order; pool is 0, named banks 1..N
BankIndex index; // this bank's samples
BankModel index; // this bank's samples
SlotMap slots; // L7 display positions of this bank's samples (gap-preserving)
bool isPool() const { return id == kPoolBankId; }
@@ -251,10 +168,10 @@ public:
// bank — an invalid set never corrupts state.
bool setActiveBank(const std::string& id);
// The active bank's BankIndex — the index the capture layer adds to. Always
// The active bank's BankModel — the index the capture layer adds to. Always
// valid (the active id always names a live bank; it falls back to the pool).
BankIndex& activeIndex();
const BankIndex& activeIndex() const;
BankModel& activeIndex();
const BankModel& activeIndex() const;
// -- Sample movement (index-only; files never relocate) ------------------
@@ -334,7 +251,7 @@ public:
// Refreshes a sample IN PLACE wherever it lives in the book (M10 re-capture):
// finds the bank holding `sampleId` and replaces its entry with `updated`
// (order-preserving, no dedup — see BankIndex::updateInPlace). Scans banks in
// (order-preserving, no dedup — see BankModel::updateInPlace). Scans banks in
// ordinal order and updates the FIRST holder (a sample id is unique within a
// bank; the same id living in two banks via copy would update the earliest, which
// is acceptable — re-capture operates on the panel's focused single selection).
@@ -372,9 +289,9 @@ public:
Bank* bank(const std::string& id);
const Bank* bank(const std::string& id) const;
// The bank's BankIndex by id, or nullptr. Convenience over bank()->index.
BankIndex* index(const std::string& id);
const BankIndex* index(const std::string& id) const;
// The bank's BankModel by id, or nullptr. Convenience over bank()->index.
BankModel* index(const std::string& id);
const BankModel* index(const std::string& id) const;
// The pool (always present). Never null.
Bank& pool();
@@ -425,6 +342,16 @@ private:
std::vector<Bank> banks_; // ordinal order; banks_[0] is always the pool
std::string activeBankId_; // always names a live bank; defaults to pool
// Folds a display name to its uniqueness key: strip leading/trailing ASCII
// whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share
// one key and cannot coexist. ASCII-only by design — the pure core carries no
// locale facility and must not grow one. A private STATIC member (Q-W5, settled)
// because BOTH halves of the split implementation need the ONE folding rule: the
// rules TU (bank_book.cpp, displayNameTaken) and the JSON TU (bank_book_json.cpp,
// deserialize's duplicate-display-name coalesce) — a drifted second copy would let
// a parsed book violate the create/rename uniqueness invariant.
static std::string nameKey(const std::string& s);
// True if a bank OTHER than `exceptId` already carries `name`'s uniqueness key
// (trimmed + case-insensitive, ASCII). Backs the create/rename uniqueness check;
// pass exceptId=id to let a bank keep (or re-case/-space) its own name.
+309
View File
@@ -0,0 +1,309 @@
#include "core/model/bank_book.h"
#include <algorithm>
#include <utility>
#include <vector>
#include "core/json/json.h"
// bank_book JSON round-trip (Q-W5 extraction out of bank_book.cpp — same header,
// compiled into the same bank_book target; the slot_map second-TU shape). The
// registry RULES half stays in bank_book.cpp; the ONE shared symbol is the private
// static BankBook::nameKey folding rule (declared in bank_book.h) — the parse-time
// duplicate-display-name coalesce below must fold names EXACTLY as the create/rename
// uniqueness check does, or a parsed book could violate the in-model invariant.
//
// JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model
// and view_mode_model. The book blob nests one bank object per bank, each carrying that
// bank's BankModel serialized by bank_model's OWN writer (BankModel::serialize),
// so per-bank sample serialization stays owned by bank_model and is not duplicated
// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a
// raw "index" member whose value is the BankModel blob verbatim; the parser splits
// the book envelope, then hands each nested index blob straight to
// BankModel::deserialize. Ints use %d; strings are escaped by writeEscaped.
// BYTE-IDENTICAL to the pre-extraction writer — the Q-W1 golden-literal test pins it.
namespace reasampler {
// ===========================================================================
// JSON — writer
// ===========================================================================
namespace {
// Shared core/json emit helpers (Q-W1): the same escape set + %d rendering the
// prior file-local writer carried, so the emitted blob is byte-identical.
std::string intToStr(int v) { return json::numToStr(v); }
using ObjWriter = json::Writer;
} // namespace
std::string BankBook::serialize() const {
std::string out;
{
ObjWriter root(out);
root.keyRaw("version", intToStr(1));
root.keyStr("activeBank", activeBankId_);
// banks: array of { id, displayName, ordinal, index: <BankModel blob> }.
// The pool rides in as bank-zero, persisted identically to any named bank.
root.keyBegin("banks");
out += '[';
for (std::size_t i = 0; i < banks_.size(); ++i) {
if (i) out += ',';
ObjWriter b(out);
b.keyStr("id", banks_[i].id);
b.keyStr("displayName", banks_[i].displayName);
b.keyRaw("ordinal", intToStr(banks_[i].ordinal));
// The nested index is bank_model's own JSON, emitted verbatim so the
// per-sample shape stays owned by BankModel::serialize (not duplicated).
b.keyRaw("index", banks_[i].index.serialize());
// L7 display positions (gap-preserving). Absent on a pre-L7 blob; the
// parser defaults such a bank's slots from insertion order on load.
b.keyRaw("slots", banks_[i].slots.serialize());
}
out += ']';
} // root closes here (see bank_model note on NRVO + deferred close)
return out;
}
// ===========================================================================
// JSON — parser (recursive descent; std::nullopt on any malformed input, never UB)
// ===========================================================================
namespace {
// The book DOMAIN grammar over the shared core/json lexical layer (Q-W1).
// parseBank parses one bank object; parseSlots the "slots" array ([{id, slot},
// ...]) into (id, slot) pairs (empty array valid; the pair-level defensive
// repair — dupes/conflicts — lives in SlotMap::fromEntries); parseBook the root
// blob, distinguishing the legacy shape (a bare bank_index object: has
// "samples", no "banks") from the book shape (has "banks"): a legacy blob
// yields a single pool bank carrying the migrated index and an empty active id
// (⇒ pool). The member deserialize() adopts the result (duplicate-display-name
// coalesce + ordinal normalize + active resolve — the coalesce lives THERE, not
// here, because it folds through the private BankBook::nameKey these free
// functions cannot reach).
bool parseSlots(json::Reader& r, std::vector<std::pair<std::string, int>>& out);
bool parseBank(json::Reader& r, Bank& b) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return false; // a bank object must at least carry an id
bool haveId = false;
bool haveIndex = false;
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "id") {
if (!r.parseString(b.id)) return false;
haveId = true;
} else if (key == "displayName") {
if (!r.parseString(b.displayName)) return false;
} else if (key == "ordinal") {
if (!r.parseInt(b.ordinal)) return false;
} else if (key == "index") {
std::string raw;
if (!r.captureValue(raw)) return false;
auto idx = BankModel::deserialize(raw);
if (!idx) return false; // a malformed nested index fails the whole parse
b.index = std::move(*idx);
haveIndex = true;
} else if (key == "slots") {
// L7 display positions. Absent on a pre-L7 blob (the else-branch skips
// nothing because the key never appears); when present it drives the
// bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership.
std::vector<std::pair<std::string, int>> pairs;
if (!parseSlots(r, pairs)) return false;
b.slots = SlotMap::fromEntries(pairs);
} else {
if (!r.skipValue()) return false; // forward-compat unknown keys
}
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveId || b.id.empty()) return false; // id keys the registry
if (!haveIndex) return false; // every bank persists its index
return true;
}
bool parseSlots(json::Reader& r, std::vector<std::pair<std::string, int>>& out) {
out.clear();
if (!r.consume('[')) return false;
r.skipWs();
if (r.consume(']')) return true; // empty slot array — a bank with no positions yet
do {
if (!r.consume('{')) return false;
std::string id;
int slot = 0;
bool haveId = false, haveSlot = false;
do {
std::string k;
if (!r.parseKey(k)) return false;
if (k == "id") { if (!r.parseString(id)) return false; haveId = true; }
else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; }
else { if (!r.skipValue()) return false; } // forward-compat
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveId || !haveSlot) return false; // a slot entry needs both
out.emplace_back(std::move(id), slot);
} while (r.consume(','));
return r.consume(']');
}
bool parseBook(json::Reader& r, const std::string& raw, std::vector<Bank>& banks,
std::string& activeBank) {
banks.clear();
activeBank.clear();
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return false; // an empty object is neither shape → malformed
// Decide the shape by which structural key we saw. A "banks" key ⇒ book shape; a
// "samples" key with no "banks" ⇒ legacy shape (promote into the pool).
std::vector<Bank> parsedBanks;
bool sawBanks = false;
bool sawSamples = false;
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "banks") {
sawBanks = true;
if (!r.consume('[')) return false;
r.skipWs();
if (!r.consume(']')) {
do {
Bank b;
if (!parseBank(r, b)) return false;
parsedBanks.push_back(std::move(b));
} while (r.consume(','));
if (!r.consume(']')) return false;
}
} else if (key == "activeBank") {
if (!r.parseString(activeBank)) return false;
} else if (key == "samples") {
// Legacy marker. The legacy index is re-parsed from the whole input below
// (BankModel::deserialize owns that shape); here we only skip the value to
// keep the scan well-formed and note that we saw it.
sawSamples = true;
if (!r.skipValue()) return false;
} else {
if (!r.skipValue()) return false; // version, or unknown
}
} while (r.consume(','));
if (!r.consume('}')) return false;
r.skipWs();
if (!r.eof()) return false; // trailing garbage
// --- Legacy migration: a bare bank_index (samples, no banks) → pool. ---
if (!sawBanks) {
if (!sawSamples) return false; // neither shape's marker → malformed
auto legacy = BankModel::deserialize(raw);
if (!legacy) return false;
Bank pool;
pool.id = kPoolBankId;
pool.displayName = kPoolBankName;
pool.ordinal = 0;
pool.index = std::move(*legacy);
banks.push_back(std::move(pool)); // { pool } with zero named banks
activeBank.clear(); // ⇒ pool (default) after adoption
return true;
}
// --- Book shape: the parsed banks ARE the book (pool folded in). ---
// The pool must be present as bank-zero (serialize always emits it). Reject a
// book blob that omits it rather than silently re-seeding — a book without its
// pool is malformed, not a legacy blob.
bool hasPool = std::any_of(parsedBanks.begin(), parsedBanks.end(),
[](const Bank& b) { return b.isPool(); });
if (!hasPool) return false;
// Reject duplicate bank ids (ids key the registry; a dup would corrupt lookup).
for (std::size_t i = 0; i < parsedBanks.size(); ++i)
for (std::size_t j = i + 1; j < parsedBanks.size(); ++j)
if (parsedBanks[i].id == parsedBanks[j].id) return false;
// Force the pool's fixed display name — it is not user-mutable, so we do not
// trust a persisted override for it (keeps kPoolBankName authoritative).
for (auto& b : parsedBanks)
if (b.isPool()) b.displayName = kPoolBankName;
banks = std::move(parsedBanks);
return true;
}
} // namespace
void BankBook::adoptBanks(std::vector<Bank>&& banks, const std::string& activeBank) {
banks_ = std::move(banks);
normalizeOrdinals();
// Resolve the active bank defensively: fall back to the pool if the persisted id
// names no bank, so a corrupt active id never leaves a dangling capture target.
activeBankId_ = (bank(activeBank) != nullptr) ? activeBank : std::string(kPoolBankId);
}
std::optional<BankBook> BankBook::deserialize(const std::string& blob) {
std::vector<Bank> banks;
std::string activeBank;
json::Reader r(blob);
if (!parseBook(r, blob, banks, activeBank)) return std::nullopt;
// --- Coalesce duplicate folded display names (B4 re-review fold-in). --------
// The in-model create/rename path enforces unique display names under nameKey,
// but a hand-edited .rpp blob can smuggle in two banks whose names fold to the
// same key ("Drums" and " drums "). Rejecting the whole book over one collision
// would degrade the user's entire library to empty, so instead we AUTO-
// DISAMBIGUATE the later duplicate deterministically: scan in parse order, and
// the first time a folded key repeats, suffix that bank's display name (" 2",
// " 3", …) until its folded key is unique among all names seen so far. The FIRST
// bank to carry a key keeps its name verbatim; only subsequent collisions are
// renamed. No bank or sample is lost, and ids are untouched. The pool is included
// in the seen-set (its "Pool" key is reserved) so a named bank folding to "pool"
// is disambiguated away from it, never the reverse.
//
// Hosted HERE (a static member, Q-W5) rather than in the free parseBook because
// it folds through the PRIVATE BankBook::nameKey — the same rule the
// create/rename uniqueness check applies. Runs after parseBook on BOTH shapes;
// the legacy path yields { pool } alone, where the scan is a trivial no-op.
{
std::vector<std::string> seenKeys;
seenKeys.reserve(banks.size());
for (auto& b : banks) {
if (b.isPool()) { // pool's name is fixed; reserve its key
seenKeys.push_back(nameKey(b.displayName));
continue;
}
const auto taken = [&](const std::string& k) {
return std::find(seenKeys.begin(), seenKeys.end(), k) != seenKeys.end();
};
std::string key = nameKey(b.displayName);
if (taken(key)) {
// Suffix with an ascending integer until the folded key is free. Guard
// against a pathological blob whose base name already ends in a number
// by folding the candidate each attempt (nameKey normalizes it).
const std::string base = b.displayName;
for (int n = 2;; ++n) {
const std::string candidate = base + " " + std::to_string(n);
const std::string candKey = nameKey(candidate);
if (!taken(candKey)) {
b.displayName = candidate;
key = candKey;
break;
}
}
}
seenKeys.push_back(key);
}
}
BankBook book;
book.adoptBanks(std::move(banks), activeBank);
return book;
}
} // namespace reasampler
+461
View File
@@ -0,0 +1,461 @@
#include "core/model/bank_model.h"
#include <cctype>
#include "core/json/json.h"
// bank_model implementation.
//
// JSON rides on the shared core/json lexical layer (Q-W1: one reader/writer,
// no per-module Parser copy). The field set is a flat struct of primitives,
// strings, one enum, a small string array, and a few optionals, so a compact
// writer + recursive-descent DOMAIN parser over json::Reader is the simplest
// thing that works. Doubles are emitted with 17 significant digits (%.17g), the
// shortest form that round-trips every IEEE-754 double exactly, so the
// deserialize(serialize(x)) == x invariant holds bit-for-bit.
namespace reasampler::model {
// ---------------------------------------------------------------------------
// equality
// ---------------------------------------------------------------------------
bool SourceRange::operator==(const SourceRange& o) const {
return startSeconds == o.startSeconds && endSeconds == o.endSeconds &&
startPpq == o.startPpq && endPpq == o.endPpq;
}
bool Provenance::operator==(const Provenance& o) const {
return parentSampleId == o.parentSampleId && fxChainSnapshot == o.fxChainSnapshot;
}
bool Levels::operator==(const Levels& o) const {
return peakDb == o.peakDb && rmsDb == o.rmsDb && lufs == o.lufs;
}
bool LoopPoints::operator==(const LoopPoints& o) const {
return start == o.start && end == o.end;
}
bool Sample::operator==(const Sample& o) const {
return id == o.id && displayName == o.displayName && relativePath == o.relativePath &&
sourceMode == o.sourceMode && sourceRange == o.sourceRange &&
trackGuids == o.trackGuids && wetDry == o.wetDry &&
channelCount == o.channelCount && sampleRate == o.sampleRate &&
lengthSeconds == o.lengthSeconds && lengthBeats == o.lengthBeats &&
captureTempo == o.captureTempo &&
captureTimeSigNum == o.captureTimeSigNum &&
captureTimeSigDenom == o.captureTimeSigDenom && key == o.key &&
rootNote == o.rootNote && loop == o.loop && levels == o.levels &&
clipped == o.clipped && tier == o.tier && contentHash == o.contentHash &&
provenance == o.provenance && createdTimestamp == o.createdTimestamp;
}
// ---------------------------------------------------------------------------
// path invariant
// ---------------------------------------------------------------------------
// DECISION: reject absolute paths rather than normalize them. The pure model has
// no knowledge of the project root, so it cannot correctly relativize an absolute
// path — any "normalization" would be a guess that could point at the wrong file.
// Rejecting at the boundary is honest and deterministic; the capture backend (M3)
// is responsible for handing us an already-relative path. Covers POSIX ("/x"),
// Windows drive ("C:\x", "C:/x", "C:foo" drive-relative), and UNC ("\\host\share")
// forms. Any leading <alpha>: is rejected regardless of the character that follows —
// drive-relative paths ("C:foo.wav") resolve against the drive's current directory,
// not the project root, so they violate the relative-paths-only invariant just as
// much as "C:\foo.wav" does.
static bool isAbsolutePath(const std::string& p) {
if (p.empty()) return false;
if (p[0] == '/' || p[0] == '\\') return true; // POSIX root or UNC
if (p.size() >= 2 && std::isalpha(static_cast<unsigned char>(p[0])) && p[1] == ':')
return true; // Windows drive (C:\, C:/, C:foo, C:)
return false;
}
// ---------------------------------------------------------------------------
// BankModel
// ---------------------------------------------------------------------------
AddResult BankModel::add(const Sample& sample) {
if (sample.id.empty()) return AddResult::RejectedEmptyId;
if (isAbsolutePath(sample.relativePath)) return AddResult::RejectedAbsolutePath;
if (findByHash(sample.contentHash) != nullptr)
return AddResult::Collapsed;
samples_.push_back(sample);
return AddResult::Added;
}
bool BankModel::remove(const std::string& id) {
for (auto it = samples_.begin(); it != samples_.end(); ++it) {
if (it->id == id) {
samples_.erase(it);
return true;
}
}
return false;
}
bool BankModel::updateInPlace(const std::string& id, const Sample& updated) {
if (isAbsolutePath(updated.relativePath)) return false; // invariant still holds
for (auto& s : samples_) {
if (s.id == id) {
s = updated; // replace in place — position (insertion order) preserved
return true;
}
}
return false;
}
const Sample* BankModel::query(const std::string& id) const {
for (const auto& s : samples_)
if (s.id == id) return &s;
return nullptr;
}
const Sample* BankModel::findByHash(const std::string& contentHash) const {
if (contentHash.empty()) return nullptr; // empty hashes never dedup
for (const auto& s : samples_)
if (s.contentHash == contentHash) return &s;
return nullptr;
}
bool BankModel::moveTier(const std::string& id, Tier tier) {
for (auto& s : samples_) {
if (s.id == id) {
s.tier = tier;
return true;
}
}
return false;
}
std::vector<Sample> BankModel::byTier(Tier tier) const {
std::vector<Sample> out;
for (const auto& s : samples_)
if (s.tier == tier) out.push_back(s);
return out;
}
// ---------------------------------------------------------------------------
// JSON writer
// ---------------------------------------------------------------------------
namespace {
using json::numToStr;
using json::writeEscaped;
using json::writeStringArray;
using ObjWriter = json::Writer;
void writeSample(std::string& out, const Sample& s) {
ObjWriter w(out);
w.keyStr("id", s.id);
w.keyStr("displayName", s.displayName);
w.keyStr("relativePath", s.relativePath);
w.keyRaw("sourceMode", numToStr(static_cast<int>(s.sourceMode)));
w.keyBegin("sourceRange");
{
ObjWriter r(out);
r.keyRaw("startSeconds", numToStr(s.sourceRange.startSeconds));
r.keyRaw("endSeconds", numToStr(s.sourceRange.endSeconds));
r.keyRaw("startPpq", numToStr(s.sourceRange.startPpq));
r.keyRaw("endPpq", numToStr(s.sourceRange.endPpq));
}
w.keyBegin("trackGuids");
writeStringArray(out, s.trackGuids);
w.keyRaw("wetDry", numToStr(s.wetDry));
w.keyRaw("channelCount", numToStr(s.channelCount));
w.keyRaw("sampleRate", numToStr(s.sampleRate));
w.keyRaw("lengthSeconds", numToStr(s.lengthSeconds));
w.keyRaw("lengthBeats", numToStr(s.lengthBeats));
w.keyRaw("captureTempo", numToStr(s.captureTempo));
w.keyRaw("captureTimeSigNum", numToStr(s.captureTimeSigNum));
w.keyRaw("captureTimeSigDenom", numToStr(s.captureTimeSigDenom));
// Optionals are emitted as null when absent so present/absent round-trips.
w.keyBegin("key");
if (s.key) writeEscaped(out, *s.key); else out += "null";
// Phase S seam fields (D-B). Emitted as null when absent (same shape as `key`
// and `provenance`) so pre-Phase-S JSON — which lacks these keys entirely —
// parses to empty optionals and re-serializes without invention.
w.keyBegin("rootNote");
if (s.rootNote) out += numToStr(*s.rootNote); else out += "null";
w.keyBegin("loop");
if (s.loop) {
ObjWriter lp(out);
lp.keyRaw("start", numToStr(s.loop->start));
lp.keyRaw("end", numToStr(s.loop->end));
} else {
out += "null";
}
w.keyBegin("levels");
{
ObjWriter l(out);
l.keyRaw("peakDb", numToStr(s.levels.peakDb));
l.keyRaw("rmsDb", numToStr(s.levels.rmsDb));
l.keyRaw("lufs", numToStr(s.levels.lufs));
}
w.keyRaw("clipped", s.clipped ? "true" : "false");
w.keyRaw("tier", numToStr(static_cast<int>(s.tier)));
w.keyStr("contentHash", s.contentHash);
w.keyBegin("provenance");
if (s.provenance) {
ObjWriter p(out);
p.keyStr("parentSampleId", s.provenance->parentSampleId);
p.keyStr("fxChainSnapshot", s.provenance->fxChainSnapshot);
} else {
out += "null";
}
w.keyRaw("createdTimestamp", numToStr(s.createdTimestamp));
}
} // namespace
std::string BankModel::serialize() const {
std::string out;
{
ObjWriter root(out);
root.keyRaw("version", numToStr(1));
root.keyBegin("samples");
out += '[';
for (std::size_t i = 0; i < samples_.size(); ++i) {
if (i) out += ',';
writeSample(out, samples_[i]);
}
out += ']';
} // root closes the object here — not deferred to function return (NRVO would
// otherwise let the caller observe `out` before the closing brace is appended)
return out;
}
// ---------------------------------------------------------------------------
// JSON parser (recursive descent over the shared json::Reader). Returns false
// on any malformed input; never reads out of bounds. Only supports the subset
// our writer emits. The lexical layer (strings, numbers, skip) lives in
// core/json; only the Sample/index DOMAIN grammar lives here.
// ---------------------------------------------------------------------------
namespace {
bool parseSample(json::Reader& r, Sample& s) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return true; // empty object (shouldn't happen, but valid)
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "id") {
if (!r.parseString(s.id)) return false;
} else if (key == "displayName") {
if (!r.parseString(s.displayName)) return false;
} else if (key == "relativePath") {
if (!r.parseString(s.relativePath)) return false;
} else if (key == "sourceMode") {
int v = 0;
if (!r.parseInt(v)) return false;
// Valid range: MasterMix(0) .. Realtime(5).
if (v < static_cast<int>(SourceMode::MasterMix) ||
v > static_cast<int>(SourceMode::Realtime))
return false;
s.sourceMode = static_cast<SourceMode>(v);
} else if (key == "sourceRange") {
if (!r.consume('{')) return false;
do {
std::string rk;
if (!r.parseKey(rk)) return false;
double dv = 0.0;
if (!r.parseDouble(dv)) return false;
if (rk == "startSeconds") s.sourceRange.startSeconds = dv;
else if (rk == "endSeconds") s.sourceRange.endSeconds = dv;
else if (rk == "startPpq") s.sourceRange.startPpq = dv;
else if (rk == "endPpq") s.sourceRange.endPpq = dv;
} while (r.consume(','));
if (!r.consume('}')) return false;
} else if (key == "trackGuids") {
if (!r.consume('[')) return false;
r.skipWs();
if (!r.consume(']')) {
do {
std::string g;
if (!r.parseString(g)) return false;
s.trackGuids.push_back(g);
} while (r.consume(','));
if (!r.consume(']')) return false;
}
} else if (key == "wetDry") {
if (!r.parseDouble(s.wetDry)) return false;
} else if (key == "channelCount") {
if (!r.parseInt(s.channelCount)) return false;
} else if (key == "sampleRate") {
if (!r.parseInt(s.sampleRate)) return false;
} else if (key == "lengthSeconds") {
if (!r.parseDouble(s.lengthSeconds)) return false;
} else if (key == "lengthBeats") {
if (!r.parseDouble(s.lengthBeats)) return false;
} else if (key == "captureTempo") {
if (!r.parseDouble(s.captureTempo)) return false;
} else if (key == "captureTimeSigNum") {
if (!r.parseInt(s.captureTimeSigNum)) return false;
} else if (key == "captureTimeSigDenom") {
if (!r.parseInt(s.captureTimeSigDenom)) return false;
} else if (key == "key") {
bool wasNull = false;
if (!r.expectNullOr(wasNull)) return false;
if (wasNull) {
s.key.reset();
} else {
std::string k;
if (!r.parseString(k)) return false;
s.key = k;
}
} else if (key == "rootNote") {
bool wasNull = false;
if (!r.expectNullOr(wasNull)) return false;
if (wasNull) {
s.rootNote.reset();
} else {
int v = 0;
if (!r.parseInt(v)) return false;
// Valid MIDI note range: 0..127 inclusive (boundaries valid).
if (v < 0 || v > 127) return false;
s.rootNote = v;
}
} else if (key == "loop") {
bool wasNull = false;
if (!r.expectNullOr(wasNull)) return false;
if (wasNull) {
s.loop.reset();
} else {
if (!r.consume('{')) return false;
LoopPoints lp;
do {
std::string lk;
if (!r.parseKey(lk)) return false;
std::int64_t lv = 0;
if (!r.parseInt64(lv)) return false;
if (lk == "start") lp.start = lv;
else if (lk == "end") lp.end = lv;
} while (r.consume(','));
if (!r.consume('}')) return false;
// Invariant: 0 <= start <= end. start == end is a valid zero-length
// marker; a negative index or start > end is malformed, not silently
// clamped (mirrors the enum-range rejection above).
if (lp.start < 0 || lp.end < lp.start) return false;
s.loop = lp;
}
} else if (key == "levels") {
if (!r.consume('{')) return false;
do {
std::string lk;
if (!r.parseKey(lk)) return false;
double dv = 0.0;
if (!r.parseDouble(dv)) return false;
if (lk == "peakDb") s.levels.peakDb = dv;
else if (lk == "rmsDb") s.levels.rmsDb = dv;
else if (lk == "lufs") s.levels.lufs = dv;
} while (r.consume(','));
if (!r.consume('}')) return false;
} else if (key == "clipped") {
if (!r.parseBool(s.clipped)) return false;
} else if (key == "tier") {
int v = 0;
if (!r.parseInt(v)) return false;
// Valid range: Scratch(0) .. Archive(1).
if (v < static_cast<int>(Tier::Scratch) ||
v > static_cast<int>(Tier::Archive))
return false;
s.tier = static_cast<Tier>(v);
} else if (key == "contentHash") {
if (!r.parseString(s.contentHash)) return false;
} else if (key == "provenance") {
bool wasNull = false;
if (!r.expectNullOr(wasNull)) return false;
if (wasNull) {
s.provenance.reset();
} else {
if (!r.consume('{')) return false;
Provenance p;
do {
std::string pk;
if (!r.parseKey(pk)) return false;
std::string pv;
if (!r.parseString(pv)) return false;
if (pk == "parentSampleId") p.parentSampleId = pv;
else if (pk == "fxChainSnapshot") p.fxChainSnapshot = pv;
} while (r.consume(','));
if (!r.consume('}')) return false;
s.provenance = p;
}
} else if (key == "createdTimestamp") {
if (!r.parseInt64(s.createdTimestamp)) return false;
} else {
if (!r.skipValue()) return false; // forward-compat: ignore unknown
}
} while (r.consume(','));
return r.consume('}');
}
bool parseIndex(json::Reader& r, BankModel& out) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return true; // empty object — vacuously an empty index
std::vector<Sample> parsed;
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "samples") {
if (!r.consume('[')) return false;
r.skipWs();
if (!r.consume(']')) {
do {
Sample s;
if (!parseSample(r, s)) return false;
parsed.push_back(std::move(s));
} while (r.consume(','));
if (!r.consume(']')) return false;
}
} else {
if (!r.skipValue()) return false; // version, or unknown keys
}
} while (r.consume(','));
if (!r.consume('}')) return false;
// Trailing garbage after the root object is malformed.
r.skipWs();
if (!r.eof()) return false;
// Rebuild via add() so the same invariants (relative-path, dedup) that guard
// live inserts also guard deserialized data. Rejected/collapsed entries are
// dropped silently — a well-formed serialized index never triggers them.
for (auto& s : parsed) out.add(s);
return true;
}
} // namespace
std::optional<BankModel> BankModel::deserialize(const std::string& blob) {
BankModel idx;
json::Reader r(blob);
if (!parseIndex(r, idx)) return std::nullopt;
return idx;
}
} // namespace reasampler::model
@@ -1,7 +1,7 @@
#pragma once
// bank_model — the HEART of ReaSampler, deliberately free of any REAPER type so
// it compiles and unit-tests OUTSIDE the DAW. It owns the per-project sample
// bank: the `Sample` metadata struct and the `BankIndex` (add / remove / query /
// bank: the `Sample` metadata struct and the `BankModel` (add / remove / query /
// tier moves / dedup-by-hash + JSON round-trip to/from std::string).
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
@@ -12,7 +12,7 @@
#include <string>
#include <vector>
namespace reasampler {
namespace reasampler::model {
// How the source audio was obtained. Kept in the pure core (no REAPER coupling);
// the capture backends (M3/M8) map their own notion onto these.
@@ -81,7 +81,7 @@ struct LoopPoints {
// The metadata record for one captured sample. The audio itself lives in a
// project-relative file; `relativePath` is ALWAYS relative (enforced at the
// BankIndex::add boundary — see AddResult).
// BankModel::add boundary — see AddResult).
struct Sample {
std::string id; // stable unique id (assigned by the caller)
std::string displayName;
@@ -128,7 +128,7 @@ struct Sample {
Tier tier = Tier::Scratch;
std::string contentHash; // dedup key (see BankIndex)
std::string contentHash; // dedup key (see BankModel)
std::optional<Provenance> provenance; // set only when resampled
@@ -141,7 +141,7 @@ struct Sample {
bool isAutoPrunable() const { return tier == Tier::Scratch; }
};
// Outcome of BankIndex::add. `add` rejects rather than silently mutating:
// Outcome of BankModel::add. `add` rejects rather than silently mutating:
// - RejectedAbsolutePath: relativePath was absolute (precision invariant).
// - RejectedEmptyId: id was empty (the collection is keyed by id).
// - Collapsed: content hash matched an existing entry; the existing
@@ -157,7 +157,7 @@ enum class AddResult {
// An ordered, id-keyed collection of Samples with content-hash dedup, tier
// moves/filtering, and lossless JSON round-trip. Insertion order is preserved
// so a future panel (M5) can iterate in stable order.
class BankIndex {
class BankModel {
public:
// Adds a sample. Enforces the relative-paths-only invariant and dedups by
// content hash (an equal-hash add collapses onto the existing entry rather
@@ -200,7 +200,7 @@ public:
std::size_t size() const { return samples_.size(); }
bool empty() const { return samples_.empty(); }
bool operator==(const BankIndex& o) const { return samples_ == o.samples_; }
bool operator==(const BankModel& o) const { return samples_ == o.samples_; }
// Serializes the whole index to a JSON string (lossless round-trip).
std::string serialize() const;
@@ -208,10 +208,10 @@ public:
// Parses a JSON string produced by serialize(). Returns std::nullopt on
// malformed / truncated input (error signaled, never UB). On success the
// returned index satisfies deserialize(serialize(x)) == x.
static std::optional<BankIndex> deserialize(const std::string& json);
static std::optional<BankModel> deserialize(const std::string& json);
private:
std::vector<Sample> samples_; // insertion order preserved
};
} // namespace reasampler
} // namespace reasampler::model
+113
View File
@@ -0,0 +1,113 @@
#include "core/model/owned_manifest.h"
#include <cctype>
#include "core/json/json.h"
// owned_manifest implementation.
//
// JSON rides on the shared core/json lexical layer (Q-W1, mirror of bank_model /
// bank_book / tail_control). The shape is a single object with one string array:
//
// {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]}
//
// so a compact writer + a focused string-array domain parse is all it needs.
namespace reasampler::model {
// ---------------------------------------------------------------------------
// path invariant (mirror of bank_model's isAbsolutePath)
// ---------------------------------------------------------------------------
namespace {
// Any leading '/' or '\' (POSIX root / UNC), or a leading <alpha>: (Windows drive,
// incl. drive-relative "C:foo") is absolute. Same rejection bank_model applies to
// Sample.relativePath — the manifest holds the SAME kind of path, so the invariant
// must match exactly (a path the index accepts must be recordable, and vice versa).
bool isAbsolutePath(const std::string& p) {
if (p.empty()) return false;
if (p[0] == '/' || p[0] == '\\') return true;
if (p.size() >= 2 && std::isalpha(static_cast<unsigned char>(p[0])) && p[1] == ':')
return true;
return false;
}
} // namespace
// ---------------------------------------------------------------------------
// mutation / query
// ---------------------------------------------------------------------------
ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) {
if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath;
if (isAbsolutePath(relativePath)) return ManifestAddResult::RejectedAbsolutePath;
if (contains(relativePath)) return ManifestAddResult::AlreadyPresent;
paths_.push_back(relativePath);
return ManifestAddResult::Added;
}
bool OwnedFileManifest::contains(const std::string& relativePath) const {
for (const auto& p : paths_)
if (p == relativePath) return true;
return false;
}
// ---------------------------------------------------------------------------
// JSON writer (shared core/json escape — byte-identical to the prior local one)
// ---------------------------------------------------------------------------
std::string OwnedFileManifest::serialize() const {
std::string out = "{\"owned\":[";
for (std::size_t i = 0; i < paths_.size(); ++i) {
if (i) out += ',';
json::writeEscaped(out, paths_[i]);
}
out += "]}";
return out;
}
// ---------------------------------------------------------------------------
// JSON parser (string-array-only DOMAIN grammar over the shared core/json
// lexical layer). Tolerates unknown keys (forward-compat) and requires the
// "owned" value to be an array of strings.
// ---------------------------------------------------------------------------
namespace {
bool parseManifest(json::Reader& r, OwnedFileManifest& out) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return true; // empty object -> empty manifest
for (;;) {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "owned") {
std::vector<std::string> paths;
if (!r.parseStringArray(paths)) return false;
for (auto& p : paths) {
// Feed through add() so the persisted invariants (dedup, reject
// empty/absolute) are re-asserted on load — a hand-edited or corrupt
// blob cannot smuggle an absolute or duplicate path into the manifest.
out.add(p);
}
} else {
if (!r.skipValue()) return false; // forward-compat: tolerate unknown keys
}
r.skipWs();
if (r.consume(',')) continue;
if (r.consume('}')) return true;
return false;
}
}
} // namespace
std::optional<OwnedFileManifest> OwnedFileManifest::deserialize(const std::string& blob) {
OwnedFileManifest m;
json::Reader r(blob);
if (!parseManifest(r, m)) return std::nullopt;
return m;
}
} // namespace reasampler::model
@@ -3,7 +3,7 @@
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the same
// "small pure type + JSON round-trip" pattern as wav_trim / tab_strip.
// "small pure type + JSON round-trip" pattern as wav_codec / tab_strip.
//
// -- What it is --------------------------------------------------------------
//
@@ -23,17 +23,17 @@
// -- The relative-paths-only invariant ---------------------------------------
//
// A manifest path is ALWAYS project-relative (same invariant as Sample.relativePath
// and the persisted BankIndex). add() rejects an absolute path rather than guess a
// and the persisted BankModel). add() rejects an absolute path rather than guess a
// relativization — the pure model has no project root, so a "normalization" would be
// a guess that could point at the wrong file (mirror of BankIndex::add's rejection).
// a guess that could point at the wrong file (mirror of BankModel::add's rejection).
#include <optional>
#include <string>
#include <vector>
namespace reasampler {
namespace reasampler::model {
// Outcome of an add(). Mirrors BankIndex::AddResult's honesty — the op reports what
// Outcome of an add(). Mirrors BankModel::AddResult's honesty — the op reports what
// happened rather than silently mutating on a bad request.
// - Added: the path was new and recorded.
// - AlreadyPresent: the path was already in the manifest (dedup no-op).
@@ -88,4 +88,4 @@ private:
std::vector<std::string> paths_; // insertion order; deduplicated
};
} // namespace reasampler
} // namespace reasampler::model
@@ -1,7 +1,8 @@
#include "provenance.h"
#include "core/model/provenance.h"
#include <cstdio>
#include <cstdlib>
#include "core/wire/wire.h"
// provenance implementation — pure, self-contained (no third-party lib, mirror of
// bank_model's hand-rolled encoding discipline).
@@ -24,7 +25,7 @@
// injection-proof on its own and can be embedded whole as one more length-prefixed
// field of the fingerprint.
namespace reasampler {
namespace reasampler::model {
bool CaptureRecipe::operator==(const CaptureRecipe& o) const {
return scope == o.scope && sourceMode == o.sourceMode &&
@@ -38,12 +39,12 @@ namespace {
constexpr const char* kMagic = "rsprov1";
// Append one length-prefixed field: <decimal-len> ':' <bytes>
void putField(std::string& out, const std::string& field) {
out += std::to_string(field.size());
out += ':';
out += field;
}
// The shared core/wire codec (Q-W1, T2-01b) carries the field grammar + the full
// hardening (incl. the fixed fieldInt range check that closes the old strtol
// silent-narrowing TODO). Only the %.17g double rendering stays local — it is
// this writer's convention, shared with the bank model's JSON doubles.
using wire::putField;
using Cursor = wire::Cursor;
std::string dblToStr(double v) {
char buf[32];
@@ -51,91 +52,6 @@ std::string dblToStr(double v) {
return buf;
}
// Cursor over the encoded string. All reads are bounds-checked; any short read
// fails the whole parse (ok_ latches false).
class Cursor {
public:
explicit Cursor(const std::string& s) : s_(s) {}
bool ok() const { return ok_; }
bool atEnd() const { return pos_ >= s_.size(); }
// Reads one length-prefixed field into `out`. Fails on a missing ':',
// non-numeric length, or a length that runs past the end.
bool field(std::string& out) {
if (!ok_) return false;
std::size_t colon = s_.find(':', pos_);
if (colon == std::string::npos) return fail();
// Parse the length digits [pos_, colon).
std::size_t len = 0;
if (colon == pos_) return fail(); // empty length token
for (std::size_t i = pos_; i < colon; ++i) {
char c = s_[i];
if (c < '0' || c > '9') return fail();
len = len * 10 + static_cast<std::size_t>(c - '0');
}
const std::size_t start = colon + 1;
if (start + len > s_.size()) return fail();
out.assign(s_, start, len);
pos_ = start + len;
return true;
}
bool fieldInt(int& out) {
std::string f;
if (!field(f)) return false;
return toInt(f, out);
}
bool fieldSizeT(std::size_t& out) {
std::string f;
if (!field(f)) return false;
if (f.empty()) return fail();
std::size_t v = 0;
for (char c : f) {
if (c < '0' || c > '9') return fail();
v = v * 10 + static_cast<std::size_t>(c - '0');
}
out = v;
return true;
}
bool fieldDouble(double& out) {
std::string f;
if (!field(f)) return false;
const char* b = f.c_str();
char* end = nullptr;
double v = std::strtod(b, &end);
if (end != b + f.size()) return fail();
out = v;
return true;
}
// Consumes an exact literal at the cursor (the magic tag). Fails if absent.
bool literal(const char* lit) {
if (!ok_) return false;
const std::string l(lit);
if (s_.compare(pos_, l.size(), l) != 0) return fail();
pos_ += l.size();
return true;
}
private:
bool fail() { ok_ = false; return false; }
static bool toInt(const std::string& f, int& out) {
const char* b = f.c_str();
char* end = nullptr;
long v = std::strtol(b, &end, 10);
if (end != b + f.size() || f.empty()) return false;
out = static_cast<int>(v);
return true;
}
const std::string& s_;
std::size_t pos_ = 0;
bool ok_ = true;
};
} // namespace
std::string fxChainIdentity(const std::vector<FxIdentityEntry>& entries) {
@@ -196,6 +112,11 @@ std::optional<CaptureRecipe> parseFingerprint(const std::string& fingerprint) {
std::size_t guidCount = 0;
if (!c.fieldSizeT(guidCount)) return std::nullopt;
// Q-W0 T2-01a (the sample_usage count-sanity pattern): each GUID field costs at least
// 2 wire bytes ("0:"), so a count past size/2 is provably bogus — reject BEFORE the
// reserve, so a corrupt/crafted persisted fingerprint can never drive reserve(huge)
// into std::length_error / bad_alloc through the shell.
if (guidCount > fingerprint.size() / 2u + 1u) return std::nullopt;
r.trackGuids.reserve(guidCount);
for (std::size_t i = 0; i < guidCount; ++i) {
std::string g;
@@ -238,4 +159,4 @@ std::optional<std::string> detectParent(
return parent;
}
} // namespace reasampler
} // namespace reasampler::model
@@ -2,7 +2,7 @@
// provenance — the REAPER-free core behind Milestone 10 (re-capture from source).
//
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
// vendor/ includes. Standard library only. The shell (main.cpp / actions.cpp)
// vendor/ includes. Standard library only. The shell (main.cpp / the action families)
// gathers the raw inputs from REAPER — the source item media-file names, the
// source track FX-chain identity (names / GUIDs / enabled flags), the exact
// capture range, scope, tail — and hands plain strings/values here. This module
@@ -35,7 +35,7 @@
#include <string>
#include <vector>
namespace reasampler {
namespace reasampler::model {
// Capture scope, mirrored from render_settings' CaptureScope but kept independent
// here so the pure provenance module does not pull the whole render_settings graph
@@ -146,4 +146,4 @@ std::optional<std::string> detectParent(
const std::vector<std::string>& sourceItemFiles,
const std::vector<BankFileRef>& bankFiles);
} // namespace reasampler
} // namespace reasampler::model
+145
View File
@@ -0,0 +1,145 @@
#include "core/model/slot_map.h"
#include <algorithm>
#include "core/json/json.h"
// slot_map implementation (extracted from bank_book, Q-W1 T4-05).
//
// The invariant: entries_ is kept sorted ascending by slot, one id per slot, one
// slot per id. Every mutator restores it; queries assume it. serialize rides the
// shared core/json emit helpers — the emitted fragment is byte-identical to the
// pre-extraction bank_book writer.
namespace reasampler::model {
void SlotMap::sortBySlot() {
std::stable_sort(entries_.begin(), entries_.end(),
[](const Entry& a, const Entry& b) { return a.slot < b.slot; });
}
int SlotMap::slotOf(const std::string& id) const {
for (const auto& e : entries_)
if (e.id == id) return e.slot;
return -1;
}
std::string SlotMap::idAt(int slot) const {
for (const auto& e : entries_)
if (e.slot == slot) return e.id;
return {};
}
int SlotMap::maxSlot() const {
int m = -1;
for (const auto& e : entries_)
if (e.slot > m) m = e.slot;
return m;
}
std::vector<std::string> SlotMap::orderedIds() const {
// entries_ is sorted ascending by slot, so a straight walk is display order.
std::vector<std::string> out;
out.reserve(entries_.size());
for (const auto& e : entries_) out.push_back(e.id);
return out;
}
void SlotMap::append(const std::string& id) {
if (id.empty()) return;
remove(id); // an existing id is re-appended, not left in place
entries_.push_back(Entry{id, maxSlot() + 1}); // next free slot after the last occupied
sortBySlot();
}
bool SlotMap::remove(const std::string& id) {
for (auto it = entries_.begin(); it != entries_.end(); ++it) {
if (it->id == id) {
entries_.erase(it); // leaves the slot empty — no re-pack
return true;
}
}
return false;
}
bool SlotMap::reorder(const std::string& id, int targetSlot) {
if (slotOf(id) < 0) return false; // not mapped -> no mutation
if (targetSlot < 0) targetSlot = 0;
if (slotOf(id) == targetSlot) return false; // already there — true no-op
// Detach the moving id first so the occupancy test below sees the post-move world.
remove(id);
const bool occupied = !idAt(targetSlot).empty();
if (occupied) {
// Insert-before-and-shift: every occupant at slot >= targetSlot shifts up by one,
// preserving relative order and interior gaps above the target. The moving id then
// takes targetSlot cleanly.
for (auto& e : entries_)
if (e.slot >= targetSlot) ++e.slot;
}
entries_.push_back(Entry{id, targetSlot});
sortBySlot();
return true;
}
void SlotMap::resetDense(const std::vector<std::string>& ids) {
entries_.clear();
int slot = 0;
for (const auto& id : ids) {
if (id.empty()) continue;
if (slotOf(id) >= 0) continue; // skip a duplicate id (one slot per id)
entries_.push_back(Entry{id, slot++});
}
// Already ascending by construction; no sort needed.
}
void SlotMap::reconcile(const std::vector<std::string>& liveIds) {
// Drop markers whose sample left the index.
entries_.erase(
std::remove_if(entries_.begin(), entries_.end(),
[&](const Entry& e) {
return std::find(liveIds.begin(), liveIds.end(), e.id) ==
liveIds.end();
}),
entries_.end());
// Append live ids that have no mapping yet (out-of-band index growth), in liveIds
// order, each to the next free slot after the current frontier.
for (const auto& id : liveIds)
if (slotOf(id) < 0) append(id);
sortBySlot();
}
bool SlotMap::operator==(const SlotMap& o) const {
return entries_ == o.entries_;
}
SlotMap SlotMap::fromEntries(const std::vector<std::pair<std::string, int>>& pairs) {
SlotMap m;
for (const auto& [id, slot] : pairs) {
if (id.empty() || slot < 0) continue; // drop malformed pair
if (m.slotOf(id) >= 0) continue; // duplicate id: first wins
if (!m.idAt(slot).empty()) continue; // slot taken: never double-occupy
m.entries_.push_back(Entry{id, slot});
}
m.sortBySlot();
return m;
}
std::string SlotMap::serialize() const {
// Array of {id, slot} objects in ascending slot order (entries_ is kept sorted).
// json::Writer + numToStr are the same emit path the pre-extraction writer used,
// so the fragment is byte-identical.
std::string out;
out += '[';
for (std::size_t i = 0; i < entries_.size(); ++i) {
if (i) out += ',';
json::Writer e(out);
e.keyStr("id", entries_[i].id);
e.keyRaw("slot", json::numToStr(entries_[i].slot));
}
out += ']';
return out;
}
} // namespace reasampler::model
+106
View File
@@ -0,0 +1,106 @@
#pragma once
// slot_map — the L7 gap-preserving display-position carrier for ONE bank (F2 settled:
// plain interchangeable slots, NOT M9 fixed/addressable slots). A slot is just a
// display position a sample id occupies; the map is sample id -> slot (>= 0). Gaps
// are first-class: a bank may have a sample at slot 1 with slot 0 empty (an empty
// first row above an occupied second row). At most one id per slot (a slot is never
// double-occupied) and at most one slot per id (an id sits in exactly one place).
//
// Position lives HERE, not on Sample (CLAUDE.md wrapping discipline): a copy of one
// sample into two banks may sit at different slots, so position is a per-bank display
// concern owned by the bank's membership. bank_model / Sample stay untouched.
//
// Extracted from bank_book (Q-W1, T4-05): a self-contained ordered-slot container
// with its own serialize, distinct from the multi-bank registry that carries it.
// Behavior covered by bank_book_tests (the round-trip + reorder/reconcile suites);
// a dedicated slot_map_tests target is a welcome follow-up, not a Q-W1 requirement.
//
// PURE: standard library + core/json (serialize) only.
#include <cstddef>
#include <string>
#include <utility>
#include <vector>
namespace reasampler::model {
class SlotMap {
public:
// The slot an id occupies, or -1 if the id is not mapped. O(N).
int slotOf(const std::string& id) const;
// The id occupying `slot`, or "" if the slot is empty. O(N).
std::string idAt(int slot) const;
// The highest occupied slot, or -1 when the map is empty. Defines the append
// frontier and (with trailing-empty trim) the content extent.
int maxSlot() const;
// Ids in ASCENDING slot order (the deterministic display order). Empty slots
// produce no entry — the caller iterates occupants; sparse layout is a draw
// concern that reads slotOf/idAt, not this list.
std::vector<std::string> orderedIds() const;
// Places `id` at the next free slot after the last occupied one (append). If the
// id is already mapped it is first removed (leaving its old slot empty), then
// appended — an append never fills an earlier gap. No-op guard: empty id ignored.
void append(const std::string& id);
// Drops `id`'s mapping, LEAVING ITS SLOT EMPTY (no re-pack) so every other id
// keeps its position. Returns true if the id was mapped.
bool remove(const std::string& id);
// Moves `id` to `targetSlot`, gap-preserving (F3 reorder semantics):
// * target slot EMPTY -> `id` moves there; its old slot is left empty.
// * target slot OCCUPIED -> insert-before-and-shift: `id` takes targetSlot and
// every occupant at slot >= targetSlot (except `id` itself) shifts up by one,
// preserving their relative order and never colliding. Matches file-manager
// reorder. Interior gaps between shifted occupants are preserved as-is
// (shift is +1 on each occupant, so the gap structure above the target is kept).
// * negative targetSlot is clamped to 0.
// Returns false (no mutation) if `id` is not mapped. Deterministic.
bool reorder(const std::string& id, int targetSlot);
// Rebuilds the map densely from `ids` in the given order (slot i = ids[i]),
// dropping any prior state. The migration path: a pre-L7 bank with no persisted
// slot data is seeded from its BankModel insertion order, densely packed (no gaps),
// so it is visually identical on first post-L7 load. Empty/duplicate ids skipped.
void resetDense(const std::vector<std::string>& ids);
// Drops any mapping whose id is NOT in `liveIds` (a stale marker whose sample left
// the index) and appends any live id that has NO mapping yet (a sample the index
// gained out-of-band). Slots of surviving ids are untouched (gaps preserved). Keeps
// the map consistent with the bank's membership without a re-pack. Deterministic:
// orphan appends follow `liveIds` order.
void reconcile(const std::vector<std::string>& liveIds);
bool empty() const { return entries_.empty(); }
std::size_t size() const { return entries_.size(); }
bool operator==(const SlotMap& o) const;
// JSON fragment (an array of {id, slot} objects, ascending slot). Emitted as the
// bank envelope's "slots" member by BankBook::serialize; parsed back by its parser.
// Round-trips losslessly with the rest of the bank.
std::string serialize() const;
// Builds a map from explicit (id, slot) pairs parsed from persisted JSON. Enforces
// the map invariants defensively against a hand-edited blob: a duplicate id keeps
// its FIRST occurrence; a slot already taken by a kept id drops the later pair
// (never double-occupies); an empty id or negative slot is dropped. The result is
// sorted ascending by slot. reconcile() against live membership runs afterward, so
// a lossy repair here degrades gracefully rather than corrupting lookup.
static SlotMap fromEntries(const std::vector<std::pair<std::string, int>>& pairs);
private:
struct Entry {
std::string id;
int slot = 0;
bool operator==(const Entry& o) const { return id == o.id && slot == o.slot; }
};
std::vector<Entry> entries_; // kept sorted ascending by slot (invariant)
void sortBySlot();
};
} // namespace reasampler::model
@@ -1,4 +1,4 @@
#include "prune_reconcile.h"
#include "core/reclaim/prune_reconcile.h"
#include <unordered_set>
@@ -7,7 +7,7 @@
// keeping a path iff it is owned AND not referenced. Walking `present` (not owned)
// gives the ∩-present clause for free and yields output in folder-enumeration order.
namespace reasampler {
namespace reasampler::reclaim {
std::vector<std::string> pruneOrphans(const std::vector<std::string>& present,
const std::vector<std::string>& referenced,
@@ -83,4 +83,4 @@ std::vector<std::string> pruneDeletePlan(const std::vector<std::string>& confirm
return plan;
}
} // namespace reasampler
} // namespace reasampler::reclaim
@@ -32,7 +32,7 @@
// -- Path representation: EXACT-STRING match (safety-critical) -----------------
//
// Every path in the model is a project-relative string compared VERBATIM: Sample.
// relativePath, OwnedFileManifest::contains (p == relativePath), and BankIndex all
// relativePath, OwnedFileManifest::contains (p == relativePath), and BankModel all
// use raw std::string equality — no separator normalization, no case-folding, no
// trailing-slash trimming. This core MATCHES that convention exactly: it compares
// the raw strings the shell supplies. Feeding a consistent spelling across the three
@@ -46,7 +46,7 @@
#include <unordered_map>
#include <vector>
namespace reasampler {
namespace reasampler::reclaim {
// The dry-run prune result (Phase R, Wave 2 — report only, no deletion). The thin
// prune shell (persist) fills this from pruneOrphans() + a per-file size stat and hands
@@ -175,4 +175,4 @@ PruneReport buildPruneReport(const std::vector<std::string>& orphans,
std::vector<std::string> pruneDeletePlan(const std::vector<std::string>& confirmed,
const std::vector<std::string>& freshOrphans);
} // namespace reasampler
} // namespace reasampler::reclaim
@@ -1,10 +1,10 @@
// action_bar — pure implementation. See action_bar.h. NO REAPER / SWELL / LICE / vendor.
#include "action_bar.h"
#include "core/ui/action_bar.h"
#include <cstddef>
namespace reasampler {
namespace reasampler::ui {
namespace {
@@ -151,4 +151,4 @@ int hitTestActionBar(int px, int py, const ActionBarRect& bar,
return -1; // inter-button/cluster gap or the overflow dead-zone — a clean miss
}
} // namespace reasampler
} // namespace reasampler::ui
+4 -12
View File
@@ -1,4 +1,5 @@
#pragma once
#include "core/ui/rect.h"
// action_bar — the REAPER-free, LICE-free layout + hit-test math behind the bank_panel's
// TASK-GROUPED toolbars (Phase L, L2 + L4 + L6). L2's dock-panel layout redesign (DS-3: a
// thorough layout, not a re-skin) groups the action-trigger button inventory BY TASK — a compact
@@ -35,7 +36,7 @@
#include <vector>
namespace reasampler {
namespace reasampler::ui {
// The task cluster a button belongs to (the L2 "group by task" mandate). The order here is
// NOT itself the bar order — the caller passes ClusterSpecs in the order it wants; this enum
@@ -58,16 +59,7 @@ enum class ActionCluster {
// The bar the clusters are drawn into, top-left origin (SWELL/LICE convention). (x, y) is the
// top-left corner; width/height are the bar extents. The panel reserves this as a fixed-height
// band (its own judgment where — above the tail footer, below the split body).
struct ActionBarRect {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
bool operator==(const ActionBarRect& o) const {
return x == o.x && y == o.y && width == o.width && height == o.height;
}
};
using ActionBarRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// One visible button's placement within the bar, top-left origin. `index` is the button's
// position in the caller's flat action list (the caller supplies actions in cluster order, so
@@ -156,4 +148,4 @@ std::vector<ActionBarSlot> computeBarSlots(const ActionBarRect& bar,
int hitTestActionBar(int px, int py, const ActionBarRect& bar,
const std::vector<ClusterSpec>& clusters, const ActionBarSpec& spec);
} // namespace reasampler
} // namespace reasampler::ui
@@ -1,11 +1,11 @@
// bank_grid — pure implementation. See bank_grid.h. NO REAPER / SWELL / vendor.
#include "bank_grid.h"
#include "core/ui/bank_grid.h"
#include <algorithm>
#include <cmath>
namespace reasampler {
namespace reasampler::ui {
namespace {
@@ -224,4 +224,4 @@ float compressAmplitudeForDisplay(float linear) {
return linear < 0.0f ? -clamped : clamped;
}
} // namespace reasampler
} // namespace reasampler::ui
+6 -14
View File
@@ -1,6 +1,7 @@
#pragma once
#include "core/ui/rect.h"
// bank_grid — the REAPER-free layout math and cache-key logic behind the docked
// bank_panel (M5, Wave A). The panel shell (bank_panel.cpp) owns the SWELL window,
// bank_panel (M5, Wave A). The panel shell (shell/panel/) owns the SWELL window,
// LICE drawing, and PCM reads; ALL of that is REAPER-bound and DAW-verified. What
// is NOT DAW-bound — how N sample cells tile a panel of a given pixel size, and
// the key that identifies a cached thumbnail — lives here so it is unit-tested
@@ -14,22 +15,13 @@
#include <string>
#include <vector>
namespace reasampler {
namespace reasampler::ui {
// A single cell's pixel rectangle within the panel, top-left origin (SWELL/LICE
// convention). (x, y) is the top-left corner; width/height are the cell extents.
// These are the draw bounds for one sample's thumbnail; the panel draws its
// waveform envelope inside this rect (minus any internal padding it applies).
struct CellRect {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
bool operator==(const CellRect& o) const {
return x == o.x && y == o.y && width == o.width && height == o.height;
}
};
using CellRect = Rect; // Q-W1: the shared concrete ui::Rect (core/ui/rect.h), role-aliased
// Fixed inputs that shape the grid. All in pixels. cellWidth/cellHeight are the
// TARGET cell size; the layout fits as many whole columns as the panel width
@@ -88,7 +80,7 @@ std::string thumbnailKeyString(const ThumbnailKey& key);
// --- Interaction (M5 Wave B): hit-test, selection, keyboard nav --------------
//
// All REAPER-free so the panel's interaction LOGIC is unit-tested outside the DAW,
// exactly as the layout math is. The panel shell (bank_panel.cpp) reads live mouse
// exactly as the layout math is. The panel shell (shell/panel/) reads live mouse
// coordinates / key codes / modifier state via SWELL and calls into these; it owns
// no selection arithmetic of its own.
@@ -183,4 +175,4 @@ constexpr float kDisplayFloorDb = -60.0f;
// (stays on the midline). Full-scale (|linear| == 1.0f) returns exactly ±1.0f.
float compressAmplitudeForDisplay(float linear);
} // namespace reasampler
} // namespace reasampler::ui
@@ -1,8 +1,8 @@
// card_drag — pure implementation. See card_drag.h. NO REAPER / SWELL / LICE / OS / vendor.
#include "card_drag.h"
#include "core/ui/card_drag.h"
namespace reasampler {
namespace reasampler::ui {
namespace {
@@ -93,4 +93,4 @@ int hitTestSlot(int px, int py, const std::vector<SlotCellRect>& rects) {
return -1;
}
} // namespace reasampler
} // namespace reasampler::ui

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