Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b34a543b81 | |||
| 1a62e10467 | |||
| 8a9e2a1194 | |||
| f529554673 | |||
| 4d2316b77c | |||
| d1202b3174 | |||
| cd12b97631 | |||
| f3be4d8cce | |||
| 4831e0e172 | |||
| 0c39a716a5 | |||
| 75aa93f913 | |||
| bbbb69ee55 | |||
| 4f587258b2 | |||
| 430e117620 | |||
| 5232227323 | |||
| 3bcd4dcc7b | |||
| 6108673c84 | |||
| 8bc5aa1257 | |||
| 09f7173db2 | |||
| d7d7f7e084 | |||
| f0f91f7698 | |||
| ea86f540b8 | |||
| 9d5783453c | |||
| 19b12186ac | |||
| 30a4ffd01b | |||
| b5788c82f6 | |||
| d8651fb7a7 | |||
| b8e8bb4c75 | |||
| 9dd3440b75 | |||
| 0ce3f4894a | |||
| 2d59bbe35d | |||
| 847936f813 | |||
| 67a41728f3 | |||
| 88e7765ee5 | |||
| 32785606d4 | |||
| b2013f2056 | |||
| 587032ffa4 | |||
| 113d268553 | |||
| 546927ee43 | |||
| 16b2a1b8ca | |||
| 15d293b42f | |||
| 35f02cece2 | |||
| 678274c19b | |||
| 78a214b247 | |||
| a4571cc074 | |||
| b06b226182 | |||
| 57e509c64f | |||
| 3eaa0b886e | |||
| 2dbefb8c01 | |||
| ca086f4009 | |||
| dfe6ccbddd | |||
| f0d5d23171 |
+300
-179
@@ -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
@@ -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 ~322–369 per file; no
|
||||
honest seam remains; bisection was rejected.
|
||||
- **Review note for the Q-W4 planning record:** `panel_bank_ops`'s verbs still embed
|
||||
prompts/panel-state nudges — Q-W4's dedupe needs promptless inner verbs (`renameBank(id,name)`
|
||||
etc.), not a call-site swap; `promptText`/`mintBankId` are byte-identical twins with
|
||||
`actions.cpp` and are the cheapest first dedupe.
|
||||
- ~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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 → W2–W5 god-module splits → W6 OCP finish).
|
||||
> the reorg is risk-ordered waves (W1 safe opener → W2/W2v–W5 god-module splits → W6 OCP finish).
|
||||
>
|
||||
> **PHASE STATUS (2026-07-29): all seven waves (Q-W0..Q-W6 incl. Q-W2v) are structurally
|
||||
> COMPLETE.** Remaining before the phase closes and merges to `dev`: (1) Daniel's in-DAW
|
||||
> verification batch — the full deferred list across all waves (panel parity, editor/processor
|
||||
> parity, stereo Preserve listening, null test, bit-identical repeats, capture flows, action
|
||||
> families, one-op-one-Ctrl-Z, prune fail-safes, save/load/relocation) — now unblocked since the
|
||||
> tree is stable; (2) the phase-close CLAUDE.md architecture refresh (module map still describes
|
||||
> some pre-Q homes); (3) the phase-q → dev merge on Daniel's sign-off. See `COMPLETED.md` for
|
||||
> each wave's full landed narrative.
|
||||
|
||||
## Q-W0 — pre-restructure functional + DSP quality audit (runs FIRST; gates Q-W1)
|
||||
**STATUS (2026-07-29): audit COMPLETE, triage COMPLETE, sign-off COMPLETE, fix-now
|
||||
remediations LANDED — Q-W0 is fully closed.** The findings report is committed
|
||||
(`docs/product/code-quality-audit.md`; track appendices in `docs/product/audit-notes/` — T1
|
||||
DSP, T2 architecture, T3 env-constants, T4 sizing/placement; 59 findings). Daniel approved
|
||||
every disposition 2026-07-28. The six approved fix-now remediations plus seven review riders
|
||||
landed 2026-07-28 (merge `546927e`) — see `COMPLETED.md`. **Q-W1 has since landed on top of
|
||||
this closure** (see `COMPLETED.md`).
|
||||
**Goal:** Before a single structural point moves, perform a **thorough static/functional audit** of
|
||||
the codebase and produce a **written, triaged findings report**. This is the *functional-correctness
|
||||
and algorithm-quality* complement to the grep-verified SOLID/naming audit that already grounds
|
||||
@@ -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, W2–W5). 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 ~322–369 per file;
|
||||
> no honest seam remains; bisection was rejected.
|
||||
>
|
||||
> **Review note for the Q-W4 planning record:** `panel_bank_ops`'s verbs still embed
|
||||
> prompts/panel-state nudges — Q-W4's dedupe needs promptless inner verbs (`renameBank(id,name)`
|
||||
> etc.), not a call-site swap; `promptText`/`mintBankId` are byte-identical twins with
|
||||
> `actions.cpp` and are the cheapest first dedupe.
|
||||
>
|
||||
> **In-DAW verification (panel parity) is PENDING on `phase-q`** — deferred by design, not yet
|
||||
> performed.
|
||||
|
||||
## Q-W2v — split the VST god-modules (NEW wave — Q-W0 T4 §1.5; runs parallel with Q-W2)
|
||||
|
||||
> **Landed on `phase-q` (2026-07-29, merge of `pq-w2v-vst`). Integrated suite 61/61 green,
|
||||
> reviewed-approved.** `reasampler_editor.cpp` (3084 LOC, the largest file in the repo) split
|
||||
> into eight face-axis TUs under `shell/instrument/`, with pure layout hoisted into
|
||||
> `core/instrument/ui/editor_geometry` (discharges T2-06, newly tested); `reasampler_processor.cpp`
|
||||
> split into `processor_state` / `processor_reload` / lifecycle+`process()` kept whole (no
|
||||
> virtual seam, T4-29); `sample_map` split into a resolution core + `component_state_io` codec
|
||||
> (the extension preset path no longer links the voice engine — link-proven; T4-13 ≡ T2-07);
|
||||
> `sampler_core.cpp` stays whole with the documented hot-path exception comment (T4-14/T4-27);
|
||||
> `zone_params.h` split out; `core/wire/bytes.h` (`putLE`/`ByteReader`) lands (T4-20);
|
||||
> `ThumbnailKey` adopted (T2-10); a golden full-blob v11 fixture pins the codec bytes. The
|
||||
> `src/vst/` directory is gone. See `COMPLETED.md` for the full narrative.
|
||||
>
|
||||
> **Deferred/known:** `component_state_io.h` still includes `sample_map.h`→`sampler_core.h`
|
||||
> transitively (T2-07's header half — future work); the `engine` namespace is deferred
|
||||
> (`sampler_core` stays flat `reasampler`); capture-side LE rewires are left for the capture
|
||||
> family.
|
||||
>
|
||||
> **In-DAW verification (editor/processor parity) is PENDING on `phase-q`** — deferred by
|
||||
> design, not yet performed.
|
||||
|
||||
## Q-W3 — split `main.cpp` (hoist orchestration; leave main = pointers + entry + dispatch)
|
||||
**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/L4–L7 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
|
||||
(W2–W5) 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
File diff suppressed because it is too large
Load Diff
@@ -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, ~577–589) +
|
||||
`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/|ratio−1| 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 (~301–311) + `process()` tap
|
||||
advance (~360–366).
|
||||
- **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 (~375–400) vs. the GA3
|
||||
`feedBound` in `advanceFrame` (~568–572).
|
||||
- **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 (~493–498, 610–612).
|
||||
- **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 (~607–624); `src/vst/pitch_shift.cpp`
|
||||
`readTap` (~172–185).
|
||||
- **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 (~240–257) and `configure()`
|
||||
geometry (~68–72).
|
||||
- **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` ~196–210.
|
||||
- **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` (~131–170).
|
||||
- **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` (~598–599, 639–646, 515–518).
|
||||
- **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' =
|
||||
(1−w)·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` (~224–227) and `src/capture_realtime.cpp` (~120–123).
|
||||
- **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 Fritsch–Carlson 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:56–137`, `src/assignment_request.cpp:25–121`,
|
||||
`src/sample_usage.cpp:25–89` (plus a fourth sibling: `src/vst/bank_sync.cpp:11–28`
|
||||
`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:65–82`) 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:197–199`) 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:88–130` (`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:749–756`.
|
||||
|
||||
**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:140–157` (`getProjExtStateString`),
|
||||
`src/vst/reaper_bridge.cpp:93–107` (self-described "mirrors persist.cpp's growing strategy"),
|
||||
`src/usage_scan.cpp:168–181` (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 992–1042), 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:383–385` (`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:31–115` (a second, independent RIFF chunk walker for content hashing),
|
||||
`src/ingest.cpp:108–160` (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:490–537` vs `src/capture_realtime.cpp:505–540`.
|
||||
|
||||
**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:422–483` (extension: `computeThumbnail` + pure
|
||||
`ThumbnailKey{id, width, generation}` via `bank_grid::thumbnailKeyString`) vs
|
||||
`src/vst/reasampler_editor.cpp:715–789` (VST: `monoPcmFor` keyed by bare `sampleId`,
|
||||
`thumbnailFor` keyed by ad-hoc `sampleId + "|" + binCount`, invalidated by wholesale
|
||||
`clear()` at lines 159–160/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:775–945` (`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 806–841 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 1–4); `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.1–96 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 150–200 % 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 v6–v11 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.
|
||||
@@ -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` (420–487) | ~130 | fine |
|
||||
| `panel_render` | `drawCardMeta`/`drawThumbnail` (487–547), kit adapters (547–570), `drawFooter` (685–780), `drawToolbar`/`drawMoreButton`/`drawTooltip` (992–1155), `drawRegionGrid`/`drawCardDropTarget`/`drawRegionHeader`/`drawTabStrip`/`paintPanel` (1334–1646) | ~700 | **over — needs the layout cut below** |
|
||||
| *(unplanned)* **`panel_layout`** | toolbar/footer/menu rect + row/cluster builders (571–684, 785–991), split geometry + region rects + L7 slot-order display bridge (1156–1333) | ~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` (1874–1965) | ~90 | fine (keep the direct call-through guardrail) |
|
||||
| `panel_input` | input helpers + `regionAt` (1965–2010), click routing `handleBanksChromeClick`…`handleKey`/accelerator (2384–2710), plus new-content detection (1647–1874, ~230) | ~800 | **over — needs the drag cut below** |
|
||||
| *(unplanned)* **`panel_drag`** | `updateDropTarget`/`dropTargetBankId`/`classifyCardDrag`/`applyDragCursor`/`resolveHover`/`updateHover`/`maybeShowTooltip`/`onMouseMove`/`doReorderDrop`/`doReplaceDrop`/`resetDragState`/`onLBtnUp`/`handleRightClick` (2711–3185) | ~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` (2006–2231) + popup menus (2231–2384) | ~375 | fine (menus ride with bank_ops or input — either works; they invoke the ops) |
|
||||
| `panel_window` | `handleDropFiles`/`dlgProc`/`openPanel`/`closePanel` (3185–3336) + public API (3336–3459) | ~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` (627–731, ~105), `renderOffline` +
|
||||
`captureAndIndexOne` + `RunCapture` + `RunCaptureItemAssign` (731–909, ~180), batch family
|
||||
(`ItemSelectionGuard`/`selectOnlyItem`/`RunBatchCaptureItems`/`collectRazorAreas`/
|
||||
`TrackSelectionGuard`/`RunBatchCaptureRazor`, 909–1181, ~270), `RunRecaptureFromSource` (1200–1398,
|
||||
~200), realtime + insert actions (1398–1512, ~115). `scope_resolve` (361–590) ≈ 230 ✓;
|
||||
`realtime_lifecycle` (186–360) ≈ 175 ✓; registration/entry residue (1512–1897) ≈ 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` (59–421, ~360), `bank_actions` (422–1016 minus prune,
|
||||
~490), `prune_action` (`doBankPruneFolder` 821–916 + 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`,
|
||||
112–288, ~180), `prune_fs` (`scanPruneOrphans`/`pruneDryRun`/`pruneOrphanSet`/`deleteOrphanFile`/
|
||||
`pruneReclaim`, 288–539, ~250 — pS-usage growth landed here, exactly where the plan isolates it),
|
||||
`session` (load/guid/poll/reload, 539–852, ~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`** (27–143, ~115 — a self-contained ordered-slot container with its
|
||||
own serialize at 614), **`BankBook`** registry/CRUD/transfer/slot-reconcile (145–545, ~400), and
|
||||
**JSON serialize + `Parser`** (547–1109, ~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`, 29–140), **pure
|
||||
planners** (`autoTagNewContent`/`planItemRetag`/`planLaneMinting`/`makeParkPlan`/`makeRestorePlan`/
|
||||
`nextModeId`, 143–326, ~185), **`ViewModeModel`** state + visibility/toggle planning (332–490), and
|
||||
**JSON serialize + `Parser`** (494–1049, ~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, 25–145, ~120) and JSON
|
||||
(`ObjWriter`/`writeSample`/`Parser::parseSample`/`parseIndex`, 148–767, ~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`, 177–324 + 569–867, ~450) and the **file-side finalize** (`readAllBytes`/`writeU32LE`/
|
||||
`trimAutoTailInPlace`/`finalizeRecording`, 324–566, ~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, 86–217, ~130 —
|
||||
see T4-22: `buildFloat32Wav` is a pure function trapped in a shell TU), **`importFileIntoActiveBank`**
|
||||
(249–420, ~170), and the three ingest surfaces + registration (420–636). 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 (146–400), PCM + thumbnail caches `monoPcmFor`/`thumbnailFor` (719–800) | ~420 |
|
||||
| `editor_controls` | control-value map `controlValue`/`applyControl` (399–475), knob-deck plumbing `zoneDeckGroupDescs`/`deckGroupDescs`/`deckControlNorm`/`applyDeckKnob`/`deckValueLabel` (475–649), envelope pack/unpack + `commitPickedMarkers` (649–719), `applyZoneControl` (2576–2587) | ~470 |
|
||||
| `editor_layout` (pure candidate — see T4-23) | anon-ns geometry: `computeSampleBands`/`clusterRects`/zone-panel areas/`channelToggleRects` (904–1076), `computeBrowseModal` (1756–1785), `zoneContentArea` (1917–1924) | ~250 |
|
||||
| `editor_paint_sample` | `paint` dispatch (1169), `drawTitleBand`, `paintSample`/`paintEnvelopeOverlay`/`paintVelocityCurve`/`paintKnobDeck`/`paintCurveButton`/`paintCurvePopup`/`paintEmptyState` + `drawKnobFace`/`drawSpectralStrip`/`drawRootMarker` (1076–1756) | ~590 |
|
||||
| `editor_paint_browse_zone` | `paintBrowse` (1785–1917), `paintZone` (1924–2041) | ~260 |
|
||||
| `editor_input` | `resolveHover` (2041–2145), `onMouseDown` (2147–2576 — a 430-line per-face dispatch), popup/curve mouse (1637–1735), `onMouseMove`/`onMouseUp`/`onMouseRDown`/`onMouseWheel`/`onSearchChar`/`onFilesDropped` (2587–2929) | ~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` (800–904), `wndProc` + non-Windows stubs (2929–3065) | ~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`, 118–209 + 492–506, ~120), **component-state I/O** (`setState`/`getState` +
|
||||
`legacyLiftShouldRun`, 209–349 + 774–904, ~270), **accessors/param setters** (349–492, ~140), and
|
||||
**reload + drain + usage-publish + render** (`reloadInstrument`/`publishUsage`/
|
||||
`publishBuiltLocked`/`rebuildVoiceEngine`/`retireIdleDrain` 506–774 ~270, `process` 904–1164 ~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`, 14–406, ~390) and **binary component-state codec** (`putU32le`/
|
||||
`putU64le`/`ByteReader`, zones payload put/read, `serializePerformance`/`deserializePerformance`,
|
||||
`serializeComponentState`/`deserializeComponentState` v3→v11 lift ladder, selection codec,
|
||||
406–970, ~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 (15–30), `Keymap` (34–58), `AdsrEnvelope`/`TriggerEnvelope`/`PitchEnvelope`
|
||||
(62–251, ~190), `Voice` (255–680 — `advanceFrame` alone is ~200), `VoiceEngine` (680–968, ~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`
|
||||
406–500), `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, 116–179) 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 139–148, 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`.
|
||||
@@ -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` (Fritsch–Carlson 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 v6–v11 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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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ₙ*(1−w) + ref*w where w = declickL_/R_ starts at 1.0 and decays by
|
||||
// outₙ = outₙ*(1−w) + ref*w where w = declickWeight_ (ONE weight, deliberately shared
|
||||
// by both channels so L/R can never diverge — Q-W0 T1-09 removed the dead per-R copy)
|
||||
// starts at 1.0 and decays by
|
||||
// kDeclickDecay each frame. This is algebraically `outₙ + w*(ref − outₙ)`, so the
|
||||
// boundary frame (w=1) is exactly `ref` and every subsequent output is bounded by
|
||||
// max(|ref|, |outₙ|) — mid-ramp overshoot is impossible regardless of outₙ rising.
|
||||
@@ -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
|
||||
@@ -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 v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone
|
||||
// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the
|
||||
// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
|
||||
// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
|
||||
// transfer curve appended to each zone record after the v6 keyTrack field:
|
||||
// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp
|
||||
// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2.
|
||||
// A v1–v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1
|
||||
// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change:
|
||||
// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A
|
||||
// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed.
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve
|
||||
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
||||
|
||||
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are
|
||||
// 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
|
||||
@@ -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 v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone
|
||||
// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the
|
||||
// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed.
|
||||
// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp
|
||||
// transfer curve appended to each zone record after the v6 keyTrack field:
|
||||
// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp
|
||||
// (double). The two endpoints (velocity 0 and 127) are always included, so N >= 2.
|
||||
// A v1–v6 payload (no velocity-curve field) lifts every zone to VelocityCurve::flat() (R10-F1
|
||||
// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change:
|
||||
// an already-saved zone's soft hits play LOUDER than under the pre-r10 linear velocity/127. A
|
||||
// truncated mid-curve record leaves the zone's flat default and keeps the zones that parsed.
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 7; // S-VIEW-9: + per-zone velocity->amp curve
|
||||
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
||||
|
||||
// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are
|
||||
// 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user