Merge dev into phase-b-multibank (integrate parallel M7/8 + Phase D work before dev promotion)
# Conflicts: # CLAUDE.md # CMakeLists.txt # src/actions.cpp # src/bank_panel.cpp # src/persist.h
This commit is contained in:
@@ -22,7 +22,7 @@ Vendors two submodules (see `.gitmodules`):
|
|||||||
cmake --build build
|
cmake --build build
|
||||||
ctest --test-dir build
|
ctest --test-dir build
|
||||||
|
|
||||||
Eight targets:
|
Key targets (see CMakeLists.txt for the full list):
|
||||||
|
|
||||||
| Target | Kind | Purpose |
|
| Target | Kind | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -33,6 +33,7 @@ Eight targets:
|
|||||||
| `view_tree_tests` | executable | Pure unit tests for `view_tree` — no REAPER, no DAW. |
|
| `view_tree_tests` | executable | Pure unit tests for `view_tree` — no REAPER, no DAW. |
|
||||||
| `mode_switch_tests` | executable | Pure unit tests for `mode_switch` — no REAPER, no DAW. |
|
| `mode_switch_tests` | executable | Pure unit tests for `mode_switch` — no REAPER, no DAW. |
|
||||||
| `bank_book_tests` | executable | Pure unit tests for `bank_book` — no REAPER, no DAW. |
|
| `bank_book_tests` | executable | Pure unit tests for `bank_book` — no REAPER, no DAW. |
|
||||||
|
| `wav_trim_tests` | executable | Pure unit tests for `wav_trim` — no REAPER, no DAW. |
|
||||||
| `reaper_reasampler` | loadable module | The actual extension binary (`.dll` / `.dylib` / `.so`). |
|
| `reaper_reasampler` | loadable module | The actual extension binary (`.dll` / `.dylib` / `.so`). |
|
||||||
|
|
||||||
### macOS / Linux: SWELL dialog resources
|
### macOS / Linux: SWELL dialog resources
|
||||||
@@ -56,12 +57,13 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde
|
|||||||
- `view_tree` — pure `I_FOLDERDEPTH`→FolderTree helper for the Design View shell; no REAPER types at the boundary.
|
- `view_tree` — pure `I_FOLDERDEPTH`→FolderTree helper for the Design View shell; no REAPER types at the boundary.
|
||||||
- `mode_switch` — REAPER-free segment layout + hit-test math for the bank_panel's Design View mode switch; divides a header rectangle into N equal segments and hit-tests a point to a segment. Mirror of `bank_grid`.
|
- `mode_switch` — REAPER-free segment layout + hit-test math for the bank_panel's Design View mode switch; divides a header rectangle into N equal segments and hit-tests a point to a segment. Mirror of `bank_grid`.
|
||||||
- `bank_book` — multi-bank registry (Phase B): an ordered set of banks (pool seeded as bank-zero + named banks), each wrapping a `BankIndex`. Owns create/rename/reorder/delete of named banks, pool privileges (un-deletable/un-renamable/un-evacuable, never zero banks) enforced in-model, active-bank id, index-only move/copy of a sample between banks, JSON round-trip + legacy-`bank_index`→pool migration. Wraps `BankIndex` (bank_model untouched; no `bankId` on `Sample`).
|
- `bank_book` — multi-bank registry (Phase B): an ordered set of banks (pool seeded as bank-zero + named banks), each wrapping a `BankIndex`. Owns create/rename/reorder/delete of named banks, pool privileges (un-deletable/un-renamable/un-evacuable, never zero banks) enforced in-model, active-bank id, index-only move/copy of a sample between banks, JSON round-trip + legacy-`bank_index`→pool migration. Wraps `BankIndex` (bank_model untouched; no `bankId` on `Sample`).
|
||||||
|
- `wav_trim` — 32-bit-float WAV parse + header-aware truncate plan (RIFF/data size rewrite) for the realtime tail's PCM decay-scan trim (T2). Rejects WAVE_FORMAT_EXTENSIBLE with non-float SubFormat GUID. Depends on `peaks` for the `AudioSample` float alias.
|
||||||
|
|
||||||
**REAPER-facing shells:**
|
**REAPER-facing shells:**
|
||||||
- `capture` — `ICaptureBackend` interface; `OfflineRenderBackend` (deterministic default) and `RealtimeRecordBackend`. Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`.
|
- `capture` — `ICaptureBackend` interface; `OfflineRenderBackend` (deterministic default) and `RealtimeRecordBackend`. Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`.
|
||||||
- `insert` — placement via `InsertMedia`; conform-to-project-tempo is an explicit opt-in flag, never silent stretching.
|
- `insert` — placement via `InsertMedia`; conform-to-project-tempo is an explicit opt-in flag, never silent stretching.
|
||||||
- `bank_panel` — docked LICE-drawn grid: thumbnails, audition, multi-select, keyboard navigation.
|
- `bank_panel` — docked LICE-drawn grid: thumbnails, audition, multi-select, keyboard navigation.
|
||||||
- `persist` — project ext state (`SetProjExtState` / `GetProjExtState`, namespace `"reasampler"`) ↔ `bank_model` JSON + `ViewModeModel` JSON (`"view_state"` key); project-relative path resolution.
|
- `persist` — project ext state (`SetProjExtState` / `GetProjExtState`, namespace `"reasampler"`) ↔ `bank_model` JSON (`"bank_index"` key) + `ViewModeModel` JSON (`"view_state"` key) + `TailSetting` JSON (`"tail_setting"` key); project-relative path resolution.
|
||||||
- `view` — Design View shell: reads the folder tree via `view_tree`, snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN` + per-FX offline) and derived visibility on parents; restores from snapshot. Never touches master or `B_MUTE`/`I_SOLO`.
|
- `view` — Design View shell: reads the folder tree via `view_tree`, snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN` + per-FX offline) and derived visibility on parents; restores from snapshot. Never touches master or `B_MUTE`/`I_SOLO`.
|
||||||
- `track_guid` — shared `MediaTrack*` → canonical GUID-string formatter; single source of truth for membership keys used by both the view shell and the actions layer.
|
- `track_guid` — shared `MediaTrack*` → canonical GUID-string formatter; single source of truth for membership keys used by both the view shell and the actions layer.
|
||||||
- `actions` — registers the capture/placement/slot action family and the Design View action family (toggle active mode, activate Arrange/Design, tag/untag selected tracks, show-both); routes each to the modules above via the `command_id`/`gaccel`/`hookcommand` contract.
|
- `actions` — registers the capture/placement/slot action family and the Design View action family (toggle active mode, activate Arrange/Design, tag/untag selected tracks, show-both); routes each to the modules above via the `command_id`/`gaccel`/`hookcommand` contract.
|
||||||
|
|||||||
+56
-1
@@ -74,6 +74,10 @@ target_include_directories(tab_strip PUBLIC src)
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
add_library(view_mode_model STATIC src/view_mode_model.cpp)
|
add_library(view_mode_model STATIC src/view_mode_model.cpp)
|
||||||
target_include_directories(view_mode_model PUBLIC src)
|
target_include_directories(view_mode_model PUBLIC src)
|
||||||
|
# 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.
|
||||||
|
target_link_libraries(view_mode_model PUBLIC lane_keys)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 2d) Pure view_tree library — NO REAPER, NO SWELL. The one testable-outside-DAW
|
# 2d) Pure view_tree library — NO REAPER, NO SWELL. The one testable-outside-DAW
|
||||||
@@ -86,6 +90,28 @@ add_library(view_tree STATIC src/view_tree.cpp)
|
|||||||
target_include_directories(view_tree PUBLIC src)
|
target_include_directories(view_tree PUBLIC src)
|
||||||
target_link_libraries(view_tree PUBLIC view_mode_model)
|
target_link_libraries(view_tree PUBLIC view_mode_model)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2d'') Pure guid_diff library — NO REAPER, NO SWELL. The D2 Wave-2 new-content
|
||||||
|
# detection core: current \ previous GUID diff + the first-poll-after-open
|
||||||
|
# baseline guard (and per-project reset). Split out so the fiddly baseline/diff
|
||||||
|
# logic is unit-tested outside the DAW; the bank_panel timer that reads REAPER's
|
||||||
|
# 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)
|
||||||
|
target_include_directories(guid_diff PUBLIC src)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2d''') Pure lane_keys library — NO REAPER, NO SWELL. The managed/manual fixed-lane
|
||||||
|
# heuristic (D2 Wave-2): a lane whose durable P_LANENAME:n carries the
|
||||||
|
# "reasampler:" prefix is tool-managed and keyed by that stable name; any other
|
||||||
|
# lane is user-minted manual and off-limits. Resolves design point #1 (auto-tag
|
||||||
|
# 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)
|
||||||
|
target_include_directories(lane_keys PUBLIC src)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 2e) Pure insert_plan library — NO REAPER, NO SWELL. The InsertMedia `mode`
|
# 2e) Pure insert_plan library — NO REAPER, NO SWELL. The InsertMedia `mode`
|
||||||
# bitmask arithmetic behind the `insert` shell (M6). Split out so the
|
# bitmask arithmetic behind the `insert` shell (M6). Split out so the
|
||||||
@@ -143,6 +169,20 @@ add_library(realtime_record STATIC src/realtime_record.cpp)
|
|||||||
target_include_directories(realtime_record PUBLIC src)
|
target_include_directories(realtime_record PUBLIC src)
|
||||||
target_link_libraries(realtime_record PUBLIC bank_model)
|
target_link_libraries(realtime_record 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.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
add_library(wav_trim STATIC src/wav_trim.cpp)
|
||||||
|
target_include_directories(wav_trim PUBLIC src)
|
||||||
|
target_link_libraries(wav_trim PUBLIC peaks)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 3) Standalone tests for the pure modules (run without launching REAPER).
|
# 3) Standalone tests for the pure modules (run without launching REAPER).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -179,6 +219,14 @@ add_executable(view_tree_tests tests/test_view_tree.cpp)
|
|||||||
target_link_libraries(view_tree_tests PRIVATE view_tree)
|
target_link_libraries(view_tree_tests PRIVATE view_tree)
|
||||||
add_test(NAME view_tree_tests COMMAND view_tree_tests)
|
add_test(NAME view_tree_tests COMMAND view_tree_tests)
|
||||||
|
|
||||||
|
add_executable(guid_diff_tests tests/test_guid_diff.cpp)
|
||||||
|
target_link_libraries(guid_diff_tests PRIVATE guid_diff)
|
||||||
|
add_test(NAME guid_diff_tests COMMAND guid_diff_tests)
|
||||||
|
|
||||||
|
add_executable(lane_keys_tests tests/test_lane_keys.cpp)
|
||||||
|
target_link_libraries(lane_keys_tests PRIVATE lane_keys)
|
||||||
|
add_test(NAME lane_keys_tests COMMAND lane_keys_tests)
|
||||||
|
|
||||||
add_executable(insert_plan_tests tests/test_insert_plan.cpp)
|
add_executable(insert_plan_tests tests/test_insert_plan.cpp)
|
||||||
target_link_libraries(insert_plan_tests PRIVATE insert_plan)
|
target_link_libraries(insert_plan_tests PRIVATE insert_plan)
|
||||||
add_test(NAME insert_plan_tests COMMAND insert_plan_tests)
|
add_test(NAME insert_plan_tests COMMAND insert_plan_tests)
|
||||||
@@ -199,6 +247,10 @@ add_executable(bank_book_tests tests/test_bank_book.cpp)
|
|||||||
target_link_libraries(bank_book_tests PRIVATE bank_book)
|
target_link_libraries(bank_book_tests PRIVATE bank_book)
|
||||||
add_test(NAME bank_book_tests COMMAND bank_book_tests)
|
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)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
|
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -229,10 +281,13 @@ add_library(reaper_reasampler MODULE
|
|||||||
src/view_tree.cpp
|
src/view_tree.cpp
|
||||||
src/view.cpp
|
src/view.cpp
|
||||||
src/track_guid.cpp
|
src/track_guid.cpp
|
||||||
|
src/guid_diff.cpp
|
||||||
|
src/lane_keys.cpp
|
||||||
|
src/item_read.cpp
|
||||||
src/actions.cpp
|
src/actions.cpp
|
||||||
src/bank_book.cpp
|
src/bank_book.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 tail_control realtime_record bank_book)
|
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim)
|
||||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||||
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
|
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
|
||||||
|
|
||||||
|
|||||||
+226
-5
@@ -284,6 +284,51 @@ round-trip of the lane index. REAPER-free, unit-tested; mirror of D1. CONTEXT.md
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## D2-W2 — shell: lane application + new-content detection
|
||||||
|
**Goal:** The view shell applies the planner's managed-lane ops in the DAW and the
|
||||||
|
bank_panel timer detects new content and auto-tags it to the active mode. Resolves
|
||||||
|
the two flagged implementation design points (I_FIXEDLANE reorder/renumber
|
||||||
|
fragility; the auto-tag / manual-lane detection heuristic). See CONTEXT.md
|
||||||
|
§Two-canvas sub-phase (Module architecture — shell; New-content detection).
|
||||||
|
**Verify (in DAW):** Toggling a mode shows + plays only the active mode's managed
|
||||||
|
lane, hides + silences the inactive-mode lane, and **never touches a manual lane**
|
||||||
|
(its `C_LANEPLAYS` stays exactly as the user set it); new content created while a
|
||||||
|
mode is active is tagged to that mode; pre-existing content stays Arrange (no
|
||||||
|
mass-tag on the first poll after open).
|
||||||
|
**Depends on:** D2-W1.
|
||||||
|
|
||||||
|
- [x] Apply managed-lane ops in the view shell (`I_FREEMODE`/`I_FIXEDLANE`/
|
||||||
|
`C_LANEPLAYS`/`B_FIXEDLANE_HIDDEN` via the item/track info setters;
|
||||||
|
`UpdateTimeline()` after `I_FREEMODE`); **managed lanes only, never manual**.
|
||||||
|
Verify every flag name/signature against the SDK header.
|
||||||
|
- [x] New-content detection on the bank_panel timer: diff the live track/item GUID
|
||||||
|
set against the previous poll; tag any GUID new since the last poll to the
|
||||||
|
then-active mode, with a **first-poll-after-open guard** (pre-existing ⇒ Arrange,
|
||||||
|
no mass-tag) and the **manual-lane exemption** (items in a manual lane not tagged).
|
||||||
|
- [x] Resolve the manual-lane detection heuristic (which new items are exempt) and
|
||||||
|
the `I_FIXEDLANE` lane-identity fragility (index survival across lane
|
||||||
|
reorder/renumber/deletion) — the two open design points from CONTEXT.md.
|
||||||
|
- [x] D2-W1 review polish: document the one-managed-lane-per-mode-per-track
|
||||||
|
exclusivity assumption in `laneModeState` (comment / debug-guard); clarify the
|
||||||
|
`serialize()` one-line style note; optional round-trip tests for the
|
||||||
|
last-writer-wins lane-replace contract.
|
||||||
|
|
||||||
|
**Notes/decisions:**
|
||||||
|
- Two new pure modules added with unit tests: `guid_diff` (diffs live track/item
|
||||||
|
GUID sets between polls) and `lane_keys` (manages lane identity via durable
|
||||||
|
`P_LANENAME` rather than the renumber-prone `I_FIXEDLANE` ordinal, reconciled each
|
||||||
|
apply — the resolution to the lane-identity fragility design point). CTest green.
|
||||||
|
- **Manual-lane protection:** a single pure predicate `isOnManualLane` is the
|
||||||
|
exclusive gate; manual lanes — including REAPER's default unnamed fixed lanes —
|
||||||
|
are provably never driven or auto-tagged.
|
||||||
|
- **Track-level auto-tag and park behavior is live.** Item-lane show/hide is
|
||||||
|
correctly structured but is a provable no-op on real projects until D2-W3 mints
|
||||||
|
the `reasampler:`-prefixed named lanes. End-to-end DAW verification of item-lane
|
||||||
|
show/hide is sequenced after D2-W3 for this reason.
|
||||||
|
- W1 review polish was folded in during this wave.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Milestone 7 — capture action family
|
## Milestone 7 — capture action family
|
||||||
**Goal:** Bindable capture actions for master / selected tracks / selected items /
|
**Goal:** Bindable capture actions for master / selected tracks / selected items /
|
||||||
razor area, each with wet-dry + tail options. CONTEXT.md §actions, Build order 7.
|
razor area, each with wet-dry + tail options. CONTEXT.md §actions, Build order 7.
|
||||||
@@ -428,8 +473,184 @@ path.
|
|||||||
- **`kNormalizeDisableAll = (4 << 16) = 262144`.** Used for None and Manual — the
|
- **`kNormalizeDisableAll = (4 << 16) = 262144`.** Used for None and Manual — the
|
||||||
same disable-all value the pre-tail exact-bounds capture used.
|
same disable-all value the pre-tail exact-bounds capture used.
|
||||||
- **`tail_control` pure module** (`src/tail_control.{h,cpp}`): REAPER-free logic for
|
- **`tail_control` pure module** (`src/tail_control.{h,cpp}`): REAPER-free logic for
|
||||||
the panel toggle. `kDefaultManualTailMs = 2000.0` (2 s). Fine-adjust UI (±
|
the panel toggle. `kDefaultManualTailMs = 2000.0` (2 s). Fine-adjust UI (scroll-wheel
|
||||||
click zones / scroll) is a noted follow-on; this pass ships a fixed default.
|
in 250 ms steps) and per-project persistence landed as T1-followons (see below).
|
||||||
- **Follow-ons noted, not done:** Manual fine-adjust UI; per-project persistence of
|
- **Follow-ons resolved:** Manual fine-adjust UI and per-project persistence of the
|
||||||
the toggle (currently extension-session lifetime, resets to None on unload); T2
|
toggle landed as T1-followons. T2 realtime tail landed separately.
|
||||||
realtime tail.
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T2 — realtime tail (follow-on to T1)
|
||||||
|
**Goal:** The parallel tail path for the M8 realtime backend, which does not drive
|
||||||
|
`RENDER_*`: record an 8 s-capped tail window past the range end, then **trim in a
|
||||||
|
PCM decay-scan** to the -72 dB point (Manual = record fixed tail, skip the scan).
|
||||||
|
See `docs/product/capture-tail.md` §The realtime path.
|
||||||
|
**Verify (in DAW):** A realtime Auto capture of a decaying source records ≥ the range
|
||||||
|
then trims at the -72 dB decay point (± inherent realtime tolerance); realtime tail is
|
||||||
|
**not** asserted bit-identical (documented non-determinism).
|
||||||
|
**Depends on:** T1, M8.
|
||||||
|
|
||||||
|
- [x] Record `[start, end + clamp(tail, 8 s)]` (extend the record time selection in
|
||||||
|
`capture_realtime.cpp`); Manual skips the scan, Auto proceeds to it.
|
||||||
|
- [x] Pure decay-scan helper alongside `peaks`: `lastFrameAboveThreshold(interleaved,
|
||||||
|
channels, frames, linearThreshold) -> frameIndex` (backward scan, per-frame max-abs
|
||||||
|
across channels, no fold); unit-tested with a synthetic decaying ramp. (Spec §realtime
|
||||||
|
path option (a) — recommended over bending `computeEnvelope`.)
|
||||||
|
- [x] Realtime shell: read the recorded wav PCM into a float buffer, find the trim
|
||||||
|
frame, rewrite the file truncated (new I/O the backend does not do today).
|
||||||
|
|
||||||
|
**Notes/decisions:**
|
||||||
|
- New pure module `wav_trim` (`src/wav_trim.{h,cpp}`): 32-bit-float WAV parse + header-aware
|
||||||
|
truncate plan (RIFF/data size rewrite). Rejects WAVE_FORMAT_EXTENSIBLE with non-float
|
||||||
|
SubFormat GUID. Depends on `peaks` for the `AudioSample` float alias. Unit-tested via a
|
||||||
|
new `wav_trim_tests` CTest target.
|
||||||
|
- `peaks` gained `lastFrameAboveThreshold` (backward PCM scan, per-frame max-abs across
|
||||||
|
channels, no fold) for the Auto decay scan.
|
||||||
|
- **Auto/Manual/Off semantics.** Auto: records `[start, end + 8 s cap]`, scans backward
|
||||||
|
for the last frame above -72 dBFS, truncates the WAV header-aware at that frame. Manual:
|
||||||
|
records `[start, end + fixed tail]`, skips the scan. Off: byte-identical to the pre-tail
|
||||||
|
exact-bounds capture.
|
||||||
|
- **Realtime tail is non-deterministic by design** (inherent to the realtime backend).
|
||||||
|
Bit-identical repeats are not asserted for the realtime path; this is documented, not a defect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T1-followons — Manual fine-adjust UI + per-project tail persistence
|
||||||
|
**Goal:** Close the two follow-ons deferred at T1 landing: (1) scroll-wheel fine-adjust
|
||||||
|
of the Manual tail length in the panel footer; (2) the tail setting (mode + Manual length)
|
||||||
|
persists per-project inside the `.rpp` rather than resetting on extension unload.
|
||||||
|
**Verify (in DAW):** Scroll-wheel over the footer adjusts Manual length in 250 ms steps,
|
||||||
|
clamped 0–8 s; the label reads "Tail: Manual X.Xs" (one decimal) in Manual mode; footer
|
||||||
|
click still cycles Off → Auto → Manual. The tail setting survives Save / close+reopen;
|
||||||
|
projects with no stored key fall back to Off / 2 s.
|
||||||
|
**Depends on:** T1.
|
||||||
|
|
||||||
|
- [x] `adjustManualMs(current, notches, stepMs)` pure helper in `tail_control` (per-notch
|
||||||
|
±`kManualStepMs` = 250 ms, clamped [0, `kMaxTailMs`]); unit-tested.
|
||||||
|
- [x] `tailToggleLabel` updated: Manual mode appends the clamped length in seconds to one
|
||||||
|
decimal, e.g. `"Tail: Manual 2.0s"`; unit-tested at boundary lengths.
|
||||||
|
- [x] Panel footer scroll-wheel handler calls `adjustManualMs` and repaints; click handler
|
||||||
|
unchanged (still cycles mode via `cycleTailMode`).
|
||||||
|
- [x] `serializeTailSetting` / `deserializeTailSetting` pure round-trip (mode + manualMs)
|
||||||
|
added to `tail_control`; unit-tested including `std::nullopt` on malformed input.
|
||||||
|
- [x] `TailSetting tail_` promoted into `ReaSamplerSession` (peer to `bank_` and `view_`);
|
||||||
|
`persist` serializes it under the forever-stable key `"tail_setting"` (namespace
|
||||||
|
`"reasampler"`) on save and reloads it on project open. Absent key → default Off / 2 s
|
||||||
|
(graceful for older/unsaved projects).
|
||||||
|
- [x] Changing the toggle marks the project dirty and commits the value to ext state;
|
||||||
|
`bankPanelTailSetting()` reads through the session (not a panel-local copy).
|
||||||
|
|
||||||
|
**Notes/decisions:**
|
||||||
|
- `kManualStepMs = 250.0` — Daniel-set coarse-but-precise step; one wheel notch = ± 250 ms.
|
||||||
|
- Label format: `"Tail: Manual 2.0s"` (one decimal, `s` suffix) — format pinned by unit tests.
|
||||||
|
- Default fallback on absent/malformed key: `TailSetting { TailMode::None, kDefaultManualTailMs }`
|
||||||
|
(Off mode, 2 s stored length) — graceful for projects saved before this feature shipped.
|
||||||
|
- `kProjExtTailKey = "tail_setting"` is forever-stable (changing it would orphan saved choices,
|
||||||
|
falling back to the default — graceful but lossy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D2-W3-A — lane minting + item→lane assignment + persist round-trip
|
||||||
|
**Goal:** The functional core that makes item-lanes appear: a pure `planLaneMinting`
|
||||||
|
decision (which tracks hold >1 mode's content, which managed lane each item lands on)
|
||||||
|
plus the shell apply path in `view.cpp` — enables fixed-lane mode, mints one managed
|
||||||
|
`reasampler:<mode>`-named lane per involved mode, assigns each item (including
|
||||||
|
pre-existing) to its mode's lane, and drives per-lane play state, all under one undo
|
||||||
|
block, triggered off the auto-tag detection tick. Reconciles the lane-ownership index
|
||||||
|
from durable lane names on project load before active-mode visibility is reapplied.
|
||||||
|
The lane-ownership index persists inside the `"reasampler"` `view_state` blob (rides
|
||||||
|
in `ViewModeModel::serialize()` / `deserialize()`).
|
||||||
|
**Verify:** CTest green (14/14). Pure decision unit-tested in `view_mode_model_tests`.
|
||||||
|
**DAW verification pending** (Daniel testing on dev): two behaviors are
|
||||||
|
REAPER-runtime-only — whether lane names stick when written on the same tick the track
|
||||||
|
flips to fixed-lane mode, and whether the leftover empty default lane 0 is silent.
|
||||||
|
**Depends on:** D2-W2.
|
||||||
|
|
||||||
|
- [x] Pure `planLaneMinting` decision (`view_mode_model.{h,cpp}`): for each reported
|
||||||
|
track, collect the distinct modes of managed-eligible items; if < 2 modes, no split
|
||||||
|
(D1 whole-track parking still separates stances); if ≥ 2 modes, emit one
|
||||||
|
`TrackSplit`, one `LaneMint` per involved mode (durable key = `laneNameForMode(mode)`,
|
||||||
|
owned by that mode), and one `LaneAssign` per managed-eligible item — including
|
||||||
|
pre-existing items, so a track that just gained a second mode retroactively lanes all
|
||||||
|
its content. Manual-lane items (`onManualLane = true`) are exempt at the source:
|
||||||
|
never counted, never reassigned, never minted-over.
|
||||||
|
- [x] Shell apply path `applyMintPlan` in `view.cpp`: enables `I_FREEMODE` = fixed
|
||||||
|
lanes, grows `I_NUMFIXEDLANES` (never shrinks — user's manual lanes are never
|
||||||
|
deleted), stamps each managed lane's durable name via `P_LANENAME`, records
|
||||||
|
ownership in the model (`lanes().setManaged`), assigns each item to its mode's lane
|
||||||
|
via `I_FIXEDLANE` resolved from the durable key. Returns `changed` so the Undo block
|
||||||
|
is only kept when state actually changed (idempotent re-runs produce no undo point).
|
||||||
|
- [x] Per-lane play state driven immediately after minting: `planToggle` lane ops
|
||||||
|
applied via `applyLaneOps` so the freshly-minted lanes take the correct
|
||||||
|
`C_LANEPLAYS` state for the active mode without a full `applyMode` re-run (which
|
||||||
|
would re-park/restore whole tracks — not correct for a minting tick).
|
||||||
|
- [x] `mintManagedLanes` entry point in `view.cpp`: reads live track/item picture via
|
||||||
|
`readLaneTracks`, calls `planLaneMinting`, wraps the apply in one Undo block labelled
|
||||||
|
`"ReaSampler: separate cross-mode content into lanes"`, calls `UpdateTimeline()` +
|
||||||
|
`UpdateArrange()` after a fixed-lane mode change.
|
||||||
|
- [x] `reconcileManagedLanes` in `view.cpp`: on project load, reads every fixed-lane
|
||||||
|
track's `P_LANENAME` values; for each name carrying the managed prefix, records the
|
||||||
|
lane as managed-for-its-mode in the ownership index — pure read of REAPER state, no
|
||||||
|
lane created or renamed. Called from `main.cpp`'s load path before `applyMode`.
|
||||||
|
- [x] Lane-ownership index persists via `ViewModeModel::serialize()` /
|
||||||
|
`deserialize()` — the `LaneOwnershipIndex` is a member of `ViewModeModel` and
|
||||||
|
round-trips inside the `"reasampler"` `view_state` key alongside modes, membership,
|
||||||
|
snapshots, and active mode. No new persistence key required.
|
||||||
|
- [x] Detection tick integration: `mintManagedLanes` is called from the `bank_panel`
|
||||||
|
timer after the auto-tag pass, so a newly-tagged multi-mode track is split into lanes
|
||||||
|
on the same tick the content is detected.
|
||||||
|
|
||||||
|
**Notes/decisions:**
|
||||||
|
- **Single-mode-track rule:** a track carrying content of only ONE mode is not split —
|
||||||
|
D1's whole-track parking continues to separate its stance from the other mode without
|
||||||
|
lane overhead. The lane-split only engages when a track genuinely holds ≥ 2 modes'
|
||||||
|
content.
|
||||||
|
- **Manual-lane invariant upheld at the source:** `planLaneMinting` never receives
|
||||||
|
manual-lane items as split candidates. The shell's `readLaneTracks` marks items on
|
||||||
|
manual lanes `onManualLane = true`; the pure decision skips them entirely. Managed
|
||||||
|
lanes are always appended (tail ordinals), never overwriting a user's existing lanes.
|
||||||
|
- **Idempotency:** re-reporting an already-split track produces the same plan; the
|
||||||
|
shell's ensure/assign writes are no-ops when state already matches. The Undo block is
|
||||||
|
closed with no label (discarded by REAPER) when the plan is non-empty but every write
|
||||||
|
was already satisfied, so no phantom undo points accumulate.
|
||||||
|
- **Review passed** with no Critical or Major findings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D2-W3-B — item-level mode actions + W3-A polish
|
||||||
|
**Goal:** Item-level lane/mode-management actions mirroring the track-level Design
|
||||||
|
View tag family (bindable in the Actions list), plus the three code-review polish
|
||||||
|
items carried from D2-W3-A. The persist slice and lane-ownership index round-trip
|
||||||
|
were completed in D2-W3-A; this wave closes the remaining action surface and
|
||||||
|
cleans up the implementation.
|
||||||
|
See CONTEXT.md §Two-canvas sub-phase (Module architecture — persistence).
|
||||||
|
**Verify (in DAW):** Item mode actions registered and MIDI-bindable in the Actions
|
||||||
|
list; re-drive mint/apply so each item lands on its mode's managed lane; manual-lane
|
||||||
|
items exempt; one undo block per action. ctest 14/14 green.
|
||||||
|
**Depends on:** D2-W3-A.
|
||||||
|
|
||||||
|
- [x] "Move selected items → Design" action (`CEREBELLUM_REASAMPLER_VIEW_` family):
|
||||||
|
retags selected items' membership to Design mode, re-drives the existing mint/apply
|
||||||
|
so each item lands on its mode's managed lane; manual-lane items exempt; one undo
|
||||||
|
block.
|
||||||
|
- [x] "Move selected items → Arrange" action: retags selected items' membership to
|
||||||
|
Arrange mode, re-drives mint/apply; manual-lane items exempt; one undo block.
|
||||||
|
- [x] "Untag selected items" action: removes selected items' membership, re-drives
|
||||||
|
mint/apply; manual-lane items exempt; one undo block.
|
||||||
|
- [x] All three registered (`command_id`/`gaccel`/`hookcommand`); MIDI-bindable.
|
||||||
|
- [x] W3-A polish — simplified `applyMintPlan`'s redundant `I_NUMFIXEDLANES` re-read:
|
||||||
|
single grow-and-track pass removes the second `GetMediaTrackInfo_Value` call inside
|
||||||
|
the mint loop.
|
||||||
|
- [x] W3-A polish — extracted shared item-read seam (`src/item_read.{h,cpp}`):
|
||||||
|
removes duplicated `itemGuid`/`itemLaneName` read logic from `view.cpp` and
|
||||||
|
`bank_panel.cpp`.
|
||||||
|
- [x] W3-A polish — added reconcile guard in `reconcileManagedLanes`: skips lanes
|
||||||
|
encoding an unregistered mode id (log and skip rather than silently recording an
|
||||||
|
orphaned ownership entry).
|
||||||
|
|
||||||
|
**Notes/decisions:**
|
||||||
|
- ctest 14/14 green; review passed with no Critical or Major findings.
|
||||||
|
- **Panel UI indicator explicitly deferred** (Daniel's decision): a per-track
|
||||||
|
lane-split marker has no natural cheap home in the bank panel; the mode switch
|
||||||
|
already shows the active mode. Preserved as a deferred/backlog note in PLAN.md
|
||||||
|
Phase D2 — not silently dropped.
|
||||||
|
|||||||
+304
@@ -739,3 +739,307 @@ and the settled-decision prose above). One panel-polish detail remains open.
|
|||||||
- **Active-bank indicator placement (B4 polish)** — per-region headers vs. a single
|
- **Active-bank indicator placement (B4 polish)** — per-region headers vs. a single
|
||||||
header readout vs. lit-tab treatment. The "visually unmistakable" requirement is
|
header readout vs. lit-tab treatment. The "visually unmistakable" requirement is
|
||||||
settled (fork 4); only the placement is open. Panel-polish detail.
|
settled (fork 4); only the placement is open. Panel-polish detail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Sample removal — additive spec (Phase B, point B5)
|
||||||
|
|
||||||
|
> **Additive section, part of the Multi-bank pillar.** The sample-level companion
|
||||||
|
> to move/copy/evacuate/delete-bank: a verb that **drops a `Sample`'s index entry**
|
||||||
|
> from a bank (or the pool). Index-only, non-destructive to the file — it sits on
|
||||||
|
> the same side of the index/file line as every other Phase B op. Product framing:
|
||||||
|
> `docs/product/removal-and-prune.md` §Sample-remove. Same verify discipline:
|
||||||
|
> **verify every REAPER API name/signature against the SDK header before use.**
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
Move, copy, and evacuate all keep a sample *somewhere*; there was no verb to drop
|
||||||
|
a sample outright. **Sample-remove** is that verb: it removes one `Sample` entry
|
||||||
|
from one `BankIndex`. It exposes the `remove` primitive `bank_model`'s `BankIndex`
|
||||||
|
**already has** — B5 wires it to an action + a panel affordance, it does not add a
|
||||||
|
model capability.
|
||||||
|
|
||||||
|
## Settled decisions (spec-level)
|
||||||
|
|
||||||
|
- **Remove is index-only.** It removes the `Sample` from a `BankIndex` and mutates
|
||||||
|
only index + ext-state. No file is written, moved, or deleted; no timeline item
|
||||||
|
is touched. Identical non-destructive posture to move/copy/evacuate/delete-bank.
|
||||||
|
- **Remove can orphan a file — the same designed orphaned-until-prune state a
|
||||||
|
non-empty delete-bank produces.** When remove drops the *last* index reference to
|
||||||
|
a file (no other bank holds its hash), that file becomes an orphan on disk,
|
||||||
|
referenced by no bank, reclaimed later by **prune** (Phase R) — never by remove.
|
||||||
|
This is not a new hazard class; it is the existing "files persist until prune"
|
||||||
|
window, reached by a sample-level verb instead of a bank-level one.
|
||||||
|
- **Collapse-by-hash is unaffected.** Remove targets a specific entry in a specific
|
||||||
|
bank. Because cross-bank dedup is deliberately not enforced, removing a sample
|
||||||
|
from one bank leaves any same-hash entry in another bank intact — the same
|
||||||
|
coexistence copy relies on.
|
||||||
|
- **The pool's *contents* are removable; the pool *container* is not.** Pool
|
||||||
|
privileges (un-deletable, un-renamable, un-evacuable) govern the pool as a
|
||||||
|
container. Individual samples **can** be removed from the pool — otherwise the
|
||||||
|
pool would be a one-way trap. Remove-from-pool is the pool's own "drop this
|
||||||
|
sample" verb and is allowed.
|
||||||
|
- **Remove scope (fork R-A, SETTLED 2026-07-24 — this-bank).** Remove drops the
|
||||||
|
entry from *this* bank only, leaving copies in other banks untouched — the core
|
||||||
|
and only shipped verb. The action carries a `scope: this-bank | all-banks` seam,
|
||||||
|
but **this-bank is the settled default and the only surfaced affordance**;
|
||||||
|
all-banks stays a latent parameter (promotable later behind the seam without a
|
||||||
|
rewrite), never a surfaced verb now. See product notes §Fork R-A.
|
||||||
|
|
||||||
|
## Precision / invariant implications
|
||||||
|
|
||||||
|
- **Non-destructive** extends to remove verbatim: index + ext-state only, no file
|
||||||
|
touched, no timeline item touched.
|
||||||
|
- **Relative-paths-only** is unaffected — remove deletes an entry, it adds no path
|
||||||
|
handling.
|
||||||
|
- **Determinism / bit-identical / null-test (capture)** untouched — remove sits
|
||||||
|
above the file, same as all of multi-bank.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- **Confirm on last-reference remove; don't confirm otherwise.** A remove that
|
||||||
|
drops the *last* index reference to a file orphans it (until prune) — confirm
|
||||||
|
that case, naming the consequence ("…its file remains on disk until pruned"). A
|
||||||
|
remove of a sample still referenced by another bank is cheap and re-derivable
|
||||||
|
(re-copy it back) and needs no confirmation. The confirmation is *earned by
|
||||||
|
actual orphan risk*, not fired on every remove.
|
||||||
|
- **Undo (fork R-B, SETTLED 2026-07-24 — batched REAPER undo points, Phase-B-wide).**
|
||||||
|
Bank/index mutations integrate into REAPER's undo system as **batched undo points**
|
||||||
|
(`Undo_BeginBlock` / `Undo_EndBlock`): the related index mutations of one bank
|
||||||
|
operation are batched into a single undo point, so one bank operation is one
|
||||||
|
Ctrl-Z. This is a **Phase-B-wide** decision — it applies to
|
||||||
|
create/rename/reorder/delete-bank, move, copy, evacuate, *and* remove, retro-
|
||||||
|
touching B1–B4, not just B5. **Must-verify before build:** confirm against
|
||||||
|
`vendor/reaper-sdk` that `"reasampler"` ext-state mutations participate correctly
|
||||||
|
in `Undo_BeginBlock`/`Undo_EndBlock` undo blocks — the whole approach depends on
|
||||||
|
it. Surfaced with remove because remove is the first verb whose *only* effect is
|
||||||
|
index-entry destruction with no relocation, so it is where the gap first bit; the
|
||||||
|
fix is shared. See product notes §Fork R-B.
|
||||||
|
|
||||||
|
## Module architecture (preserve the pure/shell split)
|
||||||
|
|
||||||
|
- `bank_book` / `BankIndex` (pure) — expose remove of a `Sample` from a bank's
|
||||||
|
index (the existing `BankIndex::remove` primitive, surfaced through the book);
|
||||||
|
pool contents removable, pool-container privileges unchanged.
|
||||||
|
- `actions` (entry) — "remove selected sample(s) from bank" (and, under fork R-A,
|
||||||
|
a scope parameter); registered with the `command_id`/`gaccel`/`hookcommand`
|
||||||
|
contract; MIDI-bindable to suit the capture-heavy workflow.
|
||||||
|
- `bank_panel` (affordance) — remove on the current selection (menu entry / key),
|
||||||
|
reusing the M5 selection model exactly as move/copy do; confirm-on-last-reference
|
||||||
|
at this layer.
|
||||||
|
|
||||||
|
## REAPER API surface
|
||||||
|
|
||||||
|
No new REAPER API. Pure model + a new action command-id string under the sampler
|
||||||
|
family prefix + a panel affordance on the existing M5 LICE surface. Verify the
|
||||||
|
command-id/gaccel/hookcommand usage against `main.cpp` (unchanged contract).
|
||||||
|
|
||||||
|
## Settled forks (Daniel, 2026-07-24)
|
||||||
|
|
||||||
|
- **Fork R-A — remove scope.** Settled: **this-bank** (this-bank-primary, all-banks
|
||||||
|
a latent seam-only parameter). Folded into Settled decisions above.
|
||||||
|
- **Fork R-B — undo model for index mutations.** Settled: **batched REAPER undo
|
||||||
|
points** (`Undo_BeginBlock`/`Undo_EndBlock`), Phase-B-wide (retro-touches B1–B4),
|
||||||
|
with the ext-state-participation SDK check as a must-verify-before-build. Folded
|
||||||
|
into Guardrails above and the Phase B / B1 plan points.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Prune — file-lifecycle spec (Phase R — Reclaim)
|
||||||
|
|
||||||
|
> **New pillar, its own lettered phase.** Prune is the file-lifecycle path the
|
||||||
|
> capture and multi-bank specs forward-reference throughout ("files persist on disk
|
||||||
|
> until prune", "the capture/prune path reclaims it") but that had no phase, module,
|
||||||
|
> or point until now. It is the **only** operation in ReaSampler that deletes bytes
|
||||||
|
> off disk. Namespaced **`R` (Reclaim)** alongside `M` (capture), `D` (Design View),
|
||||||
|
> `B` (Banks) — it is a distinct pillar, not a Multi-bank sub-step, because it
|
||||||
|
> serves *every* orphan-producing path (delete-bank, sample-remove, re-capture) and
|
||||||
|
> carries a new risk class (file deletion) with its own invariants. Product framing
|
||||||
|
> and the phase-placement justification: `docs/product/removal-and-prune.md` §Prune.
|
||||||
|
> Same discipline: **verify every REAPER/SWELL/filesystem API name/signature against
|
||||||
|
> the SDK/SWELL headers before use.**
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
Over a project's life, delete-bank and sample-remove (and, potentially, M10
|
||||||
|
re-capture superseding an old file) leave `.wav` files on disk that no bank index
|
||||||
|
references — the "orphaned-until-prune" state the specs design in on purpose.
|
||||||
|
**Prune is the reclaim pass**: reconcile the physical bank folder against the union
|
||||||
|
of every bank's index, and reclaim the files nothing references. It makes good on
|
||||||
|
the promise the rest of the spec keeps making.
|
||||||
|
|
||||||
|
## The load-bearing rule
|
||||||
|
|
||||||
|
> **Remove creates orphans; prune reclaims them.** Sample-remove and delete-bank
|
||||||
|
> drop index entries and may leave a file referenced by nothing. Prune is the
|
||||||
|
> single path that turns such an orphan back into free disk space. **No other
|
||||||
|
> operation deletes a file; prune deletes *only* files that no index references.**
|
||||||
|
> A bank op that deletes a file is still a bug — prune is not a bank op, it is the
|
||||||
|
> file-lifecycle op.
|
||||||
|
|
||||||
|
This asymmetry is deliberate and must be stated loudly: every *other* invariant
|
||||||
|
says "no operation deletes a file." Prune is the sole, explicit exception, and its
|
||||||
|
entire job is deletion — so it must be the *only* file-deleting authority in the
|
||||||
|
system, with the strongest guardrails.
|
||||||
|
|
||||||
|
## Mirror of `reconcile` — the pure pattern one level down
|
||||||
|
|
||||||
|
Prune reuses the shape Design View already shipped. `view_mode_model`'s
|
||||||
|
`ViewModeModel::reconcile(liveGuids)` reconciles *membership entries* against *live
|
||||||
|
tracks* and returns the residuals to drop. **Prune reconciles *files on disk*
|
||||||
|
against *referenced files*** (the union of every bank's index) and returns the
|
||||||
|
orphan set to delete. Same pure pattern, one level down (files instead of GUIDs).
|
||||||
|
|
||||||
|
The **decision is pure and unit-tested**: given the set of files present in the
|
||||||
|
bank folder and the set of files referenced by the book, compute the orphan set.
|
||||||
|
Only the two ends touch the shell — *enumerating* the bank folder and *deleting*
|
||||||
|
the orphans are filesystem I/O. Keep the "which files are orphans" core REAPER-free
|
||||||
|
and hard-tested (this is the safety-critical part); keep the I/O thin. Same
|
||||||
|
pure/shell split as `bank_model` / `view_mode_model` / `bank_book`.
|
||||||
|
|
||||||
|
## Settled decisions (spec-level)
|
||||||
|
|
||||||
|
- **Referenced-set is the union across ALL banks, pool included.** A file is an
|
||||||
|
orphan iff **no** bank in the book references it. Because copy lets one file be
|
||||||
|
referenced by several banks, prune must union references across the whole book
|
||||||
|
before deciding. This is the safety-critical computation — the **prune null
|
||||||
|
test** is *prune never deletes a file that any index references.*
|
||||||
|
- **Project-relative resolution, current folder.** Prune enumerates and deletes
|
||||||
|
within the project bank folder using the **same M4 project-relative path
|
||||||
|
resolution** the index uses, against the *resolved current* folder — never a
|
||||||
|
stale absolute path — so a Save-As relocation cannot cause it to mis-identify or
|
||||||
|
mis-target orphans.
|
||||||
|
- **Dry-run first, always.** Prune reports before it deletes: the orphan count,
|
||||||
|
reclaimed size, and (for a small set) the files. The dry-run — compute-and-report,
|
||||||
|
the pure core with no deletion — is the primary surface; actual deletion is the
|
||||||
|
confirmed second step. A prune that silently sweeps is unacceptable for an
|
||||||
|
irreversible file-delete.
|
||||||
|
- **Scope is the bank system's own leavings, not the folder at large.** Prune
|
||||||
|
reclaims files that *were* bank files and are now unreferenced — never a file a
|
||||||
|
user hand-dropped into the folder. Prune is a reclaimer of ReaSampler's own
|
||||||
|
orphans, not a general folder cleaner.
|
||||||
|
- **Orphan attribution is an owned-file manifest (fork R-D, SETTLED 2026-07-24).**
|
||||||
|
The book tracks the set of files it has created (an **owned-file manifest**);
|
||||||
|
prune reclaims `(owned ∩ on-disk) − referenced`. This is the honest encoding of
|
||||||
|
"reclaim only our own leavings" and rejects folder-sweep (which would delete
|
||||||
|
hand-dropped files). **The seam lands early:** because the manifest is cheap to
|
||||||
|
maintain from capture onward but a backfill cliff to reconstruct later, **capture
|
||||||
|
writes each file it creates into the owned-file manifest starting in Phase B**,
|
||||||
|
even though prune consumes it only in Phase R. R1/R2 consume the manifest; they do
|
||||||
|
not build it. The manifest is persisted in the `"reasampler"` ext-state; the exact
|
||||||
|
persistence shape (a sibling key vs. folded into the `banks` blob) is a small
|
||||||
|
build-time residual, but the manifest-now decision is firm.
|
||||||
|
|
||||||
|
## Precision / invariant implications
|
||||||
|
|
||||||
|
- **The single intentional exception to "no operation deletes files."** Stated
|
||||||
|
above; called out again here so the invariant table is honest: prune is
|
||||||
|
destructive-to-files *by design and by exclusive authority*.
|
||||||
|
- **Relative-paths-only / Save-As machinery reused** — prune resolves paths the
|
||||||
|
same way the index does (M4), so it inherits relative-path correctness and
|
||||||
|
Save-As survival; it introduces no new path handling.
|
||||||
|
- **Determinism / bit-identical / null-test (capture)** untouched — prune sits
|
||||||
|
below the capture path entirely.
|
||||||
|
- **Prune null test (new invariant):** a prune of a folder whose every file is
|
||||||
|
referenced by some bank deletes nothing; a prune deletes exactly the
|
||||||
|
`present − referenced` orphan set and nothing else. Ship as a tested property of
|
||||||
|
the pure core.
|
||||||
|
|
||||||
|
## Guardrails — the genuinely destructive act
|
||||||
|
|
||||||
|
- **Dry-run + confirm-with-manifest** (above): the user approves a *specific*
|
||||||
|
deletion (count + size + files), never an abstract "clean up."
|
||||||
|
- **Never a referenced file; never a non-bank file.** The union-across-all-banks
|
||||||
|
rule protects referenced files; the ownership-attribution rule (fork R-D)
|
||||||
|
protects hand-dropped files.
|
||||||
|
- **Safest platform deletion available (fork R-C, SETTLED 2026-07-24 — trash-
|
||||||
|
preferred, unlink fallback).** Route deletions to the platform recycle bin / trash
|
||||||
|
wherever a portable move-to-trash is available (recoverable outside the app); fall
|
||||||
|
back to unlink — behind the dry-run + confirm guardrail — only where the platform
|
||||||
|
affords no portable trash. "Delete where possible" means recoverable-trash-
|
||||||
|
preferred, never plain unlink-by-default. The move-to-trash surface is an explicit
|
||||||
|
per-platform **to-verify** (see REAPER/platform API surface).
|
||||||
|
- **Manual, explicit trigger (fork R-E, SETTLED 2026-07-24 — manual action + panel
|
||||||
|
button).** Prune runs via a bindable manual action (dry-run-first, confirm-to-
|
||||||
|
delete) **and** a `bank_panel` button that fires that same action — never a silent
|
||||||
|
background sweep. The earlier optional "…and prune now at the delete-bank
|
||||||
|
confirmation" convenience was **not** selected and is out of scope; a periodic
|
||||||
|
background sweep remains rejected (silent irreversible file-deletion violates the
|
||||||
|
guardrails).
|
||||||
|
|
||||||
|
## Module architecture (preserve the pure/shell split)
|
||||||
|
|
||||||
|
Pure (no REAPER types, unit-tested — the mirror of `reconcile`):
|
||||||
|
- **Prune-reconcile core** — given `{ files present in the bank folder }`,
|
||||||
|
`{ files referenced by the book }`, and `{ files the book owns }` (the owned-file
|
||||||
|
manifest, R-D), compute the orphan set `(owned ∩ present) − referenced`.
|
||||||
|
REAPER-free, filesystem-free, unit-tested hard (the prune null test lives here).
|
||||||
|
The referenced-set is unioned across all banks by asking the `bank_book`.
|
||||||
|
|
||||||
|
REAPER-facing / filesystem-facing (thin):
|
||||||
|
- `persist` / session — supplies the referenced-set (union across the book) and the
|
||||||
|
owned-file manifest (R-D, written from capture onward in Phase B); resolves the
|
||||||
|
current project bank folder via the M4 project-relative machinery.
|
||||||
|
- A **prune shell** — enumerates the bank folder (filesystem I/O), feeds the
|
||||||
|
pure core, presents the dry-run manifest, and on confirmation deletes the orphan
|
||||||
|
set (via OS trash where portably available — fork R-C — else unlink). Filesystem
|
||||||
|
I/O only; the decision stays in the pure core.
|
||||||
|
- `actions` (entry) — "Prune bank folder" (dry-run-first, confirm-to-delete),
|
||||||
|
registered with the `command_id`/`gaccel`/`hookcommand` contract; **plus a
|
||||||
|
`bank_panel` button** (R-E) that fires the same action.
|
||||||
|
|
||||||
|
## REAPER / platform API surface (verify all signatures)
|
||||||
|
|
||||||
|
No new REAPER *audio* API. New surfaces to verify before use:
|
||||||
|
- **Filesystem enumeration + delete** — directory listing and file removal for the
|
||||||
|
project bank folder. **Verify** the portable approach against SWELL / the existing
|
||||||
|
file-handling in `persist` / `capture` (which already resolve and write files);
|
||||||
|
prefer reusing whatever path/file machinery M4 established.
|
||||||
|
- **Move-to-trash (fork R-C, settled trash-preferred)** — verify a portable
|
||||||
|
move-to-trash exists (SWELL, or per-platform: Win `IFileOperation`/
|
||||||
|
`SHFileOperation`, macOS `NSFileManager trashItemAtURL:`, Linux XDG trash spec).
|
||||||
|
This is a **must-verify per platform** before use, not an assumed capability;
|
||||||
|
where it is unavailable, fall back to unlink behind the dry-run/confirm guardrail.
|
||||||
|
- **Owned-file manifest persistence (fork R-D, settled)** — a new tracked set in
|
||||||
|
the `"reasampler"` ext-state (a sibling key or folded into the `banks` blob —
|
||||||
|
build-time residual); shared M4 blob machinery, new data only. **Written from
|
||||||
|
capture onward in Phase B** (the seam lands early), consumed by prune in Phase R.
|
||||||
|
- **Actions** — the `command_id`/`gaccel`/`hookcommand` contract from `main.cpp`
|
||||||
|
(unchanged), a new command-id string under the sampler family prefix.
|
||||||
|
|
||||||
|
## Non-goals / guardrails
|
||||||
|
|
||||||
|
- **Prune is the ONLY file-deletion authority.** No bank op, no capture op, no
|
||||||
|
Design View op deletes a file. If any path other than prune deletes a bank file,
|
||||||
|
reject it in review.
|
||||||
|
- **No general folder cleaning.** Prune reclaims the bank system's own unreferenced
|
||||||
|
leavings, not arbitrary files a user placed in the folder (fork R-D governs the
|
||||||
|
attribution).
|
||||||
|
- **No silent deletion.** Dry-run + explicit confirm always; no background sweep.
|
||||||
|
- **No file deleted while any index references it.** The referenced-set union
|
||||||
|
across all banks is the safety-critical invariant — enforce and test it in the
|
||||||
|
pure core, not just the UI.
|
||||||
|
- **Additive only.** Prune reads the book and the folder; it does not modify
|
||||||
|
`BankIndex`, `bank_book`, the capture roadmap, or Design View semantics.
|
||||||
|
|
||||||
|
## Settled forks (Daniel, 2026-07-24)
|
||||||
|
|
||||||
|
- **Fork R-C — deletion mechanism.** Settled: **trash-preferred, unlink fallback.**
|
||||||
|
Route to the OS trash where a portable move-to-trash is available (recoverable),
|
||||||
|
else unlink behind the dry-run/confirm guardrail. Per-platform trash surface is a
|
||||||
|
must-verify. Folded into Settled decisions + Guardrails + API surface above.
|
||||||
|
Product notes §Fork R-C.
|
||||||
|
- **Fork R-D — orphan attribution.** Settled: **owned-file manifest**, `(owned ∩
|
||||||
|
present) − referenced`; folder-sweep rejected as unsafe. The **seam lands early** —
|
||||||
|
capture writes each created file to the manifest starting in Phase B, prune
|
||||||
|
consumes it in Phase R. Persistence shape (sibling key vs. `banks` blob) is a
|
||||||
|
build-time residual. Folded into Settled decisions + Module architecture + API
|
||||||
|
surface above, and added as an up-front Phase B / capture plan point. Product
|
||||||
|
notes §Fork R-D.
|
||||||
|
- **Fork R-E — trigger.** Settled: **manual action + `bank_panel` button**, dry-run-
|
||||||
|
first, confirm-to-delete; no background sweep. The delete-time "…and prune now"
|
||||||
|
convenience was not selected (out of scope). Folded into Guardrails + Module
|
||||||
|
architecture above and the R3 plan points. Product notes §Fork R-E.
|
||||||
|
|
||||||
|
**Build-time residual (not a fork):** the owned-file manifest's exact persistence
|
||||||
|
shape (sibling `"reasampler"` ext-state key vs. folded into the `banks` blob).
|
||||||
|
|||||||
@@ -23,21 +23,47 @@ state persists via the index.
|
|||||||
- [ ] Slot model + slot↔sample assignment.
|
- [ ] Slot model + slot↔sample assignment.
|
||||||
- [ ] "Capture to slot N" / "insert slot N" actions, MIDI-bindable.
|
- [ ] "Capture to slot N" / "insert slot N" actions, MIDI-bindable.
|
||||||
|
|
||||||
## Milestone 10 — provenance + null-test verify action
|
## Milestone 10 — provenance (re-capture from source)
|
||||||
**Goal:** Provenance (parent sample id + FX-chain snapshot) and "re-capture from
|
**Goal:** Populate `Sample.provenance` (parent sample id + a capture-recipe
|
||||||
source"; ship the null-test verification action. CONTEXT.md §Precision invariants,
|
fingerprint) on resample-from-sample, and ship a **"re-capture from source"**
|
||||||
Build order 10.
|
action that regenerates a sample from its recorded source. Reconciled with the
|
||||||
**Verify (in DAW):** **Null test** — a dry offline capture of a range, re-inserted
|
dual-canvas (Phase D2) model. CONTEXT.md §Data model, §capture; product framing +
|
||||||
at its source position, nulls to silence against the source. This action is the
|
the settled reconciliation in `docs/product/provenance.md`.
|
||||||
tool's trust anchor and must pass.
|
**Verify (in DAW):** A sample resampled from a bank sample carries its parent id +
|
||||||
|
recipe fingerprint; "re-capture from source" regenerates the file into the bank
|
||||||
|
(never auto-inserting into the timeline — load-bearing principle); re-capture with
|
||||||
|
an unchanged source + request is byte-identical to the original (bit-identical
|
||||||
|
repeats); non-destructive to source items/tracks.
|
||||||
|
|
||||||
- [ ] Provenance fields populated on resample-from-sample (parent id + FX-chain
|
> **Reshaped from the old "provenance + null-test verify" M10.** **Cut (fixed by
|
||||||
snapshot string).
|
> Daniel):** the null-test verification *action* and the true-pre-FX-dry *mechanism*
|
||||||
- [ ] "Re-capture from source" action.
|
> the old note required — both dropped, see `docs/product/provenance.md` §What was
|
||||||
- [ ] Null-test verification action (capture → re-insert at source pos → assert
|
> cut. **Kept:** provenance + re-capture. The `Sample.provenance` struct and its JSON
|
||||||
silence sum).
|
> round-trip **already exist** (M1) — M10 populates and consumes the field, it does
|
||||||
|
> not add it. Fork picks settled by Daniel (2026-07-23): **P1=a thin fingerprint,
|
||||||
|
> P2=a bank-only re-capture**; P3/P4 moot under P2=a. The points below are locked to
|
||||||
|
> that path.
|
||||||
|
|
||||||
**Note (from M7):** The null test requires a TRUE pre-FX dry capture, which REAPER offline render cannot produce via RENDER_SETTINGS (there is no pre-FX bit). True dry must be obtained by bypassing the source FX around an offline render (snapshot→bypass→render→restore) OR via the M8 realtime pre-FX path — so the dry-capture mechanism should be designed as part of the M10 null-test work.
|
- [ ] Populate `Sample.provenance` on resample-from-sample: `parentSampleId` (the
|
||||||
|
bank sample the capture derived from) + `fxChainSnapshot` as a **thin capture-recipe
|
||||||
|
fingerprint** (scope + source FX-chain identity/hash at capture time — a drift/repro
|
||||||
|
fingerprint, NOT a serialized pre-FX-dry chain to restore; P1=a settled).
|
||||||
|
- [ ] "Re-capture from source" action: regenerate a provenanced sample by re-running
|
||||||
|
its recorded capture request against the source's **current** state; update the
|
||||||
|
bank file + Sample in place. **Bank-only — never inserts/re-places into the
|
||||||
|
timeline** (load-bearing principle). Reports if the source drifted since capture.
|
||||||
|
- [ ] Verify: re-capture of an unchanged source is byte-identical to the original
|
||||||
|
capture (bit-identical repeats); non-destructive (`FxBypassGuard` snapshot/restore
|
||||||
|
as M7); relative-paths-only preserved.
|
||||||
|
|
||||||
|
**Dual-canvas reconciliation (settled — `docs/product/provenance.md`):** With
|
||||||
|
bank-only re-capture (P2=a), provenance is **pure per-sample bank metadata**,
|
||||||
|
`bank_model` and `view_mode_model` **stay decoupled**, and M10 touches **no** canvas
|
||||||
|
code. Dual-canvas compliance is satisfied by staying on the right side of the
|
||||||
|
capture-never-places line — not by any new coupling. Forks P3 (canvas/lane memory in
|
||||||
|
provenance) and P4 (re-capture auto-tag interaction) were only live under
|
||||||
|
re-capture-and-replace (P2=b) and are **closed as moot**; if the user manually
|
||||||
|
re-places a regenerated sample, the existing D2 mode-aware placement rule governs.
|
||||||
|
|
||||||
## Milestone 11 — polish
|
## Milestone 11 — polish
|
||||||
**Goal:** Batch capture (per selected item / per razor area),
|
**Goal:** Batch capture (per selected item / per razor area),
|
||||||
@@ -50,6 +76,20 @@ invariants; drag-out places a valid file in the OS target.
|
|||||||
- [ ] Resample-and-mute-source.
|
- [ ] Resample-and-mute-source.
|
||||||
- [ ] Conform-on-insert (explicit).
|
- [ ] Conform-on-insert (explicit).
|
||||||
- [ ] Native OS drag-out (deferred final; `InsertMedia` path must already work).
|
- [ ] Native OS drag-out (deferred final; `InsertMedia` path must already work).
|
||||||
|
- [ ] Keybinding help labels: in the docked bank_panel, surface the current key
|
||||||
|
binding for each capture/provenance action (e.g. "Capture Item → <key>") by
|
||||||
|
querying the SDK for the key bound to the action's command id
|
||||||
|
(`kbd_getTextFromCmd(cmd, SectionFromUniqueID(0))` — main section) and formatting
|
||||||
|
a reminder label. Unbound case degrades to the action name with a clear
|
||||||
|
"unbound"/"—" marker (empty/blank return handled explicitly). Split: label-text
|
||||||
|
formatting (binding string + fallback → label) is **pure/testable**; the SDK
|
||||||
|
binding query + label draw is bank_panel shell.
|
||||||
|
- [ ] Action trigger buttons: clickable bank_panel buttons that fire the capture and
|
||||||
|
provenance actions directly, routing through the **existing** command-id contract
|
||||||
|
(`Main_OnCommand`/`KBD_OnMainActionEx` with the registered command id — the same id
|
||||||
|
minted at `registerAction`), never re-implementing capture. Split: button
|
||||||
|
hit-testing/layout math is **pure/testable** (mirror of `mode_switch`/`bank_grid`);
|
||||||
|
draw + command dispatch is bank_panel shell.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -65,39 +105,6 @@ landed milestone.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Milestone T — capture tail (rider on the offline render path)
|
|
||||||
|
|
||||||
> **Rider, not a new pillar.** Tail preservation wires into the already-shipped
|
|
||||||
> offline `OfflineRenderBackend` (M3/M7) — no new backend, no new render trigger.
|
|
||||||
> It takes a **T** tag (not an M-number) because it is an enhancement to landed
|
|
||||||
> capture, sequenced independently of M8–M11. Authoritative spec:
|
|
||||||
> **`docs/product/capture-tail.md`** (full `RENDER_*` values, the surgical
|
|
||||||
> `RENDER_NORMALIZE`, the realtime parallel path, invariant interactions,
|
|
||||||
> acceptance criteria, DAW-confirm items). Parameters set by Daniel: auto-trim
|
|
||||||
> threshold **-72 dB**, max-tail cap **8 s**. When a point lands, doc-keeper moves
|
|
||||||
> it to `COMPLETED.md`.
|
|
||||||
|
|
||||||
## T2 — realtime tail (follow-on to T1)
|
|
||||||
**Goal:** The parallel tail path for the M8 realtime backend, which does not drive
|
|
||||||
`RENDER_*`: record an 8 s-capped tail window past the range end, then **trim in a
|
|
||||||
PCM decay-scan** to the -72 dB point (Manual = record fixed tail, skip the scan).
|
|
||||||
See `docs/product/capture-tail.md` §The realtime path.
|
|
||||||
**Verify (in DAW):** A realtime Auto capture of a decaying source records ≥ the range
|
|
||||||
then trims at the -72 dB decay point (± inherent realtime tolerance); realtime tail is
|
|
||||||
**not** asserted bit-identical (documented non-determinism).
|
|
||||||
**Depends on:** T1, M8.
|
|
||||||
|
|
||||||
- [ ] Record `[start, end + clamp(tail, 8 s)]` (extend the record time selection in
|
|
||||||
`capture_realtime.cpp`); Manual skips the scan, Auto proceeds to it.
|
|
||||||
- [ ] Pure decay-scan helper alongside `peaks`: `lastFrameAboveThreshold(interleaved,
|
|
||||||
channels, frames, linearThreshold) -> frameIndex` (backward scan, per-frame max-abs
|
|
||||||
across channels, no fold); unit-tested with a synthetic decaying ramp. (Spec §realtime
|
|
||||||
path option (a) — recommended over bending `computeEnvelope`.)
|
|
||||||
- [ ] Realtime shell: read the recorded wav PCM into a float buffer, find the trim
|
|
||||||
frame, rewrite the file truncated (new I/O the backend does not do today).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Phase D2 — Two-canvas (item-level mode projection; additive to D1)
|
# Phase D2 — Two-canvas (item-level mode projection; additive to D1)
|
||||||
|
|
||||||
> **Design View sub-phase.** Extends D1's track-level mode projection to **item
|
> **Design View sub-phase.** Extends D1's track-level mode projection to **item
|
||||||
@@ -111,51 +118,15 @@ then trims at the -72 dB decay point (± inherent realtime tolerance); realtime
|
|||||||
> spec. Product framing: `docs/product/design-view.md` §Two-canvas direction. When a
|
> spec. Product framing: `docs/product/design-view.md` §Two-canvas direction. When a
|
||||||
> point lands, doc-keeper moves it to `COMPLETED.md`.
|
> point lands, doc-keeper moves it to `COMPLETED.md`.
|
||||||
>
|
>
|
||||||
> **D2-W1 (pure lane extension) has landed** — see `COMPLETED.md`.
|
> **D2-W1 (pure lane extension), D2-W2 (shell: lane application + new-content
|
||||||
|
> detection), D2-W3-A (lane minting + item→lane assignment + persist round-trip),
|
||||||
## D2-W2 — shell: lane application + new-content detection
|
> and D2-W3-B (item-level mode actions + W3-A polish) have all landed** — see
|
||||||
**Goal:** The view shell applies the planner's managed-lane ops in the DAW and the
|
> `COMPLETED.md`. **Phase D2 is functionally complete.**
|
||||||
bank_panel timer detects new content and auto-tags it to the active mode. Resolves
|
>
|
||||||
the two flagged implementation design points (I_FIXEDLANE reorder/renumber
|
> **Deferred:** panel UI indicator for per-track lane/mode state (a per-track
|
||||||
fragility; the auto-tag / manual-lane detection heuristic). See CONTEXT.md
|
> lane-split marker). The mode switch already shows the active mode; no natural
|
||||||
§Two-canvas sub-phase (Module architecture — shell; New-content detection).
|
> cheap home for a per-track indicator was found in the bank panel. Explicitly
|
||||||
**Verify (in DAW):** Toggling a mode shows + plays only the active mode's managed
|
> deferred — not silently dropped. Can be picked up later if wanted.
|
||||||
lane, hides + silences the inactive-mode lane, and **never touches a manual lane**
|
|
||||||
(its `C_LANEPLAYS` stays exactly as the user set it); new content created while a
|
|
||||||
mode is active is tagged to that mode; pre-existing content stays Arrange (no
|
|
||||||
mass-tag on the first poll after open).
|
|
||||||
**Depends on:** D2-W1.
|
|
||||||
|
|
||||||
- [ ] Apply managed-lane ops in the view shell (`I_FREEMODE`/`I_FIXEDLANE`/
|
|
||||||
`C_LANEPLAYS`/`B_FIXEDLANE_HIDDEN` via the item/track info setters;
|
|
||||||
`UpdateTimeline()` after `I_FREEMODE`); **managed lanes only, never manual**.
|
|
||||||
Verify every flag name/signature against the SDK header.
|
|
||||||
- [ ] New-content detection on the bank_panel timer: diff the live track/item GUID
|
|
||||||
set against the previous poll; tag any GUID new since the last poll to the
|
|
||||||
then-active mode, with a **first-poll-after-open guard** (pre-existing ⇒ Arrange,
|
|
||||||
no mass-tag) and the **manual-lane exemption** (items in a manual lane not tagged).
|
|
||||||
- [ ] Resolve the manual-lane detection heuristic (which new items are exempt) and
|
|
||||||
the `I_FIXEDLANE` lane-identity fragility (index survival across lane
|
|
||||||
reorder/renumber/deletion) — the two open design points from CONTEXT.md.
|
|
||||||
- [ ] D2-W1 review polish: document the one-managed-lane-per-mode-per-track
|
|
||||||
exclusivity assumption in `laneModeState` (comment / debug-guard); clarify the
|
|
||||||
`serialize()` one-line style note; optional round-trip tests for the
|
|
||||||
last-writer-wins lane-replace contract.
|
|
||||||
|
|
||||||
## D2-W3 — actions, persist wiring, panel UI
|
|
||||||
**Goal:** Any new lane/mode-management actions, the persist slice serializing the
|
|
||||||
lane-ownership index alongside the membership index, and any panel UI indicator.
|
|
||||||
See CONTEXT.md §Two-canvas sub-phase (Module architecture — persistence).
|
|
||||||
**Verify (in DAW):** The lane-ownership index survives Save / Save As / reopen
|
|
||||||
(rides in the `"reasampler"` `view_state` alongside the membership index);
|
|
||||||
lane/mode-management actions are registered and bindable.
|
|
||||||
**Depends on:** D2-W2.
|
|
||||||
|
|
||||||
- [ ] Any new lane/mode-management actions (`command_id`/`gaccel`/`hookcommand`);
|
|
||||||
bindable in the Actions list.
|
|
||||||
- [ ] Persist slice: serialize/deserialize the lane-ownership index in the
|
|
||||||
`"reasampler"` `view_state` section, alongside the membership index.
|
|
||||||
- [ ] Any panel UI indicator for lane/mode state.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -205,6 +176,35 @@ active id. Legacy `bank_index` JSON parses into `{ pool }` with zero named banks
|
|||||||
with dest collapse; cross-bank same-hash coexistence; dest collapse on move into a
|
with dest collapse; cross-bank same-hash coexistence; dest collapse on move into a
|
||||||
bank already holding the hash; JSON lossless; legacy migration.
|
bank already holding the hash; JSON lossless; legacy migration.
|
||||||
|
|
||||||
|
> **Phase-B-wide undo (fork R-B, settled 2026-07-24 — batched REAPER undo points).**
|
||||||
|
> Every index verb across B1–B5 (create/rename/reorder/delete-bank, move, copy,
|
||||||
|
> evacuate, remove) wraps its bank/index mutation in a **batched REAPER undo point**
|
||||||
|
> (`Undo_BeginBlock` / `Undo_EndBlock`), so one bank operation is one Ctrl-Z. This is
|
||||||
|
> a cross-cutting decision that retro-touches B1–B4, not a B5-local one; the
|
||||||
|
> per-verb points above inherit it. **Must-verify before build:** confirm against
|
||||||
|
> `vendor/reaper-sdk` that `"reasampler"` ext-state mutations participate correctly
|
||||||
|
> in `Undo_BeginBlock`/`Undo_EndBlock` undo blocks — the whole approach depends on
|
||||||
|
> it. See CONTEXT.md §Sample removal (Guardrails) + product notes §Fork R-B.
|
||||||
|
|
||||||
|
## B-cap — owned-file manifest seam (capture writes; prune consumes in Phase R)
|
||||||
|
**Goal:** Capture writes each file it creates into an **owned-file manifest**
|
||||||
|
persisted in the `"reasampler"` ext-state, so Phase R prune can later distinguish
|
||||||
|
the bank system's own orphans from hand-dropped files. Consumed only in Phase R
|
||||||
|
(R1/R2) — landed early here because reconstructing the manifest retroactively is a
|
||||||
|
backfill cliff (fork R-D, settled 2026-07-24: *defer the feature, design the seam*).
|
||||||
|
CONTEXT.md §Prune (Settled decisions — orphan attribution) + product notes §Fork R-D.
|
||||||
|
**Verify:** every file the capture path creates is recorded in the owned-file
|
||||||
|
manifest; the manifest round-trips through the `"reasampler"` ext-state (Save / Save
|
||||||
|
As / reopen); relative-paths-only preserved. Prune's consumption of it is Phase R.
|
||||||
|
**Depends on:** the capture add-path (M7) + persist blob machinery (M4 / B2).
|
||||||
|
|
||||||
|
- [ ] Capture records each created file into an owned-file manifest (the set of
|
||||||
|
files the book has created), persisted in the `"reasampler"` ext-state (sibling
|
||||||
|
key or folded into the `banks` blob — persistence shape is a small build-time
|
||||||
|
residual, not a fork).
|
||||||
|
- [ ] Manifest round-trips: survives Save / Save As / reopen via the M4 blob
|
||||||
|
machinery; relative-paths-only. (Consumed by Phase R R1/R2 — not consumed here.)
|
||||||
|
|
||||||
## B2 — persist slice (banks ↔ project ext state)
|
## B2 — persist slice (banks ↔ project ext state)
|
||||||
**Goal:** Serialize the book under the `banks` key in `"reasampler"` alongside the
|
**Goal:** Serialize the book under the `banks` key in `"reasampler"` alongside the
|
||||||
existing sections, with the pool folded in as bank-zero; migrate a legacy
|
existing sections, with the pool folded in as bank-zero; migrate a legacy
|
||||||
@@ -276,6 +276,35 @@ and product notes → *Fork 5 — settled*.)
|
|||||||
index-only and reversible). **Verify the drag hit-test doesn't collide with the M5
|
index-only and reversible). **Verify the drag hit-test doesn't collide with the M5
|
||||||
grid's multi-select drag.**
|
grid's multi-select drag.**
|
||||||
|
|
||||||
|
## B5 — sample-remove (the missing sample-level verb)
|
||||||
|
**Goal:** Drop an individual `Sample`'s index entry from a bank or the pool —
|
||||||
|
the sample-level companion to move/copy/evacuate/delete-bank. Index-only,
|
||||||
|
non-destructive to the file; exposes the `BankIndex::remove` primitive that
|
||||||
|
`bank_model` already has (wires it, does not add it). CONTEXT.md §Sample removal.
|
||||||
|
Product framing + open forks: `docs/product/removal-and-prune.md` §Sample-remove.
|
||||||
|
**Verify (in DAW):** Remove drops the selected sample's entry from the target
|
||||||
|
bank; a same-hash entry in another bank is untouched (no cross-bank dedup);
|
||||||
|
pool *contents* are removable while pool-container privileges hold; removing the
|
||||||
|
last index reference to a file leaves that file on disk (orphaned until prune —
|
||||||
|
never deleted by remove); non-destructive (index + ext-state only, no file, no
|
||||||
|
timeline item).
|
||||||
|
**Depends on:** B1, B2, B3 (action set), B4 (panel affordance).
|
||||||
|
|
||||||
|
- [ ] Surface `BankIndex::remove` through `bank_book`: remove a `Sample` from a
|
||||||
|
bank's index; pool contents removable, pool-container privileges unchanged.
|
||||||
|
- [ ] "Remove selected sample(s)" action (`command_id`/`gaccel`/`hookcommand`),
|
||||||
|
MIDI-bindable; carries a `scope: this-bank | all-banks` seam (fork R-A, settled
|
||||||
|
2026-07-24: **this-bank** is the default and only surfaced affordance; all-banks
|
||||||
|
stays a latent seam-only parameter, not shipped).
|
||||||
|
- [ ] `bank_panel` remove affordance on the current selection (reuse M5 selection
|
||||||
|
model, as move/copy do).
|
||||||
|
- [ ] Confirm-on-last-reference guardrail: remove that orphans a file (no other
|
||||||
|
bank references it) confirms, naming the orphaned-until-prune consequence;
|
||||||
|
remove of a still-referenced sample does not confirm.
|
||||||
|
- [ ] Tests: remove drops the target entry; same-hash entry in another bank
|
||||||
|
survives; remove-from-pool allowed; last-reference remove leaves an orphan (file
|
||||||
|
untouched); non-destructive (no file/timeline mutation).
|
||||||
|
|
||||||
## Phase B open questions
|
## Phase B open questions
|
||||||
All five forks settled by Daniel (2026-07-23): persistence key = fold pool into `banks`,
|
All five forks settled by Daniel (2026-07-23): persistence key = fold pool into `banks`,
|
||||||
retire legacy key (1a); delete drops members + add evacuate verb (2); move is the
|
retire legacy key (1a); delete drops members + add evacuate verb (2); move is the
|
||||||
@@ -287,3 +316,122 @@ ready to scope into implementation waves. One polish detail remains:
|
|||||||
- **Active-bank indicator placement** — per-region headers vs. single header readout
|
- **Active-bank indicator placement** — per-region headers vs. single header readout
|
||||||
vs. lit-tab. "Unmistakable" is settled; only placement is open. Polish detail.
|
vs. lit-tab. "Unmistakable" is settled; only placement is open. Polish detail.
|
||||||
(touches B4)
|
(touches B4)
|
||||||
|
|
||||||
|
**B5 sample-remove forks — settled 2026-07-24:**
|
||||||
|
- **R-A — remove scope.** Settled: **this-bank**. Removes the entry from the bank in
|
||||||
|
view only; the `scope: this-bank | all-banks` seam stays in the action signature
|
||||||
|
but all-banks is a latent parameter, not a surfaced verb. Folded into the B5 action
|
||||||
|
point above.
|
||||||
|
- **R-B — undo model (Phase-B-wide).** Settled: **batched REAPER undo points**
|
||||||
|
(`Undo_BeginBlock`/`Undo_EndBlock`), one bank op = one Ctrl-Z. Applies across
|
||||||
|
B1–B5 (retro-touches B1–B4) — captured as the cross-cutting note under B1 above,
|
||||||
|
with the ext-state-participation SDK check as a must-verify-before-build.
|
||||||
|
Both in `docs/product/removal-and-prune.md` §Fork R-A / §Fork R-B.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase R — Reclaim (file lifecycle: the prune path)
|
||||||
|
|
||||||
|
> **New pillar, own lettered namespace.** Prune is the file-lifecycle path the
|
||||||
|
> capture and multi-bank specs forward-reference throughout ("files persist on disk
|
||||||
|
> until prune") but that had no phase, module, or point. It is the **only** operation
|
||||||
|
> in ReaSampler that deletes bytes off disk. Namespaced **`R` (Reclaim)** alongside
|
||||||
|
> `M`/`D`/`B` because it is a distinct pillar — it serves *every* orphan-producing
|
||||||
|
> path (delete-bank, sample-remove B5, potentially M10 re-capture), not just
|
||||||
|
> Multi-bank, and it carries a new risk class (file deletion) with its own
|
||||||
|
> invariants. Authoritative spec: **CONTEXT.md §Prune — file-lifecycle spec**.
|
||||||
|
> Product framing + phase-placement justification + forks:
|
||||||
|
> `docs/product/removal-and-prune.md` §Prune. When a point lands, doc-keeper moves it
|
||||||
|
> to `COMPLETED.md`.
|
||||||
|
>
|
||||||
|
> **Boundary (load-bearing):** *remove creates orphans; prune reclaims them.* No
|
||||||
|
> operation other than prune deletes a file; prune deletes only files no index
|
||||||
|
> references. A bank op that deletes a file is still a bug.
|
||||||
|
>
|
||||||
|
> **Depends on:** B1, B2 (needs the multi-bank book to union the referenced-set
|
||||||
|
> across all banks) and B5 conceptually (sample-remove is a primary orphan-producer,
|
||||||
|
> so remove-then-prune is the coherent pair — mirror of evacuate-then-delete). Does
|
||||||
|
> **not** depend on the B3/B4 UI.
|
||||||
|
|
||||||
|
## R1 — prune-reconcile core (pure)
|
||||||
|
**Goal:** REAPER-free, filesystem-free reconciler — given the files present in the
|
||||||
|
bank folder, the files referenced by the book (unioned across all banks, pool
|
||||||
|
included), and the **owned-file manifest** (fork R-D, written from capture onward by
|
||||||
|
B-cap), compute the orphan set `(owned ∩ present) − referenced`. The mirror of
|
||||||
|
`ViewModeModel::reconcile(liveGuids)`, one level down (files instead of GUIDs).
|
||||||
|
CONTEXT.md §Prune (Module architecture — pure).
|
||||||
|
**Verify:** CTest green. **Prune null test:** a folder whose every file is
|
||||||
|
referenced deletes nothing; prune returns exactly `(owned ∩ present) − referenced`
|
||||||
|
and nothing else. Referenced-set unioned across every bank (a file referenced by any
|
||||||
|
bank — including via a copy — is never an orphan); a present-but-not-owned file (a
|
||||||
|
hand-dropped file) is never an orphan.
|
||||||
|
|
||||||
|
- [ ] Prune-reconcile pure function: `(present, referenced, owned) → orphans`,
|
||||||
|
computing `(owned ∩ present) − referenced`; referenced unioned across the whole
|
||||||
|
book (copies keep a file alive).
|
||||||
|
- [ ] Tests: prune null test (all-referenced → empty); orphan = (owned∩present)−
|
||||||
|
referenced; a copied file referenced by a second bank survives; a present-but-
|
||||||
|
unowned (hand-dropped) file is never reclaimed; empty folder / empty book / empty
|
||||||
|
manifest edge cases.
|
||||||
|
|
||||||
|
## R2 — prune shell + persist wiring (filesystem I/O, thin)
|
||||||
|
**Goal:** Enumerate the current project bank folder (M4 project-relative resolution),
|
||||||
|
supply the referenced-set and the **owned-file manifest** (from B-cap) from the
|
||||||
|
session, feed the pure core, and produce a dry-run manifest. No deletion in this
|
||||||
|
wave — the report path only. CONTEXT.md §Prune (persist / prune shell).
|
||||||
|
**Verify (in DAW):** Dry-run reports the orphan count + reclaimed size (+ file list
|
||||||
|
for a small set) against the resolved current bank folder; resolves paths the same
|
||||||
|
way the index does (survives a Save-As relocation); deletes nothing.
|
||||||
|
**Depends on:** R1, B1, B2.
|
||||||
|
|
||||||
|
- [ ] Prune shell: enumerate the resolved current bank folder; feed the pure core.
|
||||||
|
- [ ] Session supplies the referenced-set (union across the book) **and the
|
||||||
|
owned-file manifest** (written by B-cap); resolve the bank folder via the M4
|
||||||
|
project-relative machinery.
|
||||||
|
- [ ] Dry-run manifest: orphan count + reclaimed size (+ files for a small set);
|
||||||
|
**no deletion in this wave.**
|
||||||
|
|
||||||
|
## R3 — deletion + action (the destructive step, guarded)
|
||||||
|
**Goal:** The confirmed deletion step, the bindable "Prune bank folder" action, and
|
||||||
|
a `bank_panel` prune button: dry-run-first, confirm-with-manifest, then reclaim the
|
||||||
|
orphan set — via OS trash where portably available (fork R-C), else unlink.
|
||||||
|
CONTEXT.md §Prune (guardrails, API).
|
||||||
|
**Verify (in DAW):** "Prune bank folder" (action or panel button) reports first,
|
||||||
|
deletes only on explicit confirm, and reclaims exactly the orphan set — never a
|
||||||
|
referenced file, never a hand-dropped non-bank file; the referenced/owned-set safety
|
||||||
|
holds; deletions route to OS trash where available; non-bank and capture invariants
|
||||||
|
untouched.
|
||||||
|
**Depends on:** R2 (and B-cap's owned-file manifest). All forks settled 2026-07-24.
|
||||||
|
|
||||||
|
- [ ] "Prune bank folder" action (`command_id`/`gaccel`/`hookcommand`),
|
||||||
|
dry-run-first, confirm-to-delete.
|
||||||
|
- [ ] `bank_panel` prune button (fork R-E) that fires the "Prune bank folder"
|
||||||
|
action through the existing command-id contract — the panel affordance alongside
|
||||||
|
the bindable action; split: button hit-test/layout is pure (mirror of
|
||||||
|
`mode_switch`/`bank_grid`), draw + dispatch is bank_panel shell.
|
||||||
|
- [ ] Deletion mechanism (fork R-C, settled trash-preferred): route to OS trash
|
||||||
|
where a portable move-to-trash is verified available, else unlink behind the
|
||||||
|
dry-run/confirm guardrail. **Verify the platform move-to-trash surface before use
|
||||||
|
(per platform).**
|
||||||
|
- [ ] Orphan attribution (fork R-D, settled owned-file manifest): reclaim only
|
||||||
|
`(owned ∩ present) − referenced` — the bank system's own leavings, never a
|
||||||
|
hand-dropped folder file. (Manifest written by B-cap; consumed via R1/R2.)
|
||||||
|
|
||||||
|
## Phase R forks — settled 2026-07-24
|
||||||
|
- **Fork R-C — deletion mechanism.** Settled: **trash-preferred, unlink fallback.**
|
||||||
|
Route to OS trash where a portable move-to-trash is available (recoverable), else
|
||||||
|
unlink behind strong dry-run/confirm. Per-platform trash surface (SWELL / Win
|
||||||
|
`SHFileOperation`·`IFileOperation` / macOS `trashItemAtURL:` / Linux XDG) is a
|
||||||
|
**must-verify before use**. Folded into R3.
|
||||||
|
- **Fork R-D — orphan attribution.** Settled: **owned-file manifest**,
|
||||||
|
`(owned ∩ present) − referenced`; folder-sweep rejected as unsafe. **Seam lands
|
||||||
|
early** — the manifest is written from capture onward (new **B-cap** point in
|
||||||
|
Phase B), not reconstructed at prune time; R1/R2 consume it. Persistence shape
|
||||||
|
(sibling `"reasampler"` key vs. `banks` blob) is a small build-time residual.
|
||||||
|
- **Fork R-E — trigger.** Settled: **manual action + `bank_panel` button**,
|
||||||
|
dry-run-first, confirm-to-delete. No background sweep. The earlier optional
|
||||||
|
delete-time "…and prune now" convenience was **not** selected — out of scope.
|
||||||
|
Folded into R3.
|
||||||
|
|
||||||
|
Both docs of record: `docs/product/removal-and-prune.md` §Fork R-C/R-D/R-E and
|
||||||
|
CONTEXT.md §Prune (Settled forks).
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# Provenance — product notes
|
||||||
|
|
||||||
|
Framing, rationale, and the dual-canvas reconciliation behind the reshaped
|
||||||
|
**Milestone 10 (provenance)**. The tickable spec lives in `PLAN.md` (M10); the
|
||||||
|
authoritative technical detail is `CONTEXT.md` (§Data model, §capture) plus this
|
||||||
|
note for the reconciliation calls. This doc holds the *why* and the open forks so
|
||||||
|
they don't clutter the build docs.
|
||||||
|
|
||||||
|
Status: **SETTLED (2026-07-23).** Reshaped from the old "provenance + null-test
|
||||||
|
verify" M10. Two decisions were fixed by Daniel up front (see *What was cut* below).
|
||||||
|
The four dual-canvas interaction forks are now resolved: **P1=a thin fingerprint,
|
||||||
|
P2=a bank-only re-capture**, which makes **P3 and P4 moot (closed)**. The fork
|
||||||
|
analysis below is retained as the rationale record — each fork is stamped with its
|
||||||
|
resolution inline; nothing here is open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What was cut (fixed by Daniel — do not reopen)
|
||||||
|
|
||||||
|
- **The null-test verification ACTION is cut.** Daniel verifies bit-accuracy
|
||||||
|
himself when he cares (he already null-tested the first capture spike, M3). The
|
||||||
|
tool ships no null-test button.
|
||||||
|
- **The "true pre-FX dry capture" mechanism is dropped.** The old M10 note said the
|
||||||
|
null test would require a true pre-FX dry render (bypass-around-render or the M8
|
||||||
|
realtime pre-FX tap). With the null-test action gone, that mechanism has no
|
||||||
|
consumer and is dropped too. `CaptureRequest.wetDry` stays in the struct as an
|
||||||
|
inert seam (M7 already retained it), but M10 does **not** build a dry path.
|
||||||
|
|
||||||
|
What survives from the old M10: **provenance + "re-capture from source,"** which
|
||||||
|
Daniel confirmed is genuinely useful — with the added constraint that it must be
|
||||||
|
coherent with the dual-canvas (Design View / two-canvas) architecture built in
|
||||||
|
parallel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What provenance is (and what already exists)
|
||||||
|
|
||||||
|
**Provenance records where a sample came from, so a sample can be regenerated from
|
||||||
|
its source.** The concrete case: you capture something into the bank, place it,
|
||||||
|
process it further, and re-capture the processed result — provenance is the thread
|
||||||
|
back from the child sample to the parent it was resampled from, plus enough about
|
||||||
|
the capture to reproduce it.
|
||||||
|
|
||||||
|
**Most of the data model already exists.** `Sample` (M1, landed) already carries:
|
||||||
|
|
||||||
|
```
|
||||||
|
std::optional<Provenance> provenance; // set only when resampled
|
||||||
|
struct Provenance {
|
||||||
|
std::string parentSampleId;
|
||||||
|
std::string fxChainSnapshot;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
and it already JSON-round-trips (M1's lossless-round-trip test covers it). So M10
|
||||||
|
is **not** "add provenance fields" — the seam is cut. M10 is:
|
||||||
|
|
||||||
|
1. **Populate** `provenance` on captures that resample from an existing bank sample.
|
||||||
|
2. **Consume** it via a "re-capture from source" action that regenerates the sample.
|
||||||
|
3. **Reconcile** both with the dual-canvas model (the new work — see below).
|
||||||
|
|
||||||
|
### What `fxChainSnapshot` should mean now (given the M7 rework)
|
||||||
|
|
||||||
|
The old note assumed provenance would snapshot a chain for a *pre-FX dry* render.
|
||||||
|
That's gone. Under the **shipped M7 capture model**, capture is always wet and the
|
||||||
|
control is the **FX scope** (item = item/take FX only; track = item FX + that
|
||||||
|
track's own track FX), with the out-of-scope chain neutralized to unity by
|
||||||
|
`FxBypassGuard` for the render. There is no wet/dry dial.
|
||||||
|
|
||||||
|
So `fxChainSnapshot` should record **the capture recipe, not a dry-render chain**:
|
||||||
|
the scope, the source range (already on `Sample.sourceRange`), the source track
|
||||||
|
GUID(s) (already on `Sample.trackGuids`), the tail setting, and — the genuinely new
|
||||||
|
bit — enough of the **source FX-chain identity at capture time** that "re-capture
|
||||||
|
from source" can tell whether the source still matches what was captured. This is a
|
||||||
|
*fingerprint for reproducibility*, not a mechanism for a different render mode.
|
||||||
|
|
||||||
|
> **Fork P1 — how much chain state does `fxChainSnapshot` carry? — CHOSEN: (a)
|
||||||
|
> thin fingerprint (Daniel, 2026-07-23).** Two shapes:
|
||||||
|
> **(a) thin fingerprint** — a hash/summary of the source scope + FX-chain identity
|
||||||
|
> at capture time, used only to detect drift ("source has changed since capture")
|
||||||
|
> and to re-run the *same* capture request; or **(b) fat snapshot** — a full
|
||||||
|
> serialized FX-chain state string (`TrackFX` chunk) that re-capture could restore
|
||||||
|
> before rendering, so the regenerated sample matches even if the user has since
|
||||||
|
> tweaked the chain. (a) is simpler, non-destructive, and matches "re-capture
|
||||||
|
> reflects the source as it is *now*"; (b) is heavier, mutates the live chain during
|
||||||
|
> re-capture (a new destructive-ish surface), and re-opens some of the pre-FX-dry
|
||||||
|
> complexity we just cut. **Lean: (a) thin fingerprint.** Re-capture-from-source
|
||||||
|
> most naturally means "run the capture again against the source's *current* state"
|
||||||
|
> — that's the useful workflow (I changed the source, give me the updated sample).
|
||||||
|
> The fingerprint's job is to *tell* the user the source drifted, not to freeze it.
|
||||||
|
> **Resolved 2026-07-23: (a). `fxChainSnapshot` is a capture-recipe fingerprint for
|
||||||
|
> drift detection and re-run — not a serialized chain to restore.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The dual-canvas reconciliation (the real new work)
|
||||||
|
|
||||||
|
Daniel's constraint, verbatim: *"with the sound design canvas parallel, I think
|
||||||
|
provenance is genuinely useful, but we should make sure it's compliant with the
|
||||||
|
dual canvas stuff."*
|
||||||
|
|
||||||
|
The dual-canvas ("two-canvas," Phase D2) architecture that landed in parallel:
|
||||||
|
Design View is a **mode projection** over one timeline reaching both **tracks**
|
||||||
|
(D1 parking) and **items** (D2 fixed lanes). New content is **auto-tagged to the
|
||||||
|
active mode** at creation. Membership + lane-ownership are GUID-keyed and persist
|
||||||
|
in the `"reasampler"` `view_state` section — a **separate** pure module
|
||||||
|
(`view_mode_model`) from the bank (`bank_model`). The settled placement rule
|
||||||
|
(CONTEXT §Capture placement — mode-aware): *an explicit placement while in Design
|
||||||
|
mode lands the item in the Design lane.*
|
||||||
|
|
||||||
|
There are exactly **four** genuine interaction points between provenance and this
|
||||||
|
model. Each is a fork for Daniel.
|
||||||
|
|
||||||
|
### Where re-capture lands (the load-bearing one)
|
||||||
|
|
||||||
|
"Re-capture from source" regenerates a sample into the bank. Per the load-bearing
|
||||||
|
capture principle, **regenerating the bank sample never inserts into the timeline**
|
||||||
|
— so at the bank level there is *no* canvas interaction: the regenerated file + index
|
||||||
|
entry land in the bank exactly as any capture does, and the bank is mode-agnostic.
|
||||||
|
|
||||||
|
The interaction only appears **if re-capture also re-places** the regenerated sample
|
||||||
|
onto the timeline (replacing the old placed item). That is a *placement*, and
|
||||||
|
placement is mode-aware under D2.
|
||||||
|
|
||||||
|
> **Fork P2 — does "re-capture from source" re-place, or only refresh the bank
|
||||||
|
> entry? — CHOSEN: (a) bank-only re-capture (Daniel, 2026-07-23).** Two shapes:
|
||||||
|
> **(a) bank-only re-capture** — regenerate the file + update the bank Sample
|
||||||
|
> in place; the user re-places manually if they want the new version on the
|
||||||
|
> timeline. Fully honors the load-bearing principle with zero canvas coupling;
|
||||||
|
> simplest; matches how every other capture behaves (capture ≠ placement).
|
||||||
|
> **(b) re-capture-and-replace** — regenerate *and* swap the placed timeline item
|
||||||
|
> for the new file. Convenient, but it is an auto-placement path, so it must obey
|
||||||
|
> the D2 mode-aware placement rule (lands in the active mode's lane / the original
|
||||||
|
> item's lane) and it touches the timeline (undo block, non-destructive to
|
||||||
|
> everything else). **Lean: (a) bank-only.** It keeps M10 inside the capture
|
||||||
|
> pillar's clean "capture never places" line and defers all the canvas-placement
|
||||||
|
> complexity. (b) can be a later opt-in ("re-capture and replace in place") once (a)
|
||||||
|
> proves the provenance thread. If Daniel wants (b), P3 and P4 below become live.
|
||||||
|
> **Resolved 2026-07-23: (a). Re-capture regenerates the file into the bank and
|
||||||
|
> updates the Sample in place; it never places/replaces on the timeline. P3 and P4
|
||||||
|
> are therefore moot — closed (b) can still be revisited as a later opt-in.**
|
||||||
|
|
||||||
|
### Does provenance need to record canvas/mode membership?
|
||||||
|
|
||||||
|
`Sample` (bank) and `Membership`/`LaneOwnership` (view model) are **separate pure
|
||||||
|
modules today, by design** — the bank is mode-agnostic (a sample is just a file +
|
||||||
|
metadata; it doesn't know it was placed in Design). The question is whether
|
||||||
|
provenance must break that separation to record *which canvas/lane* the source item
|
||||||
|
lived in, so re-capture can put the regenerated sample back there.
|
||||||
|
|
||||||
|
Under Fork P2 = (a) bank-only, the answer is **no** — re-capture doesn't place, so
|
||||||
|
it needs no canvas memory; the bank stays mode-agnostic and the two pure modules
|
||||||
|
stay decoupled. Under P2 = (b) re-place, the answer becomes **yes, partially**.
|
||||||
|
|
||||||
|
> **Fork P3 — (only live if P2 = re-place) does provenance store canvas/lane
|
||||||
|
> membership? — CLOSED/moot under P2=a (2026-07-23).** If re-capture re-places, where
|
||||||
|
> does it land?
|
||||||
|
> **(a) active-mode rule** — re-placement follows the *same* D2 auto-tag/placement
|
||||||
|
> rule as any explicit placement: it lands in whatever mode is active *now*.
|
||||||
|
> Provenance stores **nothing** about canvas; the view model's existing rule
|
||||||
|
> governs. Keeps `bank_model` mode-agnostic.
|
||||||
|
> **(b) origin-lane memory** — provenance records the source item's mode/lane at
|
||||||
|
> capture time (a GUID + mode-id or lane-key) so re-capture lands the regenerated
|
||||||
|
> sample back in the *original* canvas regardless of the active mode. More faithful
|
||||||
|
> to "put it back where it was," but it couples `bank_model` provenance to
|
||||||
|
> `view_mode_model` identifiers — a cross-module reach the architecture currently
|
||||||
|
> avoids. **Lean: (a) active-mode rule**, kept in the view model; provenance stays
|
||||||
|
> pure bank metadata with no view-model ids. Only reach for (b) if "re-capture
|
||||||
|
> restores the exact original lane" is a stated requirement.
|
||||||
|
> **Closed 2026-07-23: moot under P2=a. Re-capture never places, so it stores no
|
||||||
|
> canvas memory; provenance stays pure bank metadata and the two modules stay
|
||||||
|
> decoupled. Revisit only if re-capture-and-replace (P2=b) is later adopted.**
|
||||||
|
|
||||||
|
### Re-capture and auto-tagging
|
||||||
|
|
||||||
|
D2 auto-tags **new** track/item GUIDs (detected by the panel-timer GUID diff)
|
||||||
|
to the active mode. A re-placed item (P2 = b) is a *new* item GUID on the timeline,
|
||||||
|
so it would be auto-tagged to the active mode automatically — which is exactly Fork
|
||||||
|
P3 = (a) behavior, for free, via the existing detection path. No special-casing
|
||||||
|
needed *unless* Daniel wants origin-lane memory (P3 = b), in which case re-capture
|
||||||
|
must tag the new item explicitly and **suppress** the auto-tag for that GUID (or the
|
||||||
|
two fight).
|
||||||
|
|
||||||
|
> **Fork P4 — (only live if P2 = re-place AND P3 = origin-lane) does re-capture
|
||||||
|
> preserve or re-run auto-tagging? — CLOSED/moot under P2=a (2026-07-23).** If
|
||||||
|
> provenance restores the origin lane, the
|
||||||
|
> re-placed item must be tagged to the *origin* mode, not the active mode — so
|
||||||
|
> re-capture has to write the membership itself and exempt that GUID from the
|
||||||
|
> timer's auto-tag (same class as the manual-lane exemption already in
|
||||||
|
> `autoTagNewContent`). **This fork only exists under P2=(b) + P3=(b).** Under the
|
||||||
|
> leaned defaults (P2=a, or P2=b + P3=a) it does not arise: bank-only re-capture
|
||||||
|
> places nothing, and active-mode re-placement rides the existing auto-tag path
|
||||||
|
> unchanged. **Closed 2026-07-23: moot under P2=a — bank-only re-capture places
|
||||||
|
> nothing, so no auto-tag interaction arises.**
|
||||||
|
|
||||||
|
### Summary of the settled path
|
||||||
|
|
||||||
|
Daniel took the leans (**P1=a thin fingerprint, P2=a bank-only re-capture**,
|
||||||
|
2026-07-23), so the reconciliation collapses to almost nothing: provenance is
|
||||||
|
**pure per-sample bank metadata**, `bank_model` and `view_mode_model` **stay
|
||||||
|
decoupled**, and M10 touches **no** canvas code. The dual-canvas compliance is
|
||||||
|
satisfied by *staying on the right side of the load-bearing line* (capture/re-capture
|
||||||
|
never places), not by new coupling. P3 and P4 are moot (closed) — they were only
|
||||||
|
live if re-capture also re-placed onto the timeline.
|
||||||
|
|
||||||
|
The settled shape: **keep provenance in the bank, keep re-capture a bank-only
|
||||||
|
regenerate, and let the existing D2 placement rule handle the timeline if and when
|
||||||
|
the user manually re-places.** It is the smallest thing that delivers the useful
|
||||||
|
workflow and the cleanest against the architecture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Persistence / precision-invariant implications (spec-level)
|
||||||
|
|
||||||
|
- **Provenance is already-persisted metadata.** `Sample.provenance` already
|
||||||
|
serializes in the `BankIndex` JSON (M1) under the existing `bank_index` /
|
||||||
|
(post-Phase-B) `banks` ext-state key. **Populating it adds no new persistence
|
||||||
|
surface** — the round-trip test already exercises the field. The only spec note:
|
||||||
|
if Fork P1 grows `fxChainSnapshot` from a thin string to a fat FX-chunk (P1=b),
|
||||||
|
the field is still one string on `Sample`, so the JSON shape is unchanged, but the
|
||||||
|
blob gets heavier — a size consideration, not a schema one. Under P1=a (thin
|
||||||
|
fingerprint) the field stays small.
|
||||||
|
- **Under the leaned path, provenance touches `view_state` not at all.** No
|
||||||
|
lane-ownership or membership data is added for provenance; the view section is
|
||||||
|
unchanged. (Only P3=b would add view-model ids into provenance — and that would be
|
||||||
|
the argument *against* P3=b.)
|
||||||
|
- **Precision invariants are unaffected.** Re-capture is a capture: it produces a
|
||||||
|
file deterministically (bit-identical repeats hold — a re-capture with an
|
||||||
|
unchanged source and request is byte-identical to the original capture, which is
|
||||||
|
itself a nice provenance property), it is non-destructive to source items/tracks
|
||||||
|
(`FxBypassGuard` snapshot/restore, as M7), it honors exact bounds, and it writes
|
||||||
|
only relative paths. **Do not design the serialization here** — this is spec-level;
|
||||||
|
the implementer owns the JSON encoding of whatever P1 shape Daniel picks.
|
||||||
|
- **Do not reintroduce the dry path.** The precision-invariant list in CONTEXT still
|
||||||
|
names the null test as the "trust anchor." With the action cut, that line is now
|
||||||
|
historical framing, not an M10 deliverable — flag for doc-keeper to reconcile when
|
||||||
|
M10 lands, but M10 does **not** ship a null-test action or a pre-FX dry render.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision list (settled 2026-07-23)
|
||||||
|
|
||||||
|
1. **P1 — `fxChainSnapshot` shape: CHOSEN (a) thin reproducibility fingerprint.**
|
||||||
|
(Rejected: (b) fat serialized FX-chain chunk.)
|
||||||
|
2. **P2 — re-capture scope: CHOSEN (a) bank-only regenerate.** (Rejected for now:
|
||||||
|
(b) re-capture-and-replace-on-timeline; may return as a later opt-in.)
|
||||||
|
3. **P3 — canvas memory: CLOSED/moot under P2=a.** Was only live under P2=b; lean
|
||||||
|
was (a) active-mode rule, provenance stores no view ids.
|
||||||
|
4. **P4 — auto-tag interaction: CLOSED/moot under P2=a.** Was only live under P2=b +
|
||||||
|
P3=b.
|
||||||
|
|
||||||
|
Picks 1a + 2a make P3 and P4 moot and keep M10 a small, decoupled, capture-pillar
|
||||||
|
milestone. The PLAN M10 points are locked to this path.
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
# Removal & prune — product notes
|
||||||
|
|
||||||
|
Framing, rationale, and open forks behind the two missing removal capabilities:
|
||||||
|
**sample-remove** (a sample-level index verb) and **prune** (the file-lifecycle
|
||||||
|
path CONTEXT.md keeps forward-referencing but never scoped). The tickable spec
|
||||||
|
lives in `PLAN.md` (Phase B point B5 for remove; **Phase R** for prune) and the
|
||||||
|
authoritative technical detail in `CONTEXT.md` (§Sample removal, §Prune — file
|
||||||
|
lifecycle). This doc holds the *why* — the workflow, the guardrails, the
|
||||||
|
index-vs-file boundary, and the forks that need a Daniel decision.
|
||||||
|
|
||||||
|
Status: framed by product-designer (2026-07-23); **all five forks settled by Daniel
|
||||||
|
(2026-07-24)** — R-A this-bank-primary, R-B batched REAPER undo points
|
||||||
|
(Phase-B-wide), R-C trash-preferred-with-unlink-fallback, R-D owned-file manifest
|
||||||
|
(seam lands early in Phase B / capture), R-E manual action + panel button. The
|
||||||
|
decisions are folded into the fork sections below and into the B5 / Phase R spec
|
||||||
|
prose in CONTEXT.md and the tickable points in PLAN.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The one boundary that governs everything: index vs. file
|
||||||
|
|
||||||
|
ReaSampler already draws a hard line, stated repeatedly in CONTEXT.md: **a bank
|
||||||
|
operation touches the *index*, never the *file*.** Move, copy, evacuate, and
|
||||||
|
delete-bank are all index-only; files persist on disk "until prune." Every
|
||||||
|
removal capability below sits on exactly one side of that line, and keeping the
|
||||||
|
two verbs on opposite sides is the whole design.
|
||||||
|
|
||||||
|
- **Sample-remove drops an index entry.** It is the sample-level sibling of the
|
||||||
|
bank verbs — move/copy/evacuate all keep the sample *somewhere*; remove is
|
||||||
|
"drop this entry outright." It is **index-only, non-destructive to the file**,
|
||||||
|
and — exactly like a plain delete-bank of a non-empty bank — it can *create*
|
||||||
|
an orphan when it removes the last index reference to a file. It sits on the
|
||||||
|
**same side of the line as every existing Phase B op.**
|
||||||
|
|
||||||
|
- **Prune deletes files off disk.** It is the *only* operation in the entire
|
||||||
|
system that removes bytes. It reconciles the physical bank folder against the
|
||||||
|
union of all bank indices and reclaims files referenced by no bank. It sits on
|
||||||
|
the **file side of the line, alone.**
|
||||||
|
|
||||||
|
So the crisp statement, worth putting in the spec verbatim:
|
||||||
|
|
||||||
|
> **Remove creates orphans; prune reclaims them.** Sample-remove and delete-bank
|
||||||
|
> drop index entries and may leave a file referenced by nothing. Prune is the
|
||||||
|
> single path that turns such an orphan back into free disk space. No other
|
||||||
|
> operation deletes a file; prune deletes *only* files no index references.
|
||||||
|
|
||||||
|
This is why they are two different scope objects (Phase B vs. Phase R), even
|
||||||
|
though a naive reading ("both are 'delete' verbs") would lump them together.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sample-remove — the missing sample-level verb
|
||||||
|
|
||||||
|
### What the user is doing
|
||||||
|
|
||||||
|
The user has a sample they no longer want in a bank (or in the pool): a bad take,
|
||||||
|
a duplicate they don't want collapsed, a sample they filed into "Drums" by
|
||||||
|
mistake. Today they can *relocate* it (move/copy/evacuate) but they cannot drop
|
||||||
|
it. Remove is the "get this out of here" verb. Two intents hide inside it, and
|
||||||
|
the distinction is a fork (R-A below):
|
||||||
|
|
||||||
|
1. **Remove from *this* bank** — drop the entry from the bank the user is looking
|
||||||
|
at, leaving any copies in other banks untouched. (If the sample was copied
|
||||||
|
into "Drums" and also lives in the pool, remove-from-Drums leaves the pool copy
|
||||||
|
alone.)
|
||||||
|
2. **Remove from *everywhere*** — drop every index entry for this sample across
|
||||||
|
all banks in one act ("purge this sample from the library").
|
||||||
|
|
||||||
|
### Reconciling with existing invariants
|
||||||
|
|
||||||
|
- **Collapse-by-hash:** unaffected. Remove operates on a specific `Sample` entry
|
||||||
|
in a specific `BankIndex` (the `remove` primitive `bank_model` already has, per
|
||||||
|
CLAUDE.md — B5 exposes it, it does not add it). Because dedup is per-bank and
|
||||||
|
cross-bank dedup is deliberately *not* enforced, "remove from this bank" and
|
||||||
|
"the same hash still lives in another bank" coexist cleanly — that is the same
|
||||||
|
coexistence copy already relies on.
|
||||||
|
- **Files-are-never-deleted-by-a-bank-op:** upheld. Remove is index-only, exactly
|
||||||
|
like move/copy/evacuate/delete-bank. When remove drops the *last* reference to a
|
||||||
|
file, it produces the **same orphaned-until-prune state** a non-empty
|
||||||
|
delete-bank already produces — a designed state, not a new hazard class. The
|
||||||
|
file is reclaimed later by prune, never by remove.
|
||||||
|
- **Pool privileges:** the pool cannot be *deleted, renamed, or evacuated*, but
|
||||||
|
individual samples **can** be removed from the pool — otherwise the pool would
|
||||||
|
become a roach-motel (samples check in, never leave except by moving to a named
|
||||||
|
bank). Remove-from-pool is allowed; it is the pool's own "drop this sample"
|
||||||
|
verb. (The pool-as-container privilege is untouched; only its *contents* are
|
||||||
|
removable.)
|
||||||
|
- **Non-destructive:** remove mutates only index + ext-state, touches no file and
|
||||||
|
no timeline item — the Phase B non-destructive guarantee extends to it verbatim.
|
||||||
|
|
||||||
|
### Guardrails
|
||||||
|
|
||||||
|
Remove is *less* dangerous than it first looks, because it never deletes a file —
|
||||||
|
the bytes survive on disk until an explicit prune. So the recovery story is: an
|
||||||
|
accidental remove loses the *index entry*, not the audio. But there are two
|
||||||
|
sharpnesses to guard:
|
||||||
|
|
||||||
|
- **Last-reference remove is the orphan-maker.** Removing a sample that exists in
|
||||||
|
only one bank orphans its file (until prune). This is the same footgun as
|
||||||
|
non-empty delete-bank, and it deserves the same treatment: **confirm when the
|
||||||
|
remove drops the last index reference** ("Remove 'kick_03'? It is in no other
|
||||||
|
bank — its file will remain on disk until pruned."). A remove of a sample that
|
||||||
|
still lives in another bank is cheap and reversible-in-spirit (re-copy it back)
|
||||||
|
and need not confirm. This makes the confirmation *earned* by actual risk rather
|
||||||
|
than fired on every remove.
|
||||||
|
- **Undo.** REAPER's own undo stack does not natively cover ext-state index
|
||||||
|
mutations, so this is a Phase-B-wide decision (fork R-B, **settled**): bank/index
|
||||||
|
mutations integrate into REAPER's undo system as **batched undo points**
|
||||||
|
(`Undo_BeginBlock` / `Undo_EndBlock`), so one bank operation is one Ctrl-Z. Remove
|
||||||
|
is where the gap first bites — it is the first verb whose *only* effect is
|
||||||
|
destruction of an index entry with no relocation — but the fix is shared by every
|
||||||
|
Phase B verb.
|
||||||
|
|
||||||
|
### Where it lives
|
||||||
|
|
||||||
|
Remove is a `bank_book`/`BankIndex` verb (pure), a bindable `actions` entry, and a
|
||||||
|
`bank_panel` affordance on the current selection — the exact three-layer shape
|
||||||
|
every Phase B verb already takes. That is why it belongs **in Phase B as B5**, not
|
||||||
|
in a phase of its own: same modules, same pattern, same side of the index/file
|
||||||
|
line. It is the verb Phase B forgot, not a new pillar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prune — the file-lifecycle path CONTEXT.md kept promising
|
||||||
|
|
||||||
|
### What the user is doing
|
||||||
|
|
||||||
|
The user has been working for a while: capturing, re-capturing (M10), deleting
|
||||||
|
banks, removing samples. Each of those left files on disk that no index references
|
||||||
|
any more — the "orphaned-until-prune" state the multi-bank spec designs in on
|
||||||
|
purpose. Over a long project the bank folder accumulates dead `.wav` files that
|
||||||
|
cost disk and clutter. **Prune is the reclaim pass**: "sweep the bank folder,
|
||||||
|
delete the files nothing references, tell me what you reclaimed."
|
||||||
|
|
||||||
|
This is the path CONTEXT.md forward-references in at least four places ("files
|
||||||
|
persist on disk until prune," "the capture/prune path reclaims it") but never
|
||||||
|
scopes. It is a real, promised capability with **no phase, no module, no point**
|
||||||
|
— a dangling reference the plan has to make good on.
|
||||||
|
|
||||||
|
### The load-bearing precedent: prune-on-reconcile already exists for modes
|
||||||
|
|
||||||
|
ReaSampler already shipped this exact shape once. Design View's `view_mode_model`
|
||||||
|
has **`ViewModeModel::reconcile(liveGuids)`** — a pure function fed the live set
|
||||||
|
(the tracks that still exist), returning the residual membership entries to drop
|
||||||
|
(CONTEXT.md §Design View: "prunes orphaned snapshots on every toggle/load;
|
||||||
|
tolerates unknown/stale GUIDs (prune on reconcile)"). Prune is the **file-pool
|
||||||
|
mirror of that pure pattern**:
|
||||||
|
|
||||||
|
> `reconcile(liveGuids)` reconciles *membership entries* against *live tracks*.
|
||||||
|
> Prune reconciles *files on disk* against *referenced files* (the union of every
|
||||||
|
> bank's index). Same shape — feed the pure core the live set, get back the
|
||||||
|
> residuals — one level down (files instead of GUIDs).
|
||||||
|
|
||||||
|
This is the "mirror the existing pure pattern" move the whole codebase is built
|
||||||
|
on (`bank_model`, `view_mode_model`, `bank_book` are all the same pure-registry
|
||||||
|
shape). The **pure part of prune** — "given the set of files on disk and the set
|
||||||
|
of files referenced by the book, compute the orphan set" — is a REAPER-free,
|
||||||
|
unit-testable function that belongs with the pure cores. Only the two ends are
|
||||||
|
shell work: *enumerating* the bank folder (filesystem I/O) and *deleting* the
|
||||||
|
orphans (filesystem I/O). Keep the decision (which files are orphans) pure and
|
||||||
|
tested; keep the I/O thin. This is the same pure/shell split as everything else.
|
||||||
|
|
||||||
|
### Reconciling with existing invariants
|
||||||
|
|
||||||
|
- **This is the one place the "files are never deleted" rule is *intentionally*
|
||||||
|
broken — and it must be the *only* one.** Every other invariant says "no bank op
|
||||||
|
deletes a file." Prune is explicitly not a bank op; it is the file-lifecycle op,
|
||||||
|
and its entire job is deletion. The spec must state this asymmetry loudly so a
|
||||||
|
reviewer never reads prune as violating the bank-op rule: **prune is the sole
|
||||||
|
file-deletion authority; a bank op that deletes a file is still a bug.**
|
||||||
|
- **Referenced-set is the union across *all* banks, pool included.** A file is an
|
||||||
|
orphan iff **no** bank in the book references it. Because copy means one file can
|
||||||
|
be referenced by several banks, prune must union references across the whole
|
||||||
|
book before deciding — deleting a file still referenced by "Drums" because it
|
||||||
|
left the pool would be catastrophic. The referenced-set computation is the
|
||||||
|
safety-critical core and the thing to test hardest (the null test of prune:
|
||||||
|
*prune never deletes a file that any index references*).
|
||||||
|
- **Relative-paths-only / project-relative resolution:** prune enumerates and
|
||||||
|
deletes within the project bank folder using the same M4 project-relative path
|
||||||
|
resolution the index uses. It must resolve the *same* way the index does, or it
|
||||||
|
could mis-identify orphans across a Save-As relocation. Prune runs against the
|
||||||
|
*resolved current* bank folder, never a stale absolute path.
|
||||||
|
- **Collapse-by-hash:** irrelevant to prune's decision (prune works on files and
|
||||||
|
references, not hashes) but worth noting: two index entries that collapsed onto
|
||||||
|
one file mean one file, multiple references — prune's union handles this for
|
||||||
|
free (the file is referenced, so it survives).
|
||||||
|
- **Determinism / bit-identical / null-test (capture):** untouched — prune sits
|
||||||
|
below the capture path entirely, same as multi-bank.
|
||||||
|
|
||||||
|
### Guardrails — this is the genuinely destructive act
|
||||||
|
|
||||||
|
Prune deletes real bytes irreversibly (a deleted `.wav` is gone unless it went to
|
||||||
|
an OS trash — see fork R-C). It earns the strongest guardrails in the product:
|
||||||
|
|
||||||
|
- **Dry-run first, always.** Prune should *report before it deletes*: "12 files
|
||||||
|
(34 MB) are referenced by no bank. Delete them?" A prune that silently sweeps is
|
||||||
|
unacceptable for an irreversible file-delete. The dry-run (compute-and-report,
|
||||||
|
the pure core with no deletion) is arguably the *primary* surface, and the
|
||||||
|
actual deletion is the confirmed second step. This mirrors how every safe
|
||||||
|
garbage-collector / disk-cleaner works (npm prune --dry-run, git gc reporting,
|
||||||
|
Lightroom's "delete rejected photos" confirmation).
|
||||||
|
- **Confirm with a manifest.** The confirmation names the count and the reclaimed
|
||||||
|
size and — for a small set — the files. The user approves a *specific* deletion,
|
||||||
|
not an abstract "clean up."
|
||||||
|
- **Never touch a referenced file, and never touch a non-bank file.** Prune's
|
||||||
|
scope is *files in the bank folder that the book once owned and no longer
|
||||||
|
references*. A file that was never a bank file (a user dropped something into the
|
||||||
|
folder by hand) is out of scope — prune should only reclaim files it can
|
||||||
|
attribute to the bank system's own leavings, not act as a general folder cleaner.
|
||||||
|
(This is a fork — R-D — because "how does prune know a file was ever ours"
|
||||||
|
depends on whether we track a manifest of owned files.)
|
||||||
|
- **Recoverability via the OS trash (fork R-C, settled: trash-preferred).** Prune
|
||||||
|
routes deletions to the platform recycle bin / trash where a portable move-to-trash
|
||||||
|
is available, so an accidental prune is recoverable outside the app; it falls back
|
||||||
|
to unlink (behind the dry-run + confirm guardrail) only where the platform affords
|
||||||
|
no portable trash. Whether SWELL / the platform layer gives us that portable "move
|
||||||
|
to trash" is a to-verify per platform — but the *default* is the safest deletion
|
||||||
|
the platform affords, and "delete where possible" means recoverable-trash-preferred,
|
||||||
|
never plain unlink-by-default.
|
||||||
|
|
||||||
|
### Where it lives — and why it is its own phase, not a Phase B point
|
||||||
|
|
||||||
|
Prune is **not** a Phase B point. Three reasons it earns its own lettered phase
|
||||||
|
(proposed **Phase R — Reclaim / file lifecycle**):
|
||||||
|
|
||||||
|
1. **It is a different pillar.** Phase B is the *bank container* pillar
|
||||||
|
(index-only, non-destructive, above the file). Prune is the *file lifecycle*
|
||||||
|
pillar (the one path that deletes files). CONTEXT.md already names it as a
|
||||||
|
separate concern every time it says "the capture/**prune** path" — file
|
||||||
|
lifecycle is spoken of as its own thing, owned by neither the capture nor the
|
||||||
|
bank layer. Giving it its own phase matches how the spec already talks about it.
|
||||||
|
2. **It serves more than Phase B.** Orphans are produced by delete-bank *and*
|
||||||
|
sample-remove (B5) *and*, arguably, by M10 re-capture superseding an old file,
|
||||||
|
*and* by Design View's deleted-track residual files if any exist. Prune is the
|
||||||
|
downstream reclaim for *all* file-orphaning paths, not a Phase-B-internal
|
||||||
|
cleanup. A capability multiple pillars forward-reference should not be nested
|
||||||
|
inside one of them.
|
||||||
|
3. **It carries a new risk class and new invariants.** Every phase so far has been
|
||||||
|
non-destructive-to-files by construction. Prune is the first phase that deletes
|
||||||
|
files, so it needs its own invariant section (the prune null test, the
|
||||||
|
referenced-set union, the dry-run guarantee, trash-routing). Burying that under
|
||||||
|
a Phase B checkbox would hide the one genuinely destructive capability in the
|
||||||
|
product inside a phase whose headline invariant is "non-destructive." The
|
||||||
|
dissonance alone argues for separation.
|
||||||
|
|
||||||
|
Lettered, per the established convention (`M` = capture pillar, `D` = Design View,
|
||||||
|
`B` = Banks): **`R` = Reclaim.** It reads correctly — Phase R is "the file
|
||||||
|
lifecycle pillar," not "a bank sub-step."
|
||||||
|
|
||||||
|
**Sequencing:** Phase R depends on B1/B2 (it needs the multi-bank book to compute
|
||||||
|
the referenced-set union across all banks) and on B5 conceptually (sample-remove
|
||||||
|
is a primary orphan-producer, so prune is most useful once remove exists), but it
|
||||||
|
does not depend on the B3/B4 *UI*. It can land any time after the book exists;
|
||||||
|
practically it should follow B5 so the two removal verbs ship as a coherent pair
|
||||||
|
(remove-then-prune is the workflow, mirroring evacuate-then-delete).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settled forks (Daniel, 2026-07-24)
|
||||||
|
|
||||||
|
### Sample-remove
|
||||||
|
|
||||||
|
**Fork R-A — remove scope. SETTLED: THIS BANK (this-bank-primary).**
|
||||||
|
Remove drops the entry from the bank in view only, leaving copies in other banks
|
||||||
|
untouched. This is the core (and shipped) verb.
|
||||||
|
- *from-this-bank* is the composable primitive (it is literally the
|
||||||
|
`BankIndex::remove` the model already has); "from everywhere" is then "remove
|
||||||
|
from each bank that holds it," which the user can also achieve by removing per
|
||||||
|
bank. It matches the partition mental model (fork 3): a sample is in one bank, so
|
||||||
|
remove-from-this-bank usually *is* remove-from-everywhere.
|
||||||
|
- **Decision:** ship **from-this-bank** as B5's core verb and the only surfaced
|
||||||
|
affordance. Keep the `scope: this-bank | all-banks` seam in the action signature
|
||||||
|
as designed, but **this-bank is the settled default and the only shipped verb**;
|
||||||
|
all-banks stays a *latent parameter*, not a surfaced convenience — it can be
|
||||||
|
promoted later behind that seam without a rewrite if the copy workflow proves to
|
||||||
|
scatter samples in practice. (Settled 2026-07-24, confirming the product-designer
|
||||||
|
lean; from-everywhere is explicitly *not* elevated to a co-equal verb now.)
|
||||||
|
|
||||||
|
**Fork R-B — undo model for index mutations (Phase-B-wide, surfaced by remove).
|
||||||
|
SETTLED: BATCH UNDO POINTS (option (iii) — REAPER-integrated, batched).**
|
||||||
|
REAPER's undo stack does not natively cover `"reasampler"` ext-state index
|
||||||
|
mutations, so move/copy/evacuate/delete-bank/remove needed an undo story. Daniel
|
||||||
|
chose to integrate bank/index mutations into **REAPER's own undo system as batched
|
||||||
|
undo points** — the `Undo_BeginBlock` / `Undo_EndBlock` direction — batching the
|
||||||
|
related index mutations of one bank operation into a single undo point, so a bank
|
||||||
|
operation is one Ctrl-Z. The considered alternatives:
|
||||||
|
- *(i)* Accept no undo (rely on confirmations + files surviving) — **rejected**, too
|
||||||
|
weak once remove destroys an index entry with no relocation.
|
||||||
|
- *(ii)* A ReaSampler-internal single-snapshot "undo last bank change" — **rejected**
|
||||||
|
in favour of the more integrated (iii); the earlier product-designer lean toward
|
||||||
|
(ii) was overridden.
|
||||||
|
- *(iii)* **CHOSEN** — hook REAPER's undo system properly, batching related index
|
||||||
|
mutations into single undo points.
|
||||||
|
- **Scope — Phase-B-wide.** This is decided for **all of Phase B at once**, and it
|
||||||
|
**retro-touches B1–B4**, not just B5: every index verb (create/rename/reorder/
|
||||||
|
delete-bank, move, copy, evacuate, remove) wraps its mutation in an undo block.
|
||||||
|
Surfaced with B1's open questions, not only at B5.
|
||||||
|
- **Must-verify-before-build (carry-forward).** The whole approach depends on
|
||||||
|
`"reasampler"` ext-state mutations participating correctly in
|
||||||
|
`Undo_BeginBlock`/`Undo_EndBlock` undo blocks. **Confirm against
|
||||||
|
`vendor/reaper-sdk` that ext-state changes are captured/restored by REAPER undo
|
||||||
|
blocks before building** — if they are not, the batched-undo-point approach does
|
||||||
|
not hold and the decision must be revisited. Flagged as a hard prerequisite.
|
||||||
|
(Settled 2026-07-24.)
|
||||||
|
|
||||||
|
### Prune
|
||||||
|
|
||||||
|
**Fork R-C — deletion mechanism: unlink vs. OS trash. SETTLED: TRASH-PREFERRED,
|
||||||
|
UNLINK FALLBACK.** Prune routes deletions to the platform recycle bin / trash
|
||||||
|
(recoverable outside the app) **wherever the platform affords a portable
|
||||||
|
move-to-trash**, and falls back to unlink — behind the dry-run + confirm guardrail —
|
||||||
|
only where it does not. Trash is the settled default; "delete where possible" reads
|
||||||
|
as *recoverable-trash-preferred*, never plain unlink-by-default.
|
||||||
|
- **To-verify (carried, per platform):** whether a portable move-to-trash exists via
|
||||||
|
SWELL, or must be hand-rolled per platform — Win `SHFileOperation`/`IFileOperation`,
|
||||||
|
macOS `NSFileManager trashItemAtURL:`, Linux XDG trash spec. The move-to-trash
|
||||||
|
surface is an explicit to-verify before use, not an assumed capability. (Settled
|
||||||
|
2026-07-24.)
|
||||||
|
|
||||||
|
**Fork R-D — orphan attribution: manifest-tracked vs. index-diff vs.
|
||||||
|
folder-sweep. SETTLED: OWNED-FILE MANIFEST — and the seam lands EARLY (Phase B /
|
||||||
|
capture).** The book tracks the set of files it has created; prune reclaims
|
||||||
|
`(owned ∩ on-disk) − referenced`. The considered alternatives:
|
||||||
|
- *(i) folder-sweep* — reclaim every unreferenced file in the folder. **Rejected** —
|
||||||
|
it would delete a user's hand-placed file, violating "only reclaim our own
|
||||||
|
leavings."
|
||||||
|
- *(ii) index-diff only* — record a file's identity when its *last* index reference
|
||||||
|
drops and prune only that set. Safe but partial (misses files orphaned outside a
|
||||||
|
tracked drop path). Not chosen.
|
||||||
|
- *(iii) owned-file manifest* — **CHOSEN.** Safest and most general: distinguishes
|
||||||
|
"our orphan" from "user's file" and from "already-gone."
|
||||||
|
- **Seam lands early (accepted design-the-seam-now call).** The manifest is cheap to
|
||||||
|
maintain from capture onward but a **backfill cliff** to reconstruct later — you
|
||||||
|
cannot tell, after the fact, which folder files were ever ours. Daniel accepted the
|
||||||
|
recommendation to **start the owned-file manifest at capture time NOW, in Phase B,
|
||||||
|
even though prune (which consumes it) ships in Phase R.** So: **capture writes each
|
||||||
|
file it creates into an owned-file manifest persisted in the `"reasampler"`
|
||||||
|
ext-state**, and Phase R's R1/R2 *consume* that manifest. The exact persistence
|
||||||
|
shape — a sibling ext-state key vs. folded into the `banks` blob — is a small
|
||||||
|
residual to settle at build; the **manifest-now decision is firm**. (Settled
|
||||||
|
2026-07-24; the up-front point is added to Phase B / the capture path in PLAN.md.)
|
||||||
|
|
||||||
|
**Fork R-E — prune trigger: manual-only vs. offer-on-orphaning vs. periodic.
|
||||||
|
SETTLED: MANUAL ACTION + PANEL BUTTON.** Prune runs via a bindable manual action
|
||||||
|
(dry-run-first, confirm-to-delete) **and** a button in the `bank_panel` that fires
|
||||||
|
that same action. No background sweep. The earlier optional "…and prune now at the
|
||||||
|
delete-bank confirmation" convenience was **not** selected — it is dropped from the
|
||||||
|
settled spec (explicitly out of scope). A periodic/background sweep remains rejected
|
||||||
|
(silent irreversible file-deletion violates the guardrails). So R3 gains a
|
||||||
|
`bank_panel` button affordance alongside the action registration. (Settled
|
||||||
|
2026-07-24.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of the boundary (for the spec)
|
||||||
|
|
||||||
|
| | Sample-remove (B5) | Delete-bank (B1/B3, shipped-spec) | Prune (Phase R) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Object | one `Sample` entry | one bank + its member entries | files on disk |
|
||||||
|
| Side of the line | index | index | **file** |
|
||||||
|
| Deletes bytes? | no | no | **yes (only op that does)** |
|
||||||
|
| Produces orphans? | yes (last-ref) | yes (non-empty) | — (it *reclaims* them) |
|
||||||
|
| Reversible? | Ctrl-Z (batched undo, R-B) / re-capture | Ctrl-Z (batched undo, R-B) / re-create | **no in-app** (recoverable via OS trash, R-C) |
|
||||||
|
| Guardrail | confirm on last-ref | confirm on non-empty | dry-run + manifest confirm |
|
||||||
+116
-2
@@ -26,9 +26,11 @@
|
|||||||
|
|
||||||
#include "bank_book.h" // BankBook, nextBankId, TransferResult, kPoolBankId (B1)
|
#include "bank_book.h" // BankBook, nextBankId, TransferResult, kPoolBankId (B1)
|
||||||
#include "bank_panel.h" // selection seam + full-height toggles (B3/B4)
|
#include "bank_panel.h" // selection seam + full-height toggles (B3/B4)
|
||||||
|
#include "item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B)
|
||||||
|
#include "lane_keys.h" // isOnManualLane — the single managed/manual predicate
|
||||||
#include "persist.h" // ReaSamplerSession (owns book() + view() model)
|
#include "persist.h" // ReaSamplerSession (owns book() + view() model)
|
||||||
#include "track_guid.h" // shared MediaTrack* -> canonical GUID key
|
#include "track_guid.h" // shared MediaTrack* -> canonical GUID key
|
||||||
#include "view.h" // applyMode (D2 shell)
|
#include "view.h" // applyMode + mintManagedLanes (D2 shell)
|
||||||
#include "view_mode_model.h"
|
#include "view_mode_model.h"
|
||||||
|
|
||||||
#include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs)
|
#include "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs)
|
||||||
@@ -36,6 +38,10 @@
|
|||||||
#define REAPERAPI_MINIMAL
|
#define REAPERAPI_MINIMAL
|
||||||
#define REAPERAPI_WANT_CountSelectedTracks
|
#define REAPERAPI_WANT_CountSelectedTracks
|
||||||
#define REAPERAPI_WANT_GetSelectedTrack
|
#define REAPERAPI_WANT_GetSelectedTrack
|
||||||
|
#define REAPERAPI_WANT_CountSelectedMediaItems
|
||||||
|
#define REAPERAPI_WANT_GetSelectedMediaItem
|
||||||
|
#define REAPERAPI_WANT_GetMediaItemTrack
|
||||||
|
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||||
#define REAPERAPI_WANT_EnumProjects
|
#define REAPERAPI_WANT_EnumProjects
|
||||||
#define REAPERAPI_WANT_Main_SaveProject
|
#define REAPERAPI_WANT_Main_SaveProject
|
||||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||||
@@ -43,6 +49,8 @@
|
|||||||
#define REAPERAPI_WANT_ShowMessageBox
|
#define REAPERAPI_WANT_ShowMessageBox
|
||||||
#define REAPERAPI_WANT_genGuid
|
#define REAPERAPI_WANT_genGuid
|
||||||
#define REAPERAPI_WANT_guidToString
|
#define REAPERAPI_WANT_guidToString
|
||||||
|
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||||
|
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||||
#include "reaper_plugin_functions.h"
|
#include "reaper_plugin_functions.h"
|
||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
@@ -59,6 +67,12 @@ constexpr const char* kIdTagDesign = "CEREBELLUM_REASAMPLER_VIEW_TAG_DESIGN";
|
|||||||
constexpr const char* kIdTagArrange = "CEREBELLUM_REASAMPLER_VIEW_TAG_ARRANGE";
|
constexpr const char* kIdTagArrange = "CEREBELLUM_REASAMPLER_VIEW_TAG_ARRANGE";
|
||||||
constexpr const char* kIdUntag = "CEREBELLUM_REASAMPLER_VIEW_UNTAG";
|
constexpr const char* kIdUntag = "CEREBELLUM_REASAMPLER_VIEW_UNTAG";
|
||||||
constexpr const char* kIdShowBoth = "CEREBELLUM_REASAMPLER_VIEW_SHOW_BOTH";
|
constexpr const char* kIdShowBoth = "CEREBELLUM_REASAMPLER_VIEW_SHOW_BOTH";
|
||||||
|
// D2 Wave 3-B item-level mode moves — the item analog of the track tag family. Same
|
||||||
|
// FOREVER-STABLE contract: minted into a persistent command id, user keybindings key off
|
||||||
|
// each — NEVER change these strings after ship.
|
||||||
|
constexpr const char* kIdMoveItemsDesign = "CEREBELLUM_REASAMPLER_VIEW_MOVE_ITEMS_DESIGN";
|
||||||
|
constexpr const char* kIdMoveItemsArrange = "CEREBELLUM_REASAMPLER_VIEW_MOVE_ITEMS_ARRANGE";
|
||||||
|
constexpr const char* kIdUntagItems = "CEREBELLUM_REASAMPLER_VIEW_UNTAG_ITEMS";
|
||||||
|
|
||||||
// The live session the actions mutate. Set once by designViewRegisterActions and
|
// The live session the actions mutate. Set once by designViewRegisterActions and
|
||||||
// read by the hookcommand handler. Not owned here (main.cpp owns g_session).
|
// read by the hookcommand handler. Not owned here (main.cpp owns g_session).
|
||||||
@@ -72,6 +86,9 @@ int g_cmdTagDesign = 0;
|
|||||||
int g_cmdTagArrange = 0;
|
int g_cmdTagArrange = 0;
|
||||||
int g_cmdUntag = 0;
|
int g_cmdUntag = 0;
|
||||||
int g_cmdShowBoth = 0;
|
int g_cmdShowBoth = 0;
|
||||||
|
int g_cmdMoveItemsDesign = 0;
|
||||||
|
int g_cmdMoveItemsArrange = 0;
|
||||||
|
int g_cmdUntagItems = 0;
|
||||||
|
|
||||||
// gaccel storage must outlive registration — REAPER holds each pointer until we
|
// gaccel storage must outlive registration — REAPER holds each pointer until we
|
||||||
// mirror-unregister it. One per action.
|
// mirror-unregister it. One per action.
|
||||||
@@ -82,6 +99,9 @@ gaccel_register_t g_accelTagDesign{};
|
|||||||
gaccel_register_t g_accelTagArrange{};
|
gaccel_register_t g_accelTagArrange{};
|
||||||
gaccel_register_t g_accelUntag{};
|
gaccel_register_t g_accelUntag{};
|
||||||
gaccel_register_t g_accelShowBoth{};
|
gaccel_register_t g_accelShowBoth{};
|
||||||
|
gaccel_register_t g_accelMoveItemsDesign{};
|
||||||
|
gaccel_register_t g_accelMoveItemsArrange{};
|
||||||
|
gaccel_register_t g_accelUntagItems{};
|
||||||
|
|
||||||
// Mints a command id from a stable string and registers its gaccel (Actions-list
|
// Mints a command id from a stable string and registers its gaccel (Actions-list
|
||||||
// entry with `desc`). Returns the command id (0 on failure). The gaccel storage is
|
// entry with `desc`). Returns the command id (0 on failure). The gaccel storage is
|
||||||
@@ -120,6 +140,37 @@ void reapplyActiveMode() {
|
|||||||
applyMode(g_session->view(), g_session->view().activeModeId(), nullptr);
|
applyMode(g_session->view(), g_session->view().activeModeId(), nullptr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track fixed-lane mode value (I_FREEMODE=2). Mirrors the shell's constant; used only to
|
||||||
|
// decide whether an item's lane name is meaningful for the manual-lane read.
|
||||||
|
constexpr int kFreeModeFixedLanes = 2;
|
||||||
|
|
||||||
|
// Collects the current media-item selection as the pure decision's input: each selected
|
||||||
|
// item's GUID plus whether it sits on a MANUAL lane (⇒ EXEMPT — never retagged/re-laned).
|
||||||
|
// The manual-lane read follows the shared pure predicate exactly as the shell's readers
|
||||||
|
// do: only on a fixed-lane track (I_FREEMODE==2) is the item's lane name read; on a normal
|
||||||
|
// track isOnManualLane returns false for the empty name, so the P_LANENAME read is skipped.
|
||||||
|
// Items whose GUID cannot be read are dropped (an empty GUID must never be retagged).
|
||||||
|
std::vector<RetagItem> selectedRetagItems() {
|
||||||
|
std::vector<RetagItem> items;
|
||||||
|
const int n = CountSelectedMediaItems(nullptr); // nullptr = active project
|
||||||
|
items.reserve(static_cast<std::size_t>(n < 0 ? 0 : n));
|
||||||
|
for (int i = 0; i < n; ++i) {
|
||||||
|
MediaItem* it = GetSelectedMediaItem(nullptr, i);
|
||||||
|
if (!it) continue;
|
||||||
|
std::string g = itemGuid(it);
|
||||||
|
if (g.empty()) continue;
|
||||||
|
|
||||||
|
MediaTrack* tr = GetMediaItemTrack(it);
|
||||||
|
const bool fixedLane =
|
||||||
|
tr && static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
|
||||||
|
// Only read the lane name on a fixed-lane track; the pure predicate handles the
|
||||||
|
// normal-track case (returns false) so we pass an empty name and skip the read.
|
||||||
|
const std::string laneNm = fixedLane ? itemLaneName(tr, it) : std::string{};
|
||||||
|
items.push_back(RetagItem{std::move(g), isOnManualLane(fixedLane, laneNm)});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
// Persists both the bank and the Design-View model to the active project's ext
|
// Persists both the bank and the Design-View model to the active project's ext
|
||||||
// state. Called after every state-changing Design View action so the view model
|
// state. Called after every state-changing Design View action so the view model
|
||||||
// is not lost across save/close/reopen. Marking the project dirty is correct —
|
// is not lost across save/close/reopen. Marking the project dirty is correct —
|
||||||
@@ -218,6 +269,48 @@ void doShowBoth() {
|
|||||||
persistViewState();
|
persistViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Item-level mode moves (D2 Wave 3-B) -----------------------------------
|
||||||
|
//
|
||||||
|
// Retag the current ITEM selection to `targetMode` (empty ⇒ untag → Arrange default),
|
||||||
|
// then re-drive the minting + apply path so each moved item lands on its target mode's
|
||||||
|
// managed lane and the active-mode lane visibility is reasserted. The pure planItemRetag
|
||||||
|
// decides which selected items to retag (manual-lane items are EXEMPT — never retagged,
|
||||||
|
// never re-laned), upholding the managed-lanes-only invariant even under this explicit
|
||||||
|
// user action. The whole structural act is wrapped in ONE Undo block with a descriptive
|
||||||
|
// label (the inner blocks mintManagedLanes / applyMode open nest harmlessly under it).
|
||||||
|
//
|
||||||
|
// UNDO/SAVE ordering: persistViewState may pop a Save-As dialog (Main_SaveProject) which
|
||||||
|
// must NOT sit inside the Undo block, so we close the block first, then persist — the same
|
||||||
|
// separation the track actions rely on (they persist outside applyMode's own block).
|
||||||
|
void doMoveItems(const std::string& targetMode) {
|
||||||
|
const std::vector<RetagItem> selected = selectedRetagItems();
|
||||||
|
const std::vector<ItemRetagOp> ops = planItemRetag(selected, targetMode);
|
||||||
|
if (ops.empty()) return; // nothing selected, or every selected item was exempt/empty
|
||||||
|
|
||||||
|
MembershipIndex& membership = g_session->view().membership();
|
||||||
|
|
||||||
|
Undo_BeginBlock2(nullptr);
|
||||||
|
// Apply the pure decision's membership writes: tag into targetMode, or untag.
|
||||||
|
for (const ItemRetagOp& op : ops) {
|
||||||
|
if (op.untag) membership.untag(op.guid);
|
||||||
|
else membership.tag(op.guid, op.modeId);
|
||||||
|
}
|
||||||
|
// Re-drive the SAME minting/apply path auto-tag uses: mint/split lanes for any track
|
||||||
|
// whose items now span modes and assign each moved item to its mode's managed lane,
|
||||||
|
// then reassert the active mode's lane visibility. Manual lanes stay untouched
|
||||||
|
// (mintManagedLanes reports their items exempt and never mints over them).
|
||||||
|
mintManagedLanes(g_session->view(), nullptr);
|
||||||
|
reapplyActiveMode();
|
||||||
|
|
||||||
|
const std::string label =
|
||||||
|
targetMode.empty()
|
||||||
|
? std::string("ReaSampler: untag selected items")
|
||||||
|
: std::string("ReaSampler: move selected items -> ") + targetMode;
|
||||||
|
Undo_EndBlock2(nullptr, label.c_str(), -1);
|
||||||
|
|
||||||
|
persistViewState();
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) {
|
void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) {
|
||||||
@@ -239,6 +332,14 @@ void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* ses
|
|||||||
"ReaSampler: untag selected tracks");
|
"ReaSampler: untag selected tracks");
|
||||||
g_cmdShowBoth = registerAction(rec, kIdShowBoth, g_accelShowBoth,
|
g_cmdShowBoth = registerAction(rec, kIdShowBoth, g_accelShowBoth,
|
||||||
"ReaSampler: show both for selected tracks");
|
"ReaSampler: show both for selected tracks");
|
||||||
|
|
||||||
|
// Item-level mode moves (D2 W3-B): the item analog of the track tag family.
|
||||||
|
g_cmdMoveItemsDesign = registerAction(rec, kIdMoveItemsDesign, g_accelMoveItemsDesign,
|
||||||
|
"ReaSampler: move selected items -> Design");
|
||||||
|
g_cmdMoveItemsArrange = registerAction(rec, kIdMoveItemsArrange, g_accelMoveItemsArrange,
|
||||||
|
"ReaSampler: move selected items -> Arrange");
|
||||||
|
g_cmdUntagItems = registerAction(rec, kIdUntagItems, g_accelUntagItems,
|
||||||
|
"ReaSampler: untag selected items");
|
||||||
}
|
}
|
||||||
|
|
||||||
bool designViewHandleCommand(int command) {
|
bool designViewHandleCommand(int command) {
|
||||||
@@ -253,12 +354,25 @@ bool designViewHandleCommand(int command) {
|
|||||||
if (command == g_cmdUntag) { doUntag(); return true; }
|
if (command == g_cmdUntag) { doUntag(); return true; }
|
||||||
if (command == g_cmdShowBoth) { doShowBoth(); return true; }
|
if (command == g_cmdShowBoth) { doShowBoth(); return true; }
|
||||||
|
|
||||||
|
// Item-level moves. Move -> Arrange and Untag items collapse to the same act (an
|
||||||
|
// empty target ⇒ untag ⇒ Arrange default), mirroring the track-level pairing above.
|
||||||
|
if (command == g_cmdMoveItemsDesign) { doMoveItems(kDesignModeId); return true; }
|
||||||
|
if (command == g_cmdMoveItemsArrange) { doMoveItems(std::string{}); return true; }
|
||||||
|
if (command == g_cmdUntagItems) { doMoveItems(std::string{}); return true; }
|
||||||
|
|
||||||
return false; // not ours — caller's hookcommand keeps looking
|
return false; // not ours — caller's hookcommand keeps looking
|
||||||
}
|
}
|
||||||
|
|
||||||
void designViewUnregisterActions(reaper_plugin_info_t* rec) {
|
void designViewUnregisterActions(reaper_plugin_info_t* rec) {
|
||||||
// Mirror-unregister with '-'-prefixed strings, per the contract's unload rule.
|
// Mirror-unregister with '-'-prefixed strings, per the contract's unload rule.
|
||||||
// gaccel first, then the command_id string (reverse of registration order).
|
// gaccel first, then the command_id string (reverse of registration order — the item
|
||||||
|
// moves registered last, so they tear down first).
|
||||||
|
rec->Register("-gaccel", (void*)&g_accelUntagItems);
|
||||||
|
rec->Register("-command_id", (void*)kIdUntagItems);
|
||||||
|
rec->Register("-gaccel", (void*)&g_accelMoveItemsArrange);
|
||||||
|
rec->Register("-command_id", (void*)kIdMoveItemsArrange);
|
||||||
|
rec->Register("-gaccel", (void*)&g_accelMoveItemsDesign);
|
||||||
|
rec->Register("-command_id", (void*)kIdMoveItemsDesign);
|
||||||
rec->Register("-gaccel", (void*)&g_accelShowBoth);
|
rec->Register("-gaccel", (void*)&g_accelShowBoth);
|
||||||
rec->Register("-command_id", (void*)kIdShowBoth);
|
rec->Register("-command_id", (void*)kIdShowBoth);
|
||||||
rec->Register("-gaccel", (void*)&g_accelUntag);
|
rec->Register("-gaccel", (void*)&g_accelUntag);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "bank_grid.h"
|
#include "bank_grid.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
|
|
||||||
@@ -199,4 +200,28 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
float compressAmplitudeForDisplay(float linear) {
|
||||||
|
const float mag = linear < 0.0f ? -linear : linear;
|
||||||
|
|
||||||
|
// The linear magnitude at the floor threshold: 10^(kDisplayFloorDb/20).
|
||||||
|
// Any magnitude at or below this maps to display fraction 0.
|
||||||
|
// Computed once as a constant expression; std::pow is constexpr in C++20 but
|
||||||
|
// not C++17, so derive it via the floor definition directly at runtime — it is
|
||||||
|
// only called once per bin, and the branch-free math is cheap.
|
||||||
|
const float floorMag = std::pow(10.0f, kDisplayFloorDb / 20.0f);
|
||||||
|
|
||||||
|
if (mag <= floorMag) return 0.0f; // below floor (and guards log10(0))
|
||||||
|
|
||||||
|
// dB in [kDisplayFloorDb, 0] for magnitude in [floorMag, 1].
|
||||||
|
const float db = 20.0f * std::log10(mag);
|
||||||
|
|
||||||
|
// Normalize to [0, 1]: 0 at kDisplayFloorDb, 1 at 0 dB.
|
||||||
|
const float fraction = (db - kDisplayFloorDb) / (0.0f - kDisplayFloorDb);
|
||||||
|
|
||||||
|
// Clamp to [0, 1] so floating-point overshoot on |linear| > 1.0 stays bounded,
|
||||||
|
// then re-apply the original sign.
|
||||||
|
const float clamped = fraction < 0.0f ? 0.0f : (fraction > 1.0f ? 1.0f : fraction);
|
||||||
|
return linear < 0.0f ? -clamped : clamped;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
@@ -163,4 +163,24 @@ enum class NavKey { Left, Right, Up, Down, Home, End };
|
|||||||
Selection navigate(const Selection& current, NavKey key, int cols, int itemCount,
|
Selection navigate(const Selection& current, NavKey key, int cols, int itemCount,
|
||||||
bool shift);
|
bool shift);
|
||||||
|
|
||||||
|
// --- Waveform display compression --------------------------------------------
|
||||||
|
//
|
||||||
|
// Maps a raw linear amplitude magnitude to a perceptual display fraction so
|
||||||
|
// quiet and medium content remains visible in the thumbnail.
|
||||||
|
//
|
||||||
|
// The floor below which amplitude is treated as silence (display fraction 0).
|
||||||
|
// At -60 dB, 0.001 linear magnitude maps to ~0. Tune this constant in-DAW to
|
||||||
|
// taste — it is the only knob for the compression curve.
|
||||||
|
constexpr float kDisplayFloorDb = -60.0f;
|
||||||
|
|
||||||
|
// Maps a signed linear amplitude value in [-1, 1] (a raw envelope extreme such
|
||||||
|
// as PeakBin::max or PeakBin::min) to a signed display fraction in [-1, 1].
|
||||||
|
//
|
||||||
|
// The magnitude |linear| is converted to dB, clamped to [kDisplayFloorDb, 0],
|
||||||
|
// then normalized so kDisplayFloorDb -> 0 and 0 dB -> 1. The original sign is
|
||||||
|
// re-applied so positive max values still map positive (draw up) and negative
|
||||||
|
// min values still map negative (draw down). Exact-zero input returns 0.0f
|
||||||
|
// (stays on the midline). Full-scale (|linear| == 1.0f) returns exactly ±1.0f.
|
||||||
|
float compressAmplitudeForDisplay(float linear);
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
+312
-11
@@ -40,6 +40,8 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdlib> // std::abs (drag threshold)
|
#include <cstdlib> // std::abs (drag threshold)
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <map>
|
||||||
|
#include <set>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -48,12 +50,17 @@
|
|||||||
#include "bank_grid.h"
|
#include "bank_grid.h"
|
||||||
#include "bank_model.h"
|
#include "bank_model.h"
|
||||||
#include "capture_paths.h"
|
#include "capture_paths.h"
|
||||||
|
#include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2)
|
||||||
|
#include "item_read.h" // itemGuid / itemLaneName — shared item-read seam (D2 W3-B)
|
||||||
|
#include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2)
|
||||||
#include "mode_switch.h"
|
#include "mode_switch.h"
|
||||||
#include "peaks.h"
|
#include "peaks.h"
|
||||||
#include "persist.h"
|
#include "persist.h"
|
||||||
#include "tab_strip.h"
|
#include "tab_strip.h"
|
||||||
#include "tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure)
|
#include "tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure)
|
||||||
|
#include "track_guid.h" // guidString — canonical track GUID key (D2 Wave 2)
|
||||||
#include "view.h" // applyMode — the D2/D4 mode-activation entrypoint the switch fires
|
#include "view.h" // applyMode — the D2/D4 mode-activation entrypoint the switch fires
|
||||||
|
#include "view_mode_model.h" // autoTagNewContent / NewItem (D2 Wave 2)
|
||||||
|
|
||||||
// SWELL / LICE. On macOS/Linux SWELL is provided by the host (SWELL_PROVIDED_BY_APP);
|
// SWELL / LICE. On macOS/Linux SWELL is provided by the host (SWELL_PROVIDED_BY_APP);
|
||||||
// on Windows we use native Win32 (windows.h first, then swell.h no-ops on _WIN32).
|
// on Windows we use native Win32 (windows.h first, then swell.h no-ops on _WIN32).
|
||||||
@@ -76,9 +83,20 @@
|
|||||||
#define REAPERAPI_WANT_DockWindowActivate
|
#define REAPERAPI_WANT_DockWindowActivate
|
||||||
#define REAPERAPI_WANT_DockWindowRemove
|
#define REAPERAPI_WANT_DockWindowRemove
|
||||||
#define REAPERAPI_WANT_EnumProjects
|
#define REAPERAPI_WANT_EnumProjects
|
||||||
|
#define REAPERAPI_WANT_MarkProjectDirty // mark dirty when the tail toggle changes (saves with the project)
|
||||||
#define REAPERAPI_WANT_GetMainHwnd
|
#define REAPERAPI_WANT_GetMainHwnd
|
||||||
#define REAPERAPI_WANT_PCM_Source_CreateFromFile
|
#define REAPERAPI_WANT_PCM_Source_CreateFromFile
|
||||||
#define REAPERAPI_WANT_PCM_Source_Destroy
|
#define REAPERAPI_WANT_PCM_Source_Destroy
|
||||||
|
// New-content detection (D2 Wave 2): enumerate live tracks + items and read fixed-lane
|
||||||
|
// state to classify an item's lane as managed vs manual.
|
||||||
|
#define REAPERAPI_WANT_CountTracks
|
||||||
|
#define REAPERAPI_WANT_GetTrack
|
||||||
|
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||||
|
#define REAPERAPI_WANT_CountTrackMediaItems
|
||||||
|
#define REAPERAPI_WANT_GetTrackMediaItem
|
||||||
|
// Stock preview API (verified against reaper_plugin.h / reaper_plugin_functions.h):
|
||||||
|
// PlayPreview/StopPreview drive a caller-owned preview_register_t. These are the
|
||||||
|
// STOCK symbols (not SWS-only) — see the audition section below.
|
||||||
#define REAPERAPI_WANT_PlayPreview
|
#define REAPERAPI_WANT_PlayPreview
|
||||||
#define REAPERAPI_WANT_StopPreview
|
#define REAPERAPI_WANT_StopPreview
|
||||||
#define REAPERAPI_WANT_GetUserInputs
|
#define REAPERAPI_WANT_GetUserInputs
|
||||||
@@ -226,13 +244,43 @@ struct PanelState {
|
|||||||
std::string dropBankId; // destination bank id when dropKind==Tab
|
std::string dropBankId; // destination bank id when dropKind==Tab
|
||||||
|
|
||||||
// --- Tail-mode toggle -----------------------------------------------------
|
// --- Tail-mode toggle -----------------------------------------------------
|
||||||
TailSetting tail;
|
// The authoritative tail setting now lives in ReaSamplerSession (session->tail()),
|
||||||
|
// NOT in panel state, so it travels inside the .rpp (persist serializes it on save,
|
||||||
|
// restores it on project load). The panel reads it for drawing and mutates it via
|
||||||
|
// the footer click (cycle mode) and scroll-wheel (Manual fine-adjust), marking the
|
||||||
|
// project dirty so the choice saves. bankPanelTailSetting is the read seam for the
|
||||||
|
// capture actions. Held here only through the session pointer above.
|
||||||
|
|
||||||
// --- Audition preview -----------------------------------------------------
|
// --- Audition preview -----------------------------------------------------
|
||||||
preview_register_t preview{};
|
preview_register_t preview{};
|
||||||
PCM_source* previewSrc = nullptr;
|
PCM_source* previewSrc = nullptr;
|
||||||
bool previewActive = false;
|
bool previewActive = false;
|
||||||
bool previewInited = false;
|
bool previewInited = false; // guards double init / deinit
|
||||||
|
|
||||||
|
// --- New-content detection (D2 Wave 2) ------------------------------------
|
||||||
|
//
|
||||||
|
// Each timer tick diffs the live track+item GUID set against the previous tick to
|
||||||
|
// auto-tag content created SINCE the last tick into the then-active mode. The
|
||||||
|
// baseline carries the first-poll-after-open guard (GuidBaseline self-arms on its
|
||||||
|
// first observe()) so pre-existing content is never mass-tagged (it stays Arrange).
|
||||||
|
//
|
||||||
|
// Project-load re-arm is driven by persist's AUTHORITATIVE load lifecycle, NOT by a
|
||||||
|
// pointer compare here. main.cpp calls bankPanelNotifyProjectLoaded() on the exact
|
||||||
|
// tick persist restores a project's membership + active mode (the same tick it
|
||||||
|
// reapplies the active mode); that sets reloadPending so the NEXT detect tick this
|
||||||
|
// same tick re-baselines against the fully-loaded set and reports nothing new. This
|
||||||
|
// replaces the former `proj != lastProject` re-arm, which used a WEAKER signal than
|
||||||
|
// persist (pointer-only vs persist's GUID-primary identity) and so missed a load onto
|
||||||
|
// a RECYCLED ReaProject* address — the just-loaded project's pre-existing tracks then
|
||||||
|
// diffed against the previous project's stale baseline and were mass-tagged into the
|
||||||
|
// active mode (the reload-mis-tag bug). Coordinating with persist's signal makes the
|
||||||
|
// two identity checks agree by construction.
|
||||||
|
//
|
||||||
|
// Lives for the extension's lifetime alongside the session, independent of panel
|
||||||
|
// open/close — detection must run whether or not the dock is visible (content is
|
||||||
|
// created in the arrange, not the panel).
|
||||||
|
GuidBaseline contentBaseline;
|
||||||
|
bool reloadPending = false; // set by bankPanelNotifyProjectLoaded; drained next detect tick
|
||||||
};
|
};
|
||||||
|
|
||||||
PanelState g_panel;
|
PanelState g_panel;
|
||||||
@@ -381,8 +429,10 @@ void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env,
|
|||||||
const int innerW = rect.width - 4;
|
const int innerW = rect.width - 4;
|
||||||
for (int i = 0; i < nbins; ++i) {
|
for (int i = 0; i < nbins; ++i) {
|
||||||
const int x = rect.x + 2 + (nbins > 1 ? (i * (innerW - 1)) / (nbins - 1) : 0);
|
const int x = rect.x + 2 + (nbins > 1 ? (i * (innerW - 1)) / (nbins - 1) : 0);
|
||||||
int yMax = midY - static_cast<int>(bins[i].max * halfSpan);
|
// min<=max always (peaks invariant). Draw a vertical line from the
|
||||||
int yMin = midY - static_cast<int>(bins[i].min * halfSpan);
|
// min sample to the max sample, clamped to the band.
|
||||||
|
int yMax = midY - static_cast<int>(compressAmplitudeForDisplay(bins[i].max) * halfSpan); // max -> up
|
||||||
|
int yMin = midY - static_cast<int>(compressAmplitudeForDisplay(bins[i].min) * halfSpan); // min -> down
|
||||||
if (yMax < bandTop) yMax = bandTop;
|
if (yMax < bandTop) yMax = bandTop;
|
||||||
if (yMin > bandTop + bandH - 1) yMin = bandTop + bandH - 1;
|
if (yMin > bandTop + bandH - 1) yMin = bandTop + bandH - 1;
|
||||||
LICE_Line(bmp, x, yMin, x, yMax, kColWaveform, 1.0f, 0, false);
|
LICE_Line(bmp, x, yMin, x, yMax, kColWaveform, 1.0f, 0, false);
|
||||||
@@ -456,6 +506,15 @@ RECT panelFooter(int w, int h) {
|
|||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The session's live tail setting (default None / 2 s when no session). Single read
|
||||||
|
// point so draw, wheel-adjust, and the capture read seam all agree on the source.
|
||||||
|
TailSetting currentTail() {
|
||||||
|
return g_panel.session ? g_panel.session->tail() : TailSetting{};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draws the tail-mode toggle into the footer strip: a filled band, a top divider,
|
||||||
|
// and the current mode's label ("Tail: Off / Auto / Manual Xs") from the pure
|
||||||
|
// tail_control module. READ-ONLY: reads session->tail(); the input handlers mutate it.
|
||||||
void drawTailFooter(LICE_IBitmap* bmp, int w, int h) {
|
void drawTailFooter(LICE_IBitmap* bmp, int w, int h) {
|
||||||
const RECT f = panelFooter(w, h);
|
const RECT f = panelFooter(w, h);
|
||||||
if (f.top >= f.bottom) return;
|
if (f.top >= f.bottom) return;
|
||||||
@@ -465,7 +524,7 @@ void drawTailFooter(LICE_IBitmap* bmp, int w, int h) {
|
|||||||
|
|
||||||
HDC dc = bmp->getDC();
|
HDC dc = bmp->getDC();
|
||||||
if (!dc) return;
|
if (!dc) return;
|
||||||
const std::string label = tailToggleLabel(g_panel.tail);
|
const std::string label = tailToggleLabel(currentTail());
|
||||||
RECT rc = f;
|
RECT rc = f;
|
||||||
rc.left += 8;
|
rc.left += 8;
|
||||||
SetTextColor(dc, kRgbFooterText);
|
SetTextColor(dc, kRgbFooterText);
|
||||||
@@ -474,6 +533,30 @@ void drawTailFooter(LICE_IBitmap* bmp, int w, int h) {
|
|||||||
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
|
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True iff client-relative (x, y) falls inside the (non-degenerate) footer strip.
|
||||||
|
// Shared by the footer click (cycle mode) and the scroll-wheel (Manual fine-adjust)
|
||||||
|
// so both agree on the hit target.
|
||||||
|
bool pointInFooter(int x, int y) {
|
||||||
|
if (!g_panel.hwnd) return false;
|
||||||
|
RECT cr{};
|
||||||
|
GetClientRect(g_panel.hwnd, &cr);
|
||||||
|
const RECT f = panelFooter(cr.right - cr.left, cr.bottom - cr.top);
|
||||||
|
return f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commits the current tail setting to ext state and marks the active project dirty
|
||||||
|
// so the change travels inside the .rpp on Ctrl+S. saveToActiveProject() is the only
|
||||||
|
// path that calls SetProjExtState for the tail key — calling it here closes the gap
|
||||||
|
// where toggle/scroll would dirty the project but the new value was never written.
|
||||||
|
// On an unsaved project saveToActiveProject() no-ops cleanly (documented in persist.h).
|
||||||
|
// MarkProjectDirty runs unconditionally so REAPER knows a save is owed either way.
|
||||||
|
// NON-DESTRUCTIVE: touches nothing in the bank/arrange.
|
||||||
|
void markTailDirty() {
|
||||||
|
if (g_panel.session) g_panel.session->saveToActiveProject();
|
||||||
|
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||||
|
if (proj) MarkProjectDirty(proj);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Split geometry -----------------------------------------------------------
|
// --- Split geometry -----------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Every rect below is derived from the client size + fullHeight state, and BOTH paint
|
// Every rect below is derived from the client size + fullHeight state, and BOTH paint
|
||||||
@@ -851,7 +934,146 @@ bool refreshFingerprint() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Audition preview (unchanged from M5) -------------------------------------
|
// --- New-content detection (D2 Wave 2) ----------------------------------------
|
||||||
|
//
|
||||||
|
// REAPER exposes no "item/track added" callback, so we diff live project state on the
|
||||||
|
// existing timer. Each tick: enumerate every track GUID and every item GUID, diff
|
||||||
|
// against the previous tick (GuidBaseline, first-poll-guarded), and auto-tag the new
|
||||||
|
// GUIDs into the active mode via the pure autoTagNewContent. An item on a MANUAL lane
|
||||||
|
// is exempt (design point #1) — its lane's durable name lacks the managed prefix. All
|
||||||
|
// enumeration is READ-ONLY on the project; the only mutation is to the in-memory
|
||||||
|
// membership index (persisted by persist on the next save, same as an action-driven tag).
|
||||||
|
|
||||||
|
// True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). The SDK value is verified
|
||||||
|
// in view.cpp (kFreeModeFixedLanes=2); reproduced here as a local constant so
|
||||||
|
// bank_panel.cpp stays self-contained without pulling in view.cpp's private namespace.
|
||||||
|
constexpr int kFreeModeFixedLanes = 2;
|
||||||
|
|
||||||
|
bool isFixedLaneTrack(MediaTrack* tr) {
|
||||||
|
return static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h):
|
||||||
|
// itemGuid(it) and itemLaneName(tr, it). bank_panel.cpp no longer carries its own copies.
|
||||||
|
|
||||||
|
// Enumerates the live project's track + item GUIDs. Fills `allGuids` (the full live set,
|
||||||
|
// baseline input) and, for each item, records whether it sits on a manual lane so a
|
||||||
|
// newly-detected item can be exempted from auto-tag without a second project walk.
|
||||||
|
//
|
||||||
|
// Manual-lane classification uses the single pure predicate isOnManualLane(isFixedLaneTrack,
|
||||||
|
// laneName) from lane_keys — the same predicate the apply path consults — so the exemption
|
||||||
|
// rule is defined in exactly one place and is unit-tested there.
|
||||||
|
void enumerateLiveGuids(ReaProject* proj, std::set<std::string>& allGuids,
|
||||||
|
std::map<std::string, bool>& itemOnManualLane) {
|
||||||
|
const int trackCount = CountTracks(proj);
|
||||||
|
for (int t = 0; t < trackCount; ++t) {
|
||||||
|
MediaTrack* tr = GetTrack(proj, t);
|
||||||
|
if (!tr) continue;
|
||||||
|
std::string tg = guidString(tr);
|
||||||
|
if (!tg.empty()) allGuids.insert(tg);
|
||||||
|
|
||||||
|
// Compute the fixed-lane status once per track (not per item) — I_FREEMODE is a
|
||||||
|
// track-level attribute and is the same for every item on the track.
|
||||||
|
const bool fixedLane = isFixedLaneTrack(tr);
|
||||||
|
|
||||||
|
const int itemCount = CountTrackMediaItems(tr);
|
||||||
|
for (int i = 0; i < itemCount; ++i) {
|
||||||
|
MediaItem* it = GetTrackMediaItem(tr, i);
|
||||||
|
if (!it) continue;
|
||||||
|
std::string ig = itemGuid(it);
|
||||||
|
if (ig.empty()) continue;
|
||||||
|
allGuids.insert(ig);
|
||||||
|
// Classify via the single shared predicate. For a fixed-lane track we read
|
||||||
|
// the item's lane name; for a normal track we pass "" (isOnManualLane returns
|
||||||
|
// false immediately for non-fixed-lane tracks regardless of name).
|
||||||
|
const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{};
|
||||||
|
itemOnManualLane[ig] = isOnManualLane(fixedLane, ln);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One detection tick: diff live GUIDs against the baseline and auto-tag the new ones
|
||||||
|
// into the active mode. Runs every timer tick regardless of panel open/close (content
|
||||||
|
// is created in the arrange). READ-ONLY on the project; mutates only the in-memory
|
||||||
|
// membership index.
|
||||||
|
//
|
||||||
|
// INTENTIONAL: membership mutation happens OUTSIDE any Undo block. Auto-tag is a
|
||||||
|
// background metadata update (like setting a label), not a destructive project edit.
|
||||||
|
// persist.cpp writes it on the next project save alongside the bank and view state, the
|
||||||
|
// same way an action-driven tag is persisted. Wrapping this in an Undo block would flood
|
||||||
|
// the REAPER undo history with a new entry for every timer tick that sees new content.
|
||||||
|
// Returns true iff this tick tagged at least one new GUID into a mode — the signal the
|
||||||
|
// caller uses to decide whether to run the lane-minting pass (a track can only newly
|
||||||
|
// become multi-mode when auto-tag just placed content on it). No tag ⇒ nothing to mint.
|
||||||
|
bool detectNewContent() {
|
||||||
|
if (!g_panel.session) return false;
|
||||||
|
|
||||||
|
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||||
|
|
||||||
|
// A project (re)load re-arms the first-poll guard so we never diff across two
|
||||||
|
// projects. The signal is persist's — main.cpp calls bankPanelNotifyProjectLoaded()
|
||||||
|
// on the tick persist restores the project's membership + active mode, which sets
|
||||||
|
// reloadPending. Draining it here re-baselines against the fully-loaded set (that
|
||||||
|
// same tick's reapply-active-mode enumerated those tracks, so they are present),
|
||||||
|
// and the observe() below returns nothing new — pre-existing untagged tracks stay
|
||||||
|
// Arrange. GuidBaseline self-arms on its first observe() for the very first tick, so
|
||||||
|
// no separate first-tick handling is needed here. Using persist's GUID-primary load
|
||||||
|
// signal (not a local pointer compare) is what fixes the reload-mis-tag: the two
|
||||||
|
// identity checks can no longer diverge on a recycled ReaProject* address.
|
||||||
|
if (g_panel.reloadPending) {
|
||||||
|
g_panel.contentBaseline.reset();
|
||||||
|
g_panel.reloadPending = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::set<std::string> live;
|
||||||
|
std::map<std::string, bool> itemOnManualLane;
|
||||||
|
enumerateLiveGuids(proj, live, itemOnManualLane);
|
||||||
|
|
||||||
|
const std::vector<std::string> added = g_panel.contentBaseline.observe(live);
|
||||||
|
if (added.empty()) return false; // first poll after open, or nothing new this tick
|
||||||
|
|
||||||
|
// Split the new GUIDs into tracks vs items so the pure decision can apply the
|
||||||
|
// manual-lane exemption to items only. A GUID present in the item-lane map is an
|
||||||
|
// item; otherwise it is a track (track GUIDs never appear in that map).
|
||||||
|
std::vector<std::string> newTracks;
|
||||||
|
std::vector<NewItem> newItems;
|
||||||
|
for (const std::string& g : added) {
|
||||||
|
auto it = itemOnManualLane.find(g);
|
||||||
|
if (it == itemOnManualLane.end()) {
|
||||||
|
newTracks.push_back(g); // a track GUID
|
||||||
|
} else {
|
||||||
|
newItems.push_back(NewItem{g, it->second}); // an item; carries its exemption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewModeModel& model = g_panel.session->view();
|
||||||
|
const std::vector<AutoTag> tags =
|
||||||
|
autoTagNewContent(newTracks, newItems, model.activeModeId());
|
||||||
|
for (const AutoTag& tag : tags)
|
||||||
|
model.membership().tag(tag.guid, tag.modeId);
|
||||||
|
return !tags.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Audition preview ---------------------------------------------------------
|
||||||
|
//
|
||||||
|
// READ-ONLY / NON-DESTRUCTIVE (load-bearing principle): audition is PREVIEW
|
||||||
|
// playback only. It NEVER inserts into the arrange, creates items/tracks, or
|
||||||
|
// mutates the project or bank. PlayPreview streams a caller-owned PCM_source
|
||||||
|
// through REAPER's preview bus and touches nothing in the project.
|
||||||
|
//
|
||||||
|
// FLAGGED RUNTIME ASSUMPTIONS (header does not specify these; verified only by
|
||||||
|
// signature/struct, not semantics — DAW-verify):
|
||||||
|
// 1. REAPER's audio thread reads the preview_register_t by POINTER while the
|
||||||
|
// preview is active (the struct's own comment mandates a cs/mutex we init),
|
||||||
|
// so the register must outlive playback — we hold it in g_panel (static),
|
||||||
|
// never on the stack.
|
||||||
|
// 2. StopPreview is assumed to detach the source from the audio thread BEFORE it
|
||||||
|
// returns, making it safe to PCM_Source_Destroy the source immediately after.
|
||||||
|
// This is the conventional contract (SWS' preview helpers rely on it) but is
|
||||||
|
// NOT documented in the header — flagged. If a rare race surfaced, the fix is
|
||||||
|
// a StartPreviewFade + deferred free; not done now (YAGNI, no evidence).
|
||||||
|
// 3. m_out_chan == 0 routes to the first hardware output pair (stereo). We do not
|
||||||
|
// set mono (&1024). volume 1.0, loop false, curpos 0.
|
||||||
|
|
||||||
void initPreview() {
|
void initPreview() {
|
||||||
if (g_panel.previewInited) return;
|
if (g_panel.previewInited) return;
|
||||||
@@ -1298,10 +1520,15 @@ void handleClick(int x, int y) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tail footer: a click anywhere cycles the tail mode.
|
// Tail footer: a click anywhere in the bottom strip cycles the tail mode
|
||||||
const RECT f = panelFooter(w, h);
|
// (None -> Auto -> Manual -> None) and repaints. It mutates the SESSION's tail
|
||||||
if (f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom) {
|
// setting (which the capture actions read and persist saves with the project) and
|
||||||
g_panel.tail.mode = cycleTailMode(g_panel.tail.mode);
|
// marks the project dirty so the choice travels inside the .rpp — it touches
|
||||||
|
// NOTHING in the bank/arrange. Checked before the grid so a footer click never selects.
|
||||||
|
if (g_panel.session && pointInFooter(x, y)) {
|
||||||
|
TailSetting& tail = g_panel.session->tail();
|
||||||
|
tail.mode = cycleTailMode(tail.mode);
|
||||||
|
markTailDirty();
|
||||||
invalidatePanel();
|
invalidatePanel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1370,6 +1597,34 @@ void handleClick(int x, int y) {
|
|||||||
invalidatePanel();
|
invalidatePanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handles a scroll-wheel notch over client (x, y) with signed wheel delta `delta`.
|
||||||
|
// Fine-adjusts the Manual tail length in kManualStepMs steps ONLY when the cursor is
|
||||||
|
// over the footer strip AND the mode is Manual — wheel up lengthens, down shortens,
|
||||||
|
// clamped to [0, kMaxTailMs]. In Off/Auto (or off the footer) it does nothing (returns
|
||||||
|
// false so the caller can let REAPER/the docker handle the wheel normally). On a real
|
||||||
|
// change it mutates the SESSION's tail setting, marks the project dirty (so it saves),
|
||||||
|
// and repaints the live length. Returns true iff the wheel was consumed.
|
||||||
|
bool handleWheel(int x, int y, int delta) {
|
||||||
|
if (!g_panel.session) return false;
|
||||||
|
if (!pointInFooter(x, y)) return false;
|
||||||
|
|
||||||
|
TailSetting& tail = g_panel.session->tail();
|
||||||
|
if (tail.mode != TailMode::Manual) return false; // fine-adjust is Manual-only
|
||||||
|
|
||||||
|
// One notch is WHEEL_DELTA (120); accumulate whole notches so a high-res trackpad
|
||||||
|
// that sends fractional deltas still steps predictably. Sign carries direction.
|
||||||
|
const int notches = delta / 120;
|
||||||
|
if (notches == 0) return false; // sub-notch movement — nothing to apply yet
|
||||||
|
|
||||||
|
const double before = tail.manualMs;
|
||||||
|
tail.manualMs = adjustManualMs(tail.manualMs, notches, kManualStepMs);
|
||||||
|
if (tail.manualMs == before) return true; // already at a bound — consumed, no change
|
||||||
|
|
||||||
|
markTailDirty();
|
||||||
|
invalidatePanel(); // label shows the new length live
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// The column count for a region's current grid width (nav needs the layout's wrap).
|
// The column count for a region's current grid width (nav needs the layout's wrap).
|
||||||
int columnsForRegion(Region reg) {
|
int columnsForRegion(Region reg) {
|
||||||
RECT cr{};
|
RECT cr{};
|
||||||
@@ -1597,6 +1852,19 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
|||||||
invalidatePanel();
|
invalidatePanel();
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
|
case WM_MOUSEWHEEL: {
|
||||||
|
// Fine-adjust the Manual tail length when the wheel is over the footer.
|
||||||
|
// UNLIKE the button messages, WM_MOUSEWHEEL carries SCREEN coordinates in
|
||||||
|
// lParam (Win32 and SWELL agree — swell-generic-gdk.cpp §WM_MOUSEWHEEL), so
|
||||||
|
// convert to client space before hit-testing the footer. The signed wheel
|
||||||
|
// delta is the HIWORD of wParam (SWELL packs it as (delta<<16), delta=+/-120,
|
||||||
|
// matching GET_WHEEL_DELTA_WPARAM). Consume (return 1) only when the footer
|
||||||
|
// handler acts, so scrolling elsewhere in the dock still behaves normally.
|
||||||
|
POINT pt{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};
|
||||||
|
ScreenToClient(hwnd, &pt);
|
||||||
|
const int delta = static_cast<short>(HIWORD(wParam));
|
||||||
|
return handleWheel(pt.x, pt.y, delta) ? 1 : 0;
|
||||||
|
}
|
||||||
case WM_DESTROY:
|
case WM_DESTROY:
|
||||||
if (GetCapture() == hwnd) ReleaseCapture();
|
if (GetCapture() == hwnd) ReleaseCapture();
|
||||||
stopAudition();
|
stopAudition();
|
||||||
@@ -1677,14 +1945,47 @@ std::string bankPanelSelectedSourceBankId() {
|
|||||||
return id.empty() ? std::string(kPoolBankId) : id;
|
return id.empty() ? std::string(kPoolBankId) : id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void bankPanelNotifyProjectLoaded() {
|
||||||
|
// Persist restored a project's membership + active mode this tick (main.cpp calls
|
||||||
|
// this from the same consumeLoadSignal() branch that reapplies the active mode).
|
||||||
|
// Arm the new-content detector to re-baseline on its next tick so the just-loaded
|
||||||
|
// project's pre-existing content is treated as the baseline (nothing new) rather
|
||||||
|
// than diffed against the previous project and mass-tagged into the active mode.
|
||||||
|
// A flag (not an inline reset) because detectNewContent owns the baseline and runs
|
||||||
|
// later in the SAME OnTimer tick — it drains this and re-baselines against the live
|
||||||
|
// set in one place, keeping the reset and the observe() adjacent and ordered.
|
||||||
|
g_panel.reloadPending = true;
|
||||||
|
}
|
||||||
|
|
||||||
void bankPanelRefresh() {
|
void bankPanelRefresh() {
|
||||||
|
// New-content auto-tag detection runs EVERY tick regardless of panel open/close:
|
||||||
|
// tracks/items are created in the arrange view, not the panel, so detection must
|
||||||
|
// not be gated on the dock being visible. READ-ONLY on the project; only mutates
|
||||||
|
// the in-memory membership index (persist saves it like any action-driven tag).
|
||||||
|
const bool tagged = detectNewContent();
|
||||||
|
|
||||||
|
// Lane minting (D2 Wave 3) runs ONLY when detection just tagged new content — a
|
||||||
|
// track can only newly become multi-mode when auto-tag placed content on it. Unlike
|
||||||
|
// the invisible membership tag above, minting is a visible structural mutation
|
||||||
|
// (I_FREEMODE/I_FIXEDLANE/P_LANENAME), so mintManagedLanes wraps it in its own Undo
|
||||||
|
// block and only mints for tracks that hold >1 mode's content — a single-mode track
|
||||||
|
// is left to D1 whole-track parking. Managed lanes only; manual lanes untouched.
|
||||||
|
if (tagged && g_panel.session) {
|
||||||
|
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||||
|
mintManagedLanes(g_panel.session->view(), proj);
|
||||||
|
}
|
||||||
|
|
||||||
if (!g_panel.open || !g_panel.hwnd) return;
|
if (!g_panel.open || !g_panel.hwnd) return;
|
||||||
if (refreshFingerprint())
|
if (refreshFingerprint())
|
||||||
InvalidateRect(g_panel.hwnd, nullptr, FALSE);
|
InvalidateRect(g_panel.hwnd, nullptr, FALSE);
|
||||||
}
|
}
|
||||||
|
|
||||||
TailSetting bankPanelTailSetting() {
|
TailSetting bankPanelTailSetting() {
|
||||||
TailSetting s = g_panel.tail;
|
// The authoritative setting lives in the session (session->tail()) so it travels
|
||||||
|
// inside the .rpp: it loads per project and saves with the project. This stays the
|
||||||
|
// read seam for the capture actions. manualMs is clamped here so a caller always
|
||||||
|
// receives a within-cap length regardless of what was stored/scrolled.
|
||||||
|
TailSetting s = currentTail();
|
||||||
s.manualMs = clampManualMs(s.manualMs);
|
s.manualMs = clampManualMs(s.manualMs);
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,17 @@ std::string bankPanelSelectedSourceBankId();
|
|||||||
// reflected without the panel diffing the bank itself.
|
// reflected without the panel diffing the bank itself.
|
||||||
void bankPanelRefresh();
|
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
|
// 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
|
// 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
|
// applies whatever the panel toggle is set to. Default None (exact bounds) — a
|
||||||
|
|||||||
+182
-5
@@ -71,13 +71,18 @@
|
|||||||
|
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "capture_paths.h"
|
#include "capture_paths.h"
|
||||||
|
#include "peaks.h" // lastFrameAboveThreshold, AudioSample
|
||||||
#include "realtime_record.h"
|
#include "realtime_record.h"
|
||||||
|
#include "render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd
|
||||||
|
#include "wav_trim.h" // parseWavLayout, extractFloatFrames, planWavTruncate
|
||||||
|
|
||||||
#define REAPERAPI_MINIMAL
|
#define REAPERAPI_MINIMAL
|
||||||
#define REAPERAPI_WANT_EnumProjects
|
#define REAPERAPI_WANT_EnumProjects
|
||||||
@@ -178,6 +183,12 @@ public:
|
|||||||
BankPaths paths_;
|
BankPaths paths_;
|
||||||
std::string uniqueTag_;
|
std::string uniqueTag_;
|
||||||
|
|
||||||
|
// The RECORDED window end in project seconds (>= request_.endSeconds). For a tail
|
||||||
|
// mode the transport runs PAST the range end (Auto: +8 s cap; Manual: +the set
|
||||||
|
// length), so this — not request_.endSeconds — is the end the completion state
|
||||||
|
// machine waits for. Equals request_.endSeconds for TailMode::None (exact bounds).
|
||||||
|
double recordWindowEnd_ = 0.0;
|
||||||
|
|
||||||
// The transient sink. The sends we create (from each selected source track INTO
|
// The transient sink. The sends we create (from each selected source track INTO
|
||||||
// temp_) live on those source tracks pointing AT temp_, and are removed automatically
|
// temp_) live on those source tracks pointing AT temp_, and are removed automatically
|
||||||
// when temp_ is deleted — REAPER cannot leave a send dangling to a deleted
|
// when temp_ is deleted — REAPER cannot leave a send dangling to a deleted
|
||||||
@@ -311,6 +322,128 @@ private:
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
// Reads the whole file into a byte buffer. Empty vector on any I/O failure — the
|
||||||
|
// caller treats an unreadable file as "skip the trim" (keep the untrimmed window),
|
||||||
|
// never as a corruption of the recorded audio.
|
||||||
|
std::vector<std::uint8_t> readAllBytes(const std::string& path) {
|
||||||
|
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||||
|
if (!f) return {};
|
||||||
|
const std::streamoff size = f.tellg();
|
||||||
|
if (size <= 0) return {};
|
||||||
|
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(size));
|
||||||
|
f.seekg(0);
|
||||||
|
f.read(reinterpret_cast<char*>(bytes.data()), size);
|
||||||
|
if (!f) return {};
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patches a little-endian uint32 into a byte buffer at `off` (the header size fields).
|
||||||
|
void writeU32LE(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// §TAIL — Auto-mode PCM decay-scan trim (docs/product/capture-tail.md §realtime)
|
||||||
|
// ============================================================================
|
||||||
|
// After the recorded file is stable and moved into the bank (the file we OWN — never
|
||||||
|
// the project), Auto mode trims the trailing decay: read the WAV, scan the tail
|
||||||
|
// region (frames AFTER the original range end) backward for the last frame above
|
||||||
|
// -72 dB, and truncate the file there. Rules (spec):
|
||||||
|
// * no frame in the tail window above -72 dB -> trim back to the original range end
|
||||||
|
// * signal never falls below -72 dB in window -> keep the full window (cap did its job)
|
||||||
|
// * otherwise -> trim one frame past the last audible
|
||||||
|
//
|
||||||
|
// Returns the trimmed length in SECONDS (for the Sample), or a negative value to
|
||||||
|
// signal "no trim applied" (caller keeps the pre-trim length). Best-effort and
|
||||||
|
// non-fatal: any unreadable/unknown/short file skips the trim (keeps the full window)
|
||||||
|
// rather than risk corrupting the capture — realtime tail is a convenience path.
|
||||||
|
//
|
||||||
|
// FORMAT / FLUSH ASSUMPTIONS (DAW-verify): the recorded file is a canonical 32-bit
|
||||||
|
// float WAV (REAPER project record format — the manual procedure sets it) and is fully
|
||||||
|
// flushed/closed before this runs (the tick() Finalizing size-stable wait guarantees
|
||||||
|
// that for the normal path; abort()'s best-effort finalize races it, documented).
|
||||||
|
double trimAutoTailInPlace(const std::string& path,
|
||||||
|
double rangeStartSeconds,
|
||||||
|
double rangeEndSeconds) {
|
||||||
|
constexpr double kNoTrim = -1.0;
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> bytes = readAllBytes(path);
|
||||||
|
if (bytes.empty()) return kNoTrim;
|
||||||
|
|
||||||
|
const reasampler::WavLayout layout = parseWavLayout(bytes);
|
||||||
|
if (!layout.valid || layout.sampleRate == 0) return kNoTrim; // not a WAV we trim
|
||||||
|
|
||||||
|
const std::size_t totalFrames = layout.frameCount();
|
||||||
|
if (totalFrames == 0) return kNoTrim;
|
||||||
|
|
||||||
|
// The original range end as a frame index within the file (frame 0 == start). Use
|
||||||
|
// the FILE's own sample rate (authoritative) — the request rate may be 0 (=follow
|
||||||
|
// project). Clamp to the file so a rounding overshoot cannot exceed it.
|
||||||
|
const double rangeSeconds = rangeEndSeconds - rangeStartSeconds;
|
||||||
|
if (rangeSeconds <= 0.0) return kNoTrim;
|
||||||
|
std::size_t rangeEndFrame = static_cast<std::size_t>(
|
||||||
|
rangeSeconds * static_cast<double>(layout.sampleRate) + 0.5);
|
||||||
|
if (rangeEndFrame > totalFrames) rangeEndFrame = totalFrames;
|
||||||
|
|
||||||
|
// Nothing recorded past the range end (the tail window was empty) -> nothing to
|
||||||
|
// trim; keep as-is. (Shouldn't happen for Auto, but total by construction.)
|
||||||
|
if (rangeEndFrame >= totalFrames) return kNoTrim;
|
||||||
|
|
||||||
|
// Scan ONLY the tail region (frames after the original range end). The trim never
|
||||||
|
// eats into the range body — the scan starts at rangeEndFrame.
|
||||||
|
const std::size_t tailFrames = totalFrames - rangeEndFrame;
|
||||||
|
const std::vector<reasampler::AudioSample> tailPcm =
|
||||||
|
extractFloatFrames(bytes, layout, rangeEndFrame, tailFrames);
|
||||||
|
if (tailPcm.empty()) return kNoTrim;
|
||||||
|
|
||||||
|
const float threshold = static_cast<float>(reasampler::autoTrimEndRatio());
|
||||||
|
const std::size_t lastAbove = reasampler::lastFrameAboveThreshold(
|
||||||
|
tailPcm, layout.channelCount, tailFrames, threshold);
|
||||||
|
|
||||||
|
// keptFrames: the total frame count the trimmed file retains.
|
||||||
|
// no audible tail frame -> trim back to the range end (rangeEndFrame frames)
|
||||||
|
// an audible frame at idx -> keep range body + up to and including that frame
|
||||||
|
// The "signal never falls below threshold" case falls out naturally: lastAbove is
|
||||||
|
// the final tail frame, so keptFrames == totalFrames (the full window is kept).
|
||||||
|
std::size_t keptFrames;
|
||||||
|
if (lastAbove == reasampler::kNoFrameAboveThreshold) {
|
||||||
|
keptFrames = rangeEndFrame;
|
||||||
|
} else {
|
||||||
|
keptFrames = rangeEndFrame + (lastAbove + 1);
|
||||||
|
}
|
||||||
|
if (keptFrames >= totalFrames) return kNoTrim; // full window kept -> no truncate
|
||||||
|
|
||||||
|
const reasampler::WavTruncatePlan plan = planWavTruncate(layout, keptFrames);
|
||||||
|
if (!plan.valid) return kNoTrim;
|
||||||
|
|
||||||
|
// Patch the RIFF + data size fields in the in-memory buffer so they describe the
|
||||||
|
// kept frame count, then rewrite the file as exactly the first newFileByteLength
|
||||||
|
// bytes (header + patched sizes + retained PCM). A single truncating write is the
|
||||||
|
// simplest correct truncate — no separate resize step, no partial-write window
|
||||||
|
// where the on-disk sizes and length disagree. The result is a valid, playable WAV
|
||||||
|
// of the kept frames (verified by the wav_trim re-parse test).
|
||||||
|
writeU32LE(bytes, plan.dataSizeFieldOffset, plan.newDataSize);
|
||||||
|
writeU32LE(bytes, plan.riffSizeFieldOffset, plan.newRiffSize);
|
||||||
|
|
||||||
|
// NOTE (DAW-verify, best-effort): a truncating write that fails MID-write (a full
|
||||||
|
// disk, a yanked drive) would leave a short file while we return kNoTrim, so the
|
||||||
|
// Sample length would overstate the file. Vanishingly unlikely for a just-recorded
|
||||||
|
// local bank file, and realtime tail is a convenience path, so a temp-file+atomic-
|
||||||
|
// rename is not warranted here; flagged rather than built.
|
||||||
|
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||||
|
if (!out) return kNoTrim; // could not reopen to rewrite — leave the full file
|
||||||
|
out.write(reinterpret_cast<const char*>(bytes.data()),
|
||||||
|
static_cast<std::streamsize>(plan.newFileByteLength));
|
||||||
|
if (!out) return kNoTrim;
|
||||||
|
out.close();
|
||||||
|
|
||||||
|
// The trimmed length in seconds for the Sample metadata.
|
||||||
|
return static_cast<double>(keptFrames) / static_cast<double>(layout.sampleRate);
|
||||||
|
}
|
||||||
|
|
||||||
// Builds a CaptureResult for a finalized recording: discover the recorded file,
|
// Builds a CaptureResult for a finalized recording: discover the recorded file,
|
||||||
// move it into the bank, populate the Sample via the pure mapping. Returns Ok +
|
// move it into the bank, populate the Sample via the pure mapping. Returns Ok +
|
||||||
// Sample on success, or a RenderFailed result. Does NOT restore — the caller
|
// Sample on success, or a RenderFailed result. Does NOT restore — the caller
|
||||||
@@ -347,6 +480,19 @@ CaptureResult finalizeRecording(RealtimeCaptureState& st) {
|
|||||||
std::filesystem::remove(recorded, rmEc); // best-effort
|
std::filesystem::remove(recorded, rmEc); // best-effort
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TAIL (Auto): trim the trailing decay of the recorded window in place — on the
|
||||||
|
// BANK file we now own (destPath), never the project. Best-effort: an unreadable /
|
||||||
|
// unknown-format / short file skips the trim (keeps the full window) rather than
|
||||||
|
// corrupt the capture. Only Auto trims; None recorded exact bounds and Manual is a
|
||||||
|
// fixed window (spec §The realtime path). Returns the trimmed length in seconds,
|
||||||
|
// or < 0 for "no trim applied".
|
||||||
|
double trimmedLenSeconds = -1.0;
|
||||||
|
if (st.request_.tailMode == TailMode::Auto) {
|
||||||
|
trimmedLenSeconds = trimAutoTailInPlace(destPath,
|
||||||
|
st.request_.startSeconds,
|
||||||
|
st.request_.endSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
RecordedCapture cap;
|
RecordedCapture cap;
|
||||||
cap.relativePath = st.paths_.relativePath;
|
cap.relativePath = st.paths_.relativePath;
|
||||||
cap.uniqueTag = st.uniqueTag_;
|
cap.uniqueTag = st.uniqueTag_;
|
||||||
@@ -365,9 +511,25 @@ CaptureResult finalizeRecording(RealtimeCaptureState& st) {
|
|||||||
|
|
||||||
result.status = CaptureStatus::Ok;
|
result.status = CaptureStatus::Ok;
|
||||||
result.sample = sampleFromRecordedCapture(cap);
|
result.sample = sampleFromRecordedCapture(cap);
|
||||||
|
|
||||||
|
// The recorded file's true length differs from the request range when a tail was
|
||||||
|
// recorded, so the Sample length must reflect the FILE, not the range:
|
||||||
|
// Auto with a trim applied -> the trimmed length trimAutoTailInPlace returned.
|
||||||
|
// Auto with no trim, or Manual -> the full recorded window (end - start).
|
||||||
|
// None -> the exact range (unchanged; recordWindowEnd_ == endSeconds).
|
||||||
|
// sampleFromRecordedCapture already set lengthSeconds = end - start; override it
|
||||||
|
// to the recorded/trimmed length so downstream (thumbnail, placement) matches disk.
|
||||||
|
if (trimmedLenSeconds >= 0.0) {
|
||||||
|
result.sample.lengthSeconds = trimmedLenSeconds;
|
||||||
|
} else {
|
||||||
|
result.sample.lengthSeconds =
|
||||||
|
st.recordWindowEnd_ - st.request_.startSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
result.message = "Realtime-captured [" +
|
result.message = "Realtime-captured [" +
|
||||||
std::to_string(st.request_.startSeconds) + "s, " +
|
std::to_string(st.request_.startSeconds) + "s, " +
|
||||||
std::to_string(st.request_.endSeconds) + "s] -> " +
|
std::to_string(st.request_.endSeconds) + "s] (recorded " +
|
||||||
|
std::to_string(result.sample.lengthSeconds) + "s) -> " +
|
||||||
st.paths_.relativePath;
|
st.paths_.relativePath;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -448,6 +610,14 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
|
|||||||
st->uniqueTag_ = makeUniqueTag();
|
st->uniqueTag_ = makeUniqueTag();
|
||||||
st->paths_ = deriveBankPaths(projectDir, request.baseName, st->uniqueTag_);
|
st->paths_ = deriveBankPaths(projectDir, request.baseName, st->uniqueTag_);
|
||||||
|
|
||||||
|
// The recorded window end: extended past the range end for a tail mode (Auto/Manual),
|
||||||
|
// exact for None. This — not request.endSeconds — is what the completion machine
|
||||||
|
// waits for; the extra window past the range end is trimmed later (Auto) or kept
|
||||||
|
// (Manual). Pure mapping (render_settings), shared caps with the offline tail.
|
||||||
|
st->recordWindowEnd_ = realtimeRecordWindowEnd(request.tailMode,
|
||||||
|
request.endSeconds,
|
||||||
|
request.tailMs);
|
||||||
|
|
||||||
// DELIBERATE: the transient temp-track / arm / send / transport mutations are NOT
|
// DELIBERATE: the transient temp-track / arm / send / transport mutations are NOT
|
||||||
// wrapped in an Undo_BeginBlock/Undo_EndBlock — divergence from the insert/view
|
// wrapped in an Undo_BeginBlock/Undo_EndBlock — divergence from the insert/view
|
||||||
// shells is intentional. This backend fully restores its own state across every
|
// shells is intentional. This backend fully restores its own state across every
|
||||||
@@ -514,9 +684,12 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
|
|||||||
SetMediaTrackInfo_Value(st->temp_, "I_RECARM", 1.0); // arm ONLY the sink
|
SetMediaTrackInfo_Value(st->temp_, "I_RECARM", 1.0); // arm ONLY the sink
|
||||||
SetMediaTrackInfo_Value(st->temp_, "I_RECMON", 0.0); // no input monitoring
|
SetMediaTrackInfo_Value(st->temp_, "I_RECMON", 0.0); // no input monitoring
|
||||||
|
|
||||||
// Record range: time selection over [start,end], play cursor at start. Both were
|
// Record range: time selection over [start, recordWindowEnd], play cursor at start.
|
||||||
// snapshotted and will be restored by restore().
|
// recordWindowEnd extends past the request's range end for a tail mode so the
|
||||||
double rs = request.startSeconds, re = request.endSeconds;
|
// transport captures the decaying tail; it equals the range end for None (exact
|
||||||
|
// bounds). Both cursor + time selection were snapshotted and are restored by
|
||||||
|
// restore().
|
||||||
|
double rs = request.startSeconds, re = st->recordWindowEnd_;
|
||||||
GetSet_LoopTimeRange(true, false, &rs, &re, false);
|
GetSet_LoopTimeRange(true, false, &rs, &re, false);
|
||||||
SetEditCurPos(request.startSeconds, false, false);
|
SetEditCurPos(request.startSeconds, false, false);
|
||||||
|
|
||||||
@@ -567,9 +740,13 @@ RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) {
|
|||||||
state.lastFileSize_ = sz;
|
state.lastFileSize_ = sz;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for the transport to reach the RECORDED window end (extended past the
|
||||||
|
// range end for a tail mode), not the request's range end — the extra tail window
|
||||||
|
// is part of the record. The record safety ceiling scales with it (window - start
|
||||||
|
// + margin) inside the pure machine.
|
||||||
state.phase_ = advanceRecordPhase(state.phase_, inputs,
|
state.phase_ = advanceRecordPhase(state.phase_, inputs,
|
||||||
state.request_.startSeconds,
|
state.request_.startSeconds,
|
||||||
state.request_.endSeconds);
|
state.recordWindowEnd_);
|
||||||
|
|
||||||
// On the Recording -> Finalizing edge, stop OUR project's transport ONCE so REAPER
|
// On the Recording -> Finalizing edge, stop OUR project's transport ONCE so REAPER
|
||||||
// begins closing/flushing the recorded take. Project-scoped (OnStopButtonEx(proj_))
|
// begins closing/flushing the recorded take. Project-scoped (OnStopButtonEx(proj_))
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// guid_diff implementation — pure set arithmetic for new-content detection. See
|
||||||
|
// guid_diff.h. No REAPER, no SWELL — std only.
|
||||||
|
|
||||||
|
#include "guid_diff.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
std::vector<std::string> newGuids(const std::set<std::string>& previous,
|
||||||
|
const std::set<std::string>& current) {
|
||||||
|
std::vector<std::string> added;
|
||||||
|
// current \ previous. std::set iterates ascending, so set_difference yields a
|
||||||
|
// deterministic order without a separate sort.
|
||||||
|
for (const std::string& g : current) {
|
||||||
|
if (g.empty()) continue; // never tag a GUID-read failure
|
||||||
|
if (previous.count(g) == 0) added.push_back(g);
|
||||||
|
}
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> GuidBaseline::observe(const std::set<std::string>& current) {
|
||||||
|
if (!primed_) {
|
||||||
|
// First poll after open/reset: establish the baseline, report nothing new so
|
||||||
|
// pre-existing content is NOT auto-tagged (it defaults to Arrange).
|
||||||
|
baseline_ = current;
|
||||||
|
primed_ = true;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
std::vector<std::string> added = newGuids(baseline_, current);
|
||||||
|
// Advance the baseline to the full current set. Using `current` (not baseline_ ∪
|
||||||
|
// added) means a DELETED GUID drops out of the baseline too, so if REAPER later
|
||||||
|
// reuses that GUID for genuinely new content it is detected again — the baseline
|
||||||
|
// tracks the live set exactly, not a monotonic union.
|
||||||
|
baseline_ = current;
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GuidBaseline::reset() {
|
||||||
|
baseline_.clear();
|
||||||
|
primed_ = false; // next observe() re-baselines (first-poll guard re-armed)
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#pragma once
|
||||||
|
// guid_diff — the pure, REAPER-free core of the D2 Wave-2 new-content detection.
|
||||||
|
//
|
||||||
|
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||||
|
// vendor/ includes. Standard library only. Unit-tested outside the DAW.
|
||||||
|
//
|
||||||
|
// The shell (bank_panel timer) reads REAPER's live track/item GUID set each tick;
|
||||||
|
// this module owns the DECISION of "which GUIDs are new since the last tick" and the
|
||||||
|
// first-poll-after-open guard so pre-existing content is never mass-tagged. Keeping
|
||||||
|
// this here — rather than in the shell — means the fiddly baseline/diff logic is
|
||||||
|
// unit-tested, mirroring how view_tree splits the folder-depth walk out of view.cpp.
|
||||||
|
//
|
||||||
|
// The shell then hands the "new since last tick" GUIDs to the pure autoTagNewContent
|
||||||
|
// (view_mode_model) to produce the membership writes.
|
||||||
|
|
||||||
|
#include <set>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
// The GUIDs present in `current` but absent from `previous` — i.e. new since the
|
||||||
|
// previous poll. Order is the set's ascending order (deterministic; the caller does
|
||||||
|
// not depend on discovery order). Empty GUIDs are ignored (a GUID read failure at the
|
||||||
|
// shell boundary must never be tagged).
|
||||||
|
std::vector<std::string> newGuids(const std::set<std::string>& previous,
|
||||||
|
const std::set<std::string>& current);
|
||||||
|
|
||||||
|
// Tracks the live GUID set across polls for ONE project, implementing the
|
||||||
|
// first-poll-after-open guard: the first observation after a (re)start establishes a
|
||||||
|
// BASELINE and reports NOTHING new, so pre-existing content stays at its default
|
||||||
|
// (Arrange) rather than being mass-tagged. Every subsequent observe() returns only the
|
||||||
|
// GUIDs created since the prior observe().
|
||||||
|
//
|
||||||
|
// Project switches are handled by reset(): the shell detects a project change (the
|
||||||
|
// active ReaProject* / project GUID changed) and calls reset() so the next observe()
|
||||||
|
// re-baselines against the newly-opened project instead of diffing across two
|
||||||
|
// unrelated projects (which would spuriously "detect" the entire new project as new
|
||||||
|
// content, or miss content because a same-GUID collision looked pre-existing).
|
||||||
|
class GuidBaseline {
|
||||||
|
public:
|
||||||
|
// Observes the current live GUID set. On the FIRST call after construction or
|
||||||
|
// reset() this records the baseline and returns {} (nothing is "new" at open).
|
||||||
|
// On every later call it returns the GUIDs added since the previous call and
|
||||||
|
// advances the baseline to `current`. Empty GUIDs are ignored.
|
||||||
|
std::vector<std::string> observe(const std::set<std::string>& current);
|
||||||
|
|
||||||
|
// Re-arms the first-poll guard: the next observe() re-baselines and reports
|
||||||
|
// nothing new. Called on a project switch so detection never diffs across
|
||||||
|
// projects.
|
||||||
|
void reset();
|
||||||
|
|
||||||
|
// True until the first observe() after construction/reset — exposed for the shell
|
||||||
|
// to reason about (and for tests) about whether a baseline is established yet.
|
||||||
|
bool primed() const { return primed_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::set<std::string> baseline_;
|
||||||
|
bool primed_ = false; // false ⇒ next observe() sets the baseline
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// item_read.cpp — the single MediaItem* read seam (GUID + fixed-lane name). See
|
||||||
|
// item_read.h. Compiled into the reaper_reasampler MODULE; includes
|
||||||
|
// reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT (main.cpp is the one TU that
|
||||||
|
// defines the API pointers — CLAUDE.md §contract).
|
||||||
|
|
||||||
|
#include "item_read.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
#define REAPERAPI_MINIMAL
|
||||||
|
#define REAPERAPI_WANT_GetSetMediaItemInfo_String
|
||||||
|
#define REAPERAPI_WANT_GetMediaItemInfo_Value
|
||||||
|
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
|
||||||
|
#include "reaper_plugin_functions.h"
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
std::string itemGuid(MediaItem* it) {
|
||||||
|
char buf[64] = {0};
|
||||||
|
if (!GetSetMediaItemInfo_String(it, "GUID", buf, false)) return {};
|
||||||
|
return std::string(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string itemLaneName(MediaTrack* tr, MediaItem* it) {
|
||||||
|
const int laneIdx = static_cast<int>(GetMediaItemInfo_Value(it, "I_FIXEDLANE"));
|
||||||
|
char parm[32];
|
||||||
|
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
|
||||||
|
char buf[512] = {0};
|
||||||
|
if (!GetSetMediaTrackInfo_String(tr, parm, buf, false)) return {};
|
||||||
|
return std::string(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#pragma once
|
||||||
|
// item_read — the ONE place a MediaItem* is read for its canonical GUID string and for
|
||||||
|
// the durable P_LANENAME of the fixed lane it sits on. Before this seam, view.cpp and
|
||||||
|
// bank_panel.cpp each carried a near-identical private itemGuid / itemLaneName pair
|
||||||
|
// (both files' comments acknowledged the deliberate copy); the D2 Wave-3-B item actions
|
||||||
|
// need the same two reads, so the duplication is extracted here — the item-read analog
|
||||||
|
// of track_guid's single MediaTrack* -> GUID-key formatter.
|
||||||
|
//
|
||||||
|
// REAPER-facing shell: the .cpp includes reaper_plugin_functions.h WITHOUT
|
||||||
|
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers; here they are extern —
|
||||||
|
// CLAUDE.md §contract). MediaItem / MediaTrack are forward-declared so this header
|
||||||
|
// stays SDK-lite. These are shell reads (REAPER string/value getters); the managed/
|
||||||
|
// manual DECISION that consumes the lane name stays pure in lane_keys (isOnManualLane).
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
class MediaItem;
|
||||||
|
class MediaTrack;
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
// An item's canonical GUID string via GetSetMediaItemInfo_String("GUID"). Empty on a
|
||||||
|
// read failure (an empty GUID must never be tagged — every caller skips empties).
|
||||||
|
std::string itemGuid(MediaItem* it);
|
||||||
|
|
||||||
|
// The durable P_LANENAME of the fixed lane item `it` currently sits on (read via the
|
||||||
|
// item's I_FIXEDLANE ordinal, then P_LANENAME:n on `tr`). Empty if the lane is unnamed
|
||||||
|
// or the param is unavailable. Callers must already know `tr` is a fixed-lane track
|
||||||
|
// (I_FREEMODE==2) before calling — I_FIXEDLANE is meaningless otherwise; the pure
|
||||||
|
// isOnManualLane predicate handles the non-fixed-lane case via its own argument, so
|
||||||
|
// callers should not call this at all for a normal track.
|
||||||
|
std::string itemLaneName(MediaTrack* tr, MediaItem* it);
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// lane_keys implementation — pure string convention, no REAPER. See lane_keys.h.
|
||||||
|
|
||||||
|
#include "lane_keys.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Does `s` start with the managed-lane prefix?
|
||||||
|
bool hasManagedPrefix(const std::string& s) {
|
||||||
|
const std::size_t n = std::strlen(kManagedLanePrefix);
|
||||||
|
return s.size() >= n && s.compare(0, n, kManagedLanePrefix) == 0;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool isManagedLaneName(const std::string& laneName) {
|
||||||
|
return hasManagedPrefix(laneName);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::string> managedLaneKey(const std::string& laneName) {
|
||||||
|
if (!hasManagedPrefix(laneName)) return std::nullopt; // manual/unnamed ⇒ no key
|
||||||
|
// The durable name IS the key (stable across ordinal renumber). Keeping the full
|
||||||
|
// prefixed name — rather than stripping to the mode id — means the key is globally
|
||||||
|
// unambiguous and the ownership index's mode field remains the single source of
|
||||||
|
// truth for which mode owns the lane.
|
||||||
|
return laneName;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string laneNameForMode(const std::string& modeId) {
|
||||||
|
return std::string(kManagedLanePrefix) + modeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::string> modeIdFromLaneName(const std::string& laneName) {
|
||||||
|
if (!hasManagedPrefix(laneName)) return std::nullopt; // manual/unnamed ⇒ no mode
|
||||||
|
const std::size_t n = std::strlen(kManagedLanePrefix);
|
||||||
|
if (laneName.size() == n) return std::nullopt; // prefix only, no mode suffix (illegal)
|
||||||
|
return laneName.substr(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isOnManualLane(bool isFixedLaneTrack, const std::string& laneName) {
|
||||||
|
// On a normal (non-fixed-lane) track there is no concept of a manual lane; the
|
||||||
|
// item follows the normal auto-tag rule.
|
||||||
|
if (!isFixedLaneTrack) return false;
|
||||||
|
// On a fixed-lane track: a managed lane (prefixed) is NOT manual; everything else
|
||||||
|
// — including the empty/unnamed lane that REAPER creates by default — IS manual
|
||||||
|
// (user-minted, off-limits to auto-tag and to the lane-drive path).
|
||||||
|
return !hasManagedPrefix(laneName);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#pragma once
|
||||||
|
// lane_keys — the pure, REAPER-free convention that maps a REAPER fixed lane's
|
||||||
|
// durable NAME (P_LANENAME:n) to the opaque lane-key the pure view_mode_model uses,
|
||||||
|
// and the managed/manual heuristic that rides on it.
|
||||||
|
//
|
||||||
|
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL. std only.
|
||||||
|
// Unit-tested outside the DAW. The shell (view.cpp) reads each lane's P_LANENAME:n
|
||||||
|
// string from REAPER and asks this module whether the lane is tool-managed and what
|
||||||
|
// its stable lane-key is; the shell never re-derives the prefix rule itself.
|
||||||
|
//
|
||||||
|
// -- Design point #2 (lane-identity robustness) resolution --------------------
|
||||||
|
//
|
||||||
|
// REAPER exposes no durable per-lane GUID. The only lane identity is the ordinal
|
||||||
|
// I_FIXEDLANE, which REAPER RENUMBERS when lanes are reordered or deleted — so keying
|
||||||
|
// the ownership index by raw ordinal would silently corrupt managed/manual ownership
|
||||||
|
// on any reorder. REAPER DOES expose a writable, durable lane NAME (P_LANENAME:n) that
|
||||||
|
// travels with the lane across renumber. So the tool names each lane it mints with a
|
||||||
|
// stable, prefixed identity ("reasampler:<mode>") and keys the ownership index by that
|
||||||
|
// NAME, not the ordinal. On each apply the shell walks the track's lanes by current
|
||||||
|
// ordinal, reads each name, and reconciles ordinal<->laneKey — so a C_LANEPLAYS:N
|
||||||
|
// write always targets the lane's CURRENT ordinal for a given durable key even after a
|
||||||
|
// reorder. A lane WITHOUT the prefix was not minted by the tool: it is manual and
|
||||||
|
// off-limits (the fixed-lane analog of "never touch mute/solo").
|
||||||
|
//
|
||||||
|
// -- Design point #1 (manual-lane exemption) resolution -----------------------
|
||||||
|
//
|
||||||
|
// The SAME prefix rule is the manual/managed heuristic for auto-tag: an item on a lane
|
||||||
|
// whose name lacks the "reasampler:" prefix is on a manual lane and is EXEMPT from
|
||||||
|
// auto-tag. isManagedLaneName is the single predicate both the toggle-apply path and
|
||||||
|
// the new-content detection path consult, so the boundary is defined in one place and
|
||||||
|
// unit-tested.
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
// The prefix the tool stamps on every lane NAME it mints. A lane name carrying this
|
||||||
|
// prefix is a managed lane the tool created; any other name (or an empty/unnamed lane)
|
||||||
|
// is a user-minted manual lane. Stable-forever: changing it would strand the ownership
|
||||||
|
// of every lane in every already-saved project, so treat it like an action id string.
|
||||||
|
inline constexpr const char* kManagedLanePrefix = "reasampler:";
|
||||||
|
|
||||||
|
// True iff `laneName` is a tool-minted managed-lane name (carries kManagedLanePrefix).
|
||||||
|
// This is the load-bearing managed/manual predicate for BOTH design points #1 and #2.
|
||||||
|
bool isManagedLaneName(const std::string& laneName);
|
||||||
|
|
||||||
|
// The opaque lane-key the pure model keys by, for a lane with REAPER name `laneName`.
|
||||||
|
// For a managed lane the key IS the durable name (stable across ordinal renumber). For
|
||||||
|
// a manual/unnamed lane there is no managed key: returns std::nullopt so the caller
|
||||||
|
// treats the lane as manual (never driven, items on it exempt from auto-tag).
|
||||||
|
std::optional<std::string> managedLaneKey(const std::string& laneName);
|
||||||
|
|
||||||
|
// The lane NAME the tool mints for the lane owned by `modeId` (kManagedLanePrefix +
|
||||||
|
// modeId). The inverse of managedLaneKey for a managed lane: managedLaneKey(
|
||||||
|
// laneNameForMode(m)) == kManagedLanePrefix + m. Exposed for the Wave-3 lane-minting
|
||||||
|
// path and for tests; the apply path in this wave only READS names, but the round-trip
|
||||||
|
// contract is asserted here so minting and reading cannot drift.
|
||||||
|
std::string laneNameForMode(const std::string& modeId);
|
||||||
|
|
||||||
|
// The owning mode id encoded in a managed lane NAME — the suffix after the managed
|
||||||
|
// prefix. std::nullopt for a manual/unnamed lane (no managed prefix) or a name that is
|
||||||
|
// EXACTLY the prefix with no mode suffix (illegal — a managed lane always names a mode).
|
||||||
|
// The exact inverse of laneNameForMode: modeIdFromLaneName(laneNameForMode(m)) == m.
|
||||||
|
// Used by the load-time reconcile to recover managed ownership from REAPER's durable
|
||||||
|
// lane name (the source of truth for identity across sessions — design point #2).
|
||||||
|
std::optional<std::string> modeIdFromLaneName(const std::string& laneName);
|
||||||
|
|
||||||
|
// True iff an item on a fixed-lane track with the given lane name is on a MANUAL lane
|
||||||
|
// (i.e. exempt from auto-tag). The two inputs are:
|
||||||
|
// isFixedLaneTrack — whether the item's track has I_FREEMODE==2. On a normal
|
||||||
|
// (non-fixed-lane) track the concept of a "manual lane" does not
|
||||||
|
// apply; the item follows the normal auto-tag rule (return false).
|
||||||
|
// laneName — the durable P_LANENAME of the lane the item sits on. A lane
|
||||||
|
// that carries kManagedLanePrefix is a tool-minted managed lane
|
||||||
|
// (not manual); any other name — including empty (unnamed) — is
|
||||||
|
// a user-minted manual lane (exempt from auto-tag).
|
||||||
|
//
|
||||||
|
// This is the SINGLE predicate that governs BOTH the apply path (which lanes may be
|
||||||
|
// driven) and the auto-tag exemption path (which items are exempt). It is unit-tested
|
||||||
|
// here so both paths share exactly one definition; the shell supplies the two REAPER
|
||||||
|
// inputs (I_FREEMODE result, P_LANENAME string) and never re-derives this logic.
|
||||||
|
bool isOnManualLane(bool isFixedLaneTrack, const std::string& laneName);
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
+33
-5
@@ -218,8 +218,22 @@ static void OnTimer()
|
|||||||
// project saved in Design mode parks the Arrange tracks automatically, no manual
|
// project saved in Design mode parks the Arrange tracks automatically, no manual
|
||||||
// toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks
|
// toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks
|
||||||
// skip it. proj = nullptr -> REAPER's active project (the one poll just loaded).
|
// skip it. proj = nullptr -> REAPER's active project (the one poll just loaded).
|
||||||
if (g_session.consumeLoadSignal())
|
//
|
||||||
|
// 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);
|
reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
// Reflect a live bank change (capture / project load) in the docked grid.
|
// Reflect a live bank change (capture / project load) in the docked grid.
|
||||||
// Cheap when the bank is unchanged (a fingerprint compare); repaints only on
|
// Cheap when the bank is unchanged (a fingerprint compare); repaints only on
|
||||||
@@ -600,13 +614,20 @@ static void RunCaptureRealtimeTrack()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tail mode is the SAME panel setting the offline capture actions read (the
|
||||||
|
// docked bank panel's toggle). Realtime honors it via a parallel path: the backend
|
||||||
|
// records a generous window past the range end, then trims by PCM decay-scan (T2 /
|
||||||
|
// capture-tail.md §The realtime path) — it does NOT drive RENDER_*. Default None
|
||||||
|
// keeps realtime exact-bounds / byte-identical to today.
|
||||||
|
const reasampler::TailSetting tail = reasampler::bankPanelTailSetting();
|
||||||
|
|
||||||
reasampler::CaptureRequest req;
|
reasampler::CaptureRequest req;
|
||||||
req.sourceMode = reasampler::SourceMode::SelectedTracks; // realtime track scope
|
req.sourceMode = reasampler::SourceMode::SelectedTracks; // realtime track scope
|
||||||
req.startSeconds = src.startSeconds; // exact bounds — no rounding
|
req.startSeconds = src.startSeconds; // exact bounds — no rounding
|
||||||
req.endSeconds = src.endSeconds;
|
req.endSeconds = src.endSeconds;
|
||||||
req.wetDry = 1.0; // fully wet (post-fader tap)
|
req.wetDry = 1.0; // fully wet (post-fader tap)
|
||||||
req.tailMode = reasampler::TailMode::None; // realtime tail is T2; exact bounds here
|
req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle
|
||||||
req.tailMs = 0.0;
|
req.tailMs = tail.manualMs; // Manual-only (pre-clamped); ignored for None/Auto
|
||||||
req.sampleRate = 0; // follow project rate
|
req.sampleRate = 0; // follow project rate
|
||||||
req.channelCount = 2;
|
req.channelCount = 2;
|
||||||
req.bitDepth = reasampler::WavBitDepth::Float32;
|
req.bitDepth = reasampler::WavBitDepth::Float32;
|
||||||
@@ -627,8 +648,15 @@ static void RunCaptureRealtimeTrack()
|
|||||||
// completion across ticks (UI stays responsive).
|
// completion across ticks (UI stays responsive).
|
||||||
g_rtCaptureProject = EnumProjects(-1, nullptr, 0);
|
g_rtCaptureProject = EnumProjects(-1, nullptr, 0);
|
||||||
g_rtCapture = std::move(st);
|
g_rtCapture = std::move(st);
|
||||||
ShowConsoleMsg("ReaSampler: realtime capture started — recording in the "
|
// With a tail mode the recorded window runs PAST the range end (Auto: +8 s then
|
||||||
"background; the bank updates when it reaches the range end.\n");
|
// decay-trim; Manual: +the set length), so the completion note names the window,
|
||||||
|
// not just the range end.
|
||||||
|
const char* doneWhen =
|
||||||
|
(tail.mode == reasampler::TailMode::None)
|
||||||
|
? "the bank updates when it reaches the range end."
|
||||||
|
: "the bank updates after the extra tail window (past the range end).";
|
||||||
|
ShowConsoleMsg((std::string("ReaSampler: realtime capture started — recording in "
|
||||||
|
"the background; ") + doneWhen + "\n").c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancels the in-flight realtime capture on demand (bindable action). Force-terminates
|
// Cancels the in-flight realtime capture on demand (bindable action). Force-terminates
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <climits>
|
#include <climits>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
// peaks implementation.
|
// peaks implementation.
|
||||||
//
|
//
|
||||||
@@ -63,4 +64,31 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
|||||||
return envelope;
|
return envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
|
||||||
|
std::size_t channelCount,
|
||||||
|
std::size_t frameCount,
|
||||||
|
AudioSample linearThreshold) {
|
||||||
|
if (channelCount == 0) return kNoFrameAboveThreshold;
|
||||||
|
|
||||||
|
// Clamp to what the buffer actually holds — a caller frameCount that overstates
|
||||||
|
// the buffer must never read past the end (mirror of computeEnvelope's guard).
|
||||||
|
const std::size_t availableFrames = interleaved.size() / channelCount;
|
||||||
|
const std::size_t frames = std::min(frameCount, availableFrames);
|
||||||
|
if (frames == 0) return kNoFrameAboveThreshold;
|
||||||
|
|
||||||
|
// Scan backward: the first frame (from the end) whose loudest channel exceeds the
|
||||||
|
// threshold is the last audible frame. `f` runs frames..1 so `f-1` never wraps.
|
||||||
|
for (std::size_t f = frames; f > 0; --f) {
|
||||||
|
const std::size_t frame = f - 1;
|
||||||
|
const std::size_t base = frame * channelCount;
|
||||||
|
AudioSample peak = 0.0f;
|
||||||
|
for (std::size_t c = 0; c < channelCount; ++c) {
|
||||||
|
const AudioSample a = std::fabs(interleaved[base + c]);
|
||||||
|
peak = std::max(peak, a);
|
||||||
|
}
|
||||||
|
if (peak > linearThreshold) return frame;
|
||||||
|
}
|
||||||
|
return kNoFrameAboveThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
+38
@@ -66,4 +66,42 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
|||||||
std::size_t frameCount,
|
std::size_t frameCount,
|
||||||
std::size_t binCount);
|
std::size_t binCount);
|
||||||
|
|
||||||
|
// Sentinel returned by lastFrameAboveThreshold when NO frame in the scanned range
|
||||||
|
// peaks above the threshold (pure silence at that level). SIZE_MAX is unambiguous:
|
||||||
|
// no valid frame index can equal it (a real index is < frameCount <= SIZE_MAX for
|
||||||
|
// any allocatable buffer), so the caller tests `== kNoFrameAboveThreshold` cleanly.
|
||||||
|
inline constexpr std::size_t kNoFrameAboveThreshold =
|
||||||
|
static_cast<std::size_t>(-1);
|
||||||
|
|
||||||
|
// Scans interleaved PCM BACKWARD for the last frame whose per-frame peak (the max
|
||||||
|
// absolute value across all channels of that frame — NO stereo fold, just the
|
||||||
|
// loudest channel that frame) exceeds `linearThreshold`, returning that frame index.
|
||||||
|
// Returns kNoFrameAboveThreshold if no frame exceeds it (or on degenerate input).
|
||||||
|
//
|
||||||
|
// This is the boundary primitive behind the realtime tail's decay-scan trim
|
||||||
|
// (docs/product/capture-tail.md §The realtime path): the recorded tail window is
|
||||||
|
// scanned back from the end for the last frame still above -72 dB, and the file is
|
||||||
|
// truncated one frame past it. Deliberately a separate primitive from
|
||||||
|
// computeEnvelope — that answers "the min/max envelope over bins" (a thumbnail),
|
||||||
|
// this answers "the last frame above a level" (a boundary). Bending the bin-oriented
|
||||||
|
// envelope to a frame-exact boundary question is a worse fit (spec §option a).
|
||||||
|
//
|
||||||
|
// interleaved frame-interleaved samples: [f0c0, f0c1, ..., f1c0, f1c1, ...].
|
||||||
|
// Must hold >= frameCount * channelCount; extra is ignored, and a
|
||||||
|
// short buffer is clamped to what it actually holds (no OOB read).
|
||||||
|
// channelCount channels per frame (the stride). The per-frame test is the max
|
||||||
|
// |sample| over these channels — the frame is "above" if its
|
||||||
|
// loudest channel is above the threshold.
|
||||||
|
// frameCount frames to consider (the scan starts at the last of these).
|
||||||
|
// linearThreshold the comparison level as a LINEAR amplitude ratio (e.g. the
|
||||||
|
// -72 dB ratio from render_settings::autoTrimEndRatio), NOT dB.
|
||||||
|
// A frame counts as above when its peak is STRICTLY > this.
|
||||||
|
//
|
||||||
|
// Pure, stdlib-only, unit-tested (a synthetic decaying ramp, silence, all-above,
|
||||||
|
// and degenerate inputs) so the trim boundary math is locked outside the DAW.
|
||||||
|
std::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
|
||||||
|
std::size_t channelCount,
|
||||||
|
std::size_t frameCount,
|
||||||
|
AudioSample linearThreshold);
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
@@ -198,6 +198,13 @@ void ReaSamplerSession::saveToActiveProject() {
|
|||||||
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
|
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
|
||||||
kProjExtViewKey, viewJson.c_str());
|
kProjExtViewKey, viewJson.c_str());
|
||||||
|
|
||||||
|
// Additive: the docked panel's tail setting rides alongside in its own key, so the
|
||||||
|
// tail choice travels inside the .rpp. Independent write — does not disturb the
|
||||||
|
// bank_index or view_state above.
|
||||||
|
const std::string tailJson = serializeTailSetting(tail_);
|
||||||
|
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
|
||||||
|
kProjExtTailKey, tailJson.c_str());
|
||||||
|
|
||||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +229,23 @@ ViewModeModel loadViewModel(ReaProject* proj) {
|
|||||||
return std::move(*loaded);
|
return std::move(*loaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load the tail setting from a project's tail_setting key, or return the default. An
|
||||||
|
// absent/empty key (older / never-adjusted project) yields the default setting (None /
|
||||||
|
// 2 s manual) — graceful, never a crash. Malformed JSON is warned and also falls back
|
||||||
|
// to default, mirroring the bank's and view's malformed handling.
|
||||||
|
TailSetting loadTailSetting(ReaProject* proj) {
|
||||||
|
if (!proj) return TailSetting{};
|
||||||
|
const std::string tailJson =
|
||||||
|
getProjExtStateString(proj, kProjExtNamespace, kProjExtTailKey);
|
||||||
|
if (tailJson.empty()) return TailSetting{}; // no stored setting -> default
|
||||||
|
std::optional<TailSetting> loaded = deserializeTailSetting(tailJson);
|
||||||
|
if (!loaded) {
|
||||||
|
ShowConsoleMsg("ReaSampler: stored tail setting is malformed — ignoring.\n");
|
||||||
|
return TailSetting{};
|
||||||
|
}
|
||||||
|
return *loaded;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) {
|
void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) {
|
||||||
@@ -239,6 +263,11 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi
|
|||||||
// only — no visibility/processing is applied here (that is D4).
|
// only — no visibility/processing is applied here (that is D4).
|
||||||
view_ = loadViewModel(static_cast<ReaProject*>(proj));
|
view_ = loadViewModel(static_cast<ReaProject*>(proj));
|
||||||
|
|
||||||
|
// The tail setting is restored on EVERY load path too (peer-symmetry): switching
|
||||||
|
// to a project with no stored setting must fall back to the default, not inherit
|
||||||
|
// the previous project's choice (this REPLACES the old session-carry behavior).
|
||||||
|
tail_ = loadTailSetting(static_cast<ReaProject*>(proj));
|
||||||
|
|
||||||
if (!proj) {
|
if (!proj) {
|
||||||
book_ = BankBook{};
|
book_ = BankBook{};
|
||||||
return;
|
return;
|
||||||
|
|||||||
+26
-4
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
#include "bank_book.h"
|
#include "bank_book.h"
|
||||||
#include "bank_model.h"
|
#include "bank_model.h"
|
||||||
|
#include "tail_control.h"
|
||||||
#include "view_mode_model.h"
|
#include "view_mode_model.h"
|
||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
@@ -50,6 +51,13 @@ inline constexpr const char* kProjExtBanksKey = "banks";
|
|||||||
// FOREVER-STABLE: changing it orphans every already-saved project's view state.
|
// FOREVER-STABLE: changing it orphans every already-saved project's view state.
|
||||||
inline constexpr const char* kProjExtViewKey = "view_state";
|
inline constexpr const char* kProjExtViewKey = "view_state";
|
||||||
|
|
||||||
|
// The ext-state key the docked panel's TailSetting JSON (mode + manualMs) is stored
|
||||||
|
// under, so the tail choice travels inside the .rpp and loads per project. Distinct
|
||||||
|
// from the index/view keys — one namespace, three keys. FOREVER-STABLE: changing it
|
||||||
|
// orphans every already-saved project's tail setting (which then falls back to the
|
||||||
|
// default — graceful, but the user's saved choice would be lost).
|
||||||
|
inline constexpr const char* kProjExtTailKey = "tail_setting";
|
||||||
|
|
||||||
// The ext-state key holding a GUID we mint per project to establish CONTENT-BASED
|
// The ext-state key holding a GUID we mint per project to establish CONTENT-BASED
|
||||||
// project identity (REAPER exposes no stable per-project GUID). poll() uses it to
|
// project identity (REAPER exposes no stable per-project GUID). poll() uses it to
|
||||||
// tell a genuine Save-As (same GUID, new .rpp path) apart from a project switch
|
// tell a genuine Save-As (same GUID, new .rpp path) apart from a project switch
|
||||||
@@ -105,10 +113,19 @@ public:
|
|||||||
ViewModeModel& view() { return view_; }
|
ViewModeModel& view() { return view_; }
|
||||||
const ViewModeModel& view() const { return view_; }
|
const ViewModeModel& view() const { return view_; }
|
||||||
|
|
||||||
// Serialize the current book (under the `banks` key) and view model to the active
|
// The docked panel's tail setting (mode + manualMs), authoritative here — NOT in
|
||||||
// project's ext state (namespace "reasampler"), and clear the retired legacy
|
// panel state — so it travels inside the .rpp: persist serializes it on save and
|
||||||
// `bank_index` key. Non-destructive beyond writing our own ext-state keys. Safe
|
// replaces it on project load exactly as it treats the bank and view model. The
|
||||||
// to call when there is no active/saved project (it no-ops).
|
// panel reads/writes it through this seam (bank_panel holds the session), and the
|
||||||
|
// capture actions read it via bankPanelTailSetting. Default None / 2 s manual for
|
||||||
|
// an unsaved or pre-feature project (no stored key -> this default survives load).
|
||||||
|
TailSetting& tail() { return tail_; }
|
||||||
|
const TailSetting& tail() const { return tail_; }
|
||||||
|
|
||||||
|
// Serialize the current book (under the `banks` key), view model, and tail setting
|
||||||
|
// to the active project's ext state (namespace "reasampler"), and clear the retired
|
||||||
|
// legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys.
|
||||||
|
// Safe to call when there is no active/saved project (it no-ops).
|
||||||
void saveToActiveProject();
|
void saveToActiveProject();
|
||||||
|
|
||||||
// Poll the active project. Detects a project load (active project changed)
|
// Poll the active project. Detects a project load (active project changed)
|
||||||
@@ -136,6 +153,11 @@ private:
|
|||||||
// view_state (older project), so an absent key is graceful, not a crash.
|
// view_state (older project), so an absent key is graceful, not a crash.
|
||||||
ViewModeModel view_;
|
ViewModeModel view_;
|
||||||
|
|
||||||
|
// The tail setting. Default None / kDefaultManualTailMs; loadFromProject resets it
|
||||||
|
// to this default when a project has no stored tail_setting key (older / never-
|
||||||
|
// adjusted project), so an absent key is graceful. Peer to bank_/view_.
|
||||||
|
TailSetting tail_;
|
||||||
|
|
||||||
// The project identity last observed by poll(), used to detect load/Save-As.
|
// The project identity last observed by poll(), used to detect load/Save-As.
|
||||||
// The GUID is the PRIMARY signal (a different stored GUID = a different project
|
// The GUID is the PRIMARY signal (a different stored GUID = a different project
|
||||||
// of record = Load, immune to pointer recycling). The pointer disambiguates the
|
// of record = Load, immune to pointer recycling). The pointer disambiguates the
|
||||||
|
|||||||
@@ -56,6 +56,24 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
|
|||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
|
||||||
|
double manualTailMs) {
|
||||||
|
switch (mode) {
|
||||||
|
case TailMode::None:
|
||||||
|
// Exact — no extra recording (byte-identical to today's realtime capture).
|
||||||
|
return rangeEndSeconds;
|
||||||
|
case TailMode::Auto:
|
||||||
|
// The 8 s runaway cap past the range end; the decay-trim shortens it later.
|
||||||
|
return rangeEndSeconds + kMaxTailSeconds;
|
||||||
|
case TailMode::Manual:
|
||||||
|
// Fixed window: range + the set length, clamped to the 8 s cap (the same
|
||||||
|
// runaway guard the offline Manual path applies). Negative floors to 0.
|
||||||
|
return rangeEndSeconds + std::clamp(manualTailMs, 0.0, kMaxTailMs) / 1000.0;
|
||||||
|
}
|
||||||
|
// Unreachable for a valid enum; fail closed to exact bounds (never a stray tail).
|
||||||
|
return rangeEndSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
||||||
// `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future
|
// `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future
|
||||||
// dry work (M10 null test), but it does not affect this mapping. FX scoping is
|
// dry work (M10 null test), but it does not affect this mapping. FX scoping is
|
||||||
|
|||||||
@@ -114,6 +114,20 @@ struct TailRenderSettings {
|
|||||||
// the Auto default or an explicit request (spec §Manual override). Pure + tested.
|
// the Auto default or an explicit request (spec §Manual override). Pure + tested.
|
||||||
TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs);
|
TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs);
|
||||||
|
|
||||||
|
// The REALTIME record-window end (in project seconds) a tail mode records to, given
|
||||||
|
// the request's exact range end (docs/product/capture-tail.md §The realtime path).
|
||||||
|
// Realtime does NOT drive RENDER_*; it records a generous window and trims later, so
|
||||||
|
// the window end is where the transport actually stops:
|
||||||
|
// None -> rangeEndSeconds (exact — no extra recording).
|
||||||
|
// Auto -> rangeEndSeconds + kMaxTailSeconds (the 8 s runaway cap; trimmed later).
|
||||||
|
// Manual -> rangeEndSeconds + clamp(manualTailMs, kMaxTailMs)/1000 (fixed, no trim).
|
||||||
|
// `manualTailMs` is used ONLY for Manual. Pure so the mode->window arithmetic (and
|
||||||
|
// the Manual clamp) is unit-tested outside the DAW; the backend applies the returned
|
||||||
|
// end to the record time selection. Shared -72 dB / 8 s constants are the same ones
|
||||||
|
// the offline tail uses (single source of truth).
|
||||||
|
double realtimeRecordWindowEnd(TailMode mode, double rangeEndSeconds,
|
||||||
|
double manualTailMs);
|
||||||
|
|
||||||
// The RENDER_SETTINGS value for a given source mode. `supported` is false only
|
// The RENDER_SETTINGS value for a given source mode. `supported` is false only
|
||||||
// for SourceMode::Realtime (that is the M8 backend, not offline render).
|
// for SourceMode::Realtime (that is the M8 backend, not offline render).
|
||||||
struct RenderSettingsChoice {
|
struct RenderSettingsChoice {
|
||||||
|
|||||||
+100
-1
@@ -3,6 +3,10 @@
|
|||||||
#include "tail_control.h"
|
#include "tail_control.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
|
|
||||||
@@ -21,13 +25,108 @@ double clampManualMs(double manualMs) {
|
|||||||
return std::clamp(manualMs, 0.0, kMaxTailMs);
|
return std::clamp(manualMs, 0.0, kMaxTailMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double adjustManualMs(double current, int notches, double stepMs) {
|
||||||
|
// Clamp the stepped value so both scroll directions saturate at the bounds rather
|
||||||
|
// than running away (the same [0, kMaxTailMs] guard clampManualMs enforces).
|
||||||
|
return clampManualMs(current + notches * stepMs);
|
||||||
|
}
|
||||||
|
|
||||||
std::string tailToggleLabel(const TailSetting& setting) {
|
std::string tailToggleLabel(const TailSetting& setting) {
|
||||||
switch (setting.mode) {
|
switch (setting.mode) {
|
||||||
case TailMode::None: return "Tail: Off";
|
case TailMode::None: return "Tail: Off";
|
||||||
case TailMode::Auto: return "Tail: Auto";
|
case TailMode::Auto: return "Tail: Auto";
|
||||||
case TailMode::Manual: return "Tail: Manual";
|
case TailMode::Manual: {
|
||||||
|
// Append the CLAMPED length in seconds to one decimal so the readout can
|
||||||
|
// never show an over-cap value even if manualMs was stored past the cap.
|
||||||
|
const double seconds = clampManualMs(setting.manualMs) / 1000.0;
|
||||||
|
char buf[32];
|
||||||
|
std::snprintf(buf, sizeof(buf), "Tail: Manual %.1fs", seconds);
|
||||||
|
return std::string(buf);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return "Tail: Off"; // unreachable for a valid enum; fail to the safe default
|
return "Tail: Off"; // unreachable for a valid enum; fail to the safe default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JSON round-trip
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// The persisted integer for a mode. Stable forever (stored in the .rpp): never
|
||||||
|
// renumber these values or an already-saved project reads back the wrong mode.
|
||||||
|
int modeToInt(TailMode m) {
|
||||||
|
switch (m) {
|
||||||
|
case TailMode::None: return 0;
|
||||||
|
case TailMode::Auto: return 1;
|
||||||
|
case TailMode::Manual: return 2;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<TailMode> modeFromInt(int v) {
|
||||||
|
switch (v) {
|
||||||
|
case 0: return TailMode::None;
|
||||||
|
case 1: return TailMode::Auto;
|
||||||
|
case 2: return TailMode::Manual;
|
||||||
|
default: return std::nullopt; // unknown enumerant -> malformed -> default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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));
|
||||||
|
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
|
||||||
|
|||||||
+26
-5
@@ -9,6 +9,7 @@
|
|||||||
// only (plus render_settings for the pure TailMode enum). Builds and unit-tests
|
// only (plus render_settings for the pure TailMode enum). Builds and unit-tests
|
||||||
// without REAPER.
|
// without REAPER.
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "render_settings.h" // TailMode (pure enum) — the three-state tail contract
|
#include "render_settings.h" // TailMode (pure enum) — the three-state tail contract
|
||||||
@@ -16,10 +17,15 @@
|
|||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
|
|
||||||
// The Manual-mode starting length. 2 s is a musically useful default tail (a bar of
|
// 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. A fine-adjust UI
|
// reverb throw at a moderate tempo) that is well under the 8 s cap. Also the value a
|
||||||
// (+/- click zones or scroll) is a noted follow-on; this pass ships a fixed default.
|
// project with no stored tail setting (older / never-adjusted) falls back to on load.
|
||||||
inline constexpr double kDefaultManualTailMs = 2000.0;
|
inline constexpr double kDefaultManualTailMs = 2000.0;
|
||||||
|
|
||||||
|
// The fine-adjust step per scroll-wheel notch in Manual mode. 250 ms is coarse enough
|
||||||
|
// that a few notches cover the useful range, fine enough to dial a length precisely.
|
||||||
|
// Daniel-set. The panel maps one wheel notch to +/- this many ms via adjustManualMs.
|
||||||
|
inline constexpr double kManualStepMs = 250.0;
|
||||||
|
|
||||||
// The panel's current tail setting: the mode plus the length used ONLY when the
|
// 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 (bank_panel.cpp), default
|
||||||
// None so a capture with no explicit choice stays exact-bounds / byte-identical to
|
// None so a capture with no explicit choice stays exact-bounds / byte-identical to
|
||||||
@@ -41,9 +47,24 @@ TailMode cycleTailMode(TailMode current);
|
|||||||
// tailMs into the CaptureRequest. Meaningful only for TailMode::Manual.
|
// tailMs into the CaptureRequest. Meaningful only for TailMode::Manual.
|
||||||
double clampManualMs(double manualMs);
|
double clampManualMs(double manualMs);
|
||||||
|
|
||||||
// The toggle's label for a setting, e.g. "Tail: Off", "Tail: Auto", "Tail: Manual".
|
// Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped to
|
||||||
// (Manual omits the length here — the panel is unobtrusive; a length readout can be
|
// [0, kMaxTailMs]. Positive notches lengthen, negative shorten. Pure so the fine-adjust
|
||||||
// added with the fine-adjust follow-on.) Pure so the exact strings are test-pinned.
|
// arithmetic (and its clamp at both bounds) is unit-tested; the panel wheel handler
|
||||||
|
// owns no arithmetic of its own. Meaningful only for TailMode::Manual.
|
||||||
|
double adjustManualMs(double current, int notches, double stepMs);
|
||||||
|
|
||||||
|
// The toggle's label for a setting, e.g. "Tail: Off", "Tail: Auto". In Manual mode the
|
||||||
|
// clamped length is appended in seconds to one decimal, e.g. "Tail: Manual 2.0s" —
|
||||||
|
// Off/Auto carry no length. Pure so the exact strings (and the Manual format) are
|
||||||
|
// test-pinned, including the boundary lengths (0.0s, 8.0s).
|
||||||
std::string tailToggleLabel(const TailSetting& setting);
|
std::string tailToggleLabel(const TailSetting& setting);
|
||||||
|
|
||||||
|
// JSON round-trip of a TailSetting (mode + manualMs), for persist to store the tail
|
||||||
|
// setting per-project alongside the bank and view model. Kept pure/testable here —
|
||||||
|
// the natural home, mirroring bank_model's serialize/deserialize. serialize emits a
|
||||||
|
// compact object; deserialize returns std::nullopt on malformed input so the caller
|
||||||
|
// (persist) falls back to a default setting, exactly as an absent key does.
|
||||||
|
std::string serializeTailSetting(const TailSetting& setting);
|
||||||
|
std::optional<TailSetting> deserializeTailSetting(const std::string& json);
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
+442
@@ -10,10 +10,16 @@
|
|||||||
|
|
||||||
#include "view.h"
|
#include "view.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <map>
|
||||||
|
#include <optional>
|
||||||
#include <set>
|
#include <set>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "item_read.h"
|
||||||
|
#include "lane_keys.h"
|
||||||
#include "track_guid.h"
|
#include "track_guid.h"
|
||||||
#include "view_tree.h"
|
#include "view_tree.h"
|
||||||
|
|
||||||
@@ -22,6 +28,7 @@
|
|||||||
#define REAPERAPI_WANT_GetTrack
|
#define REAPERAPI_WANT_GetTrack
|
||||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||||
|
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
|
||||||
#define REAPERAPI_WANT_TrackFX_GetCount
|
#define REAPERAPI_WANT_TrackFX_GetCount
|
||||||
#define REAPERAPI_WANT_TrackFX_GetOffline
|
#define REAPERAPI_WANT_TrackFX_GetOffline
|
||||||
#define REAPERAPI_WANT_TrackFX_SetOffline
|
#define REAPERAPI_WANT_TrackFX_SetOffline
|
||||||
@@ -29,12 +36,56 @@
|
|||||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||||
#define REAPERAPI_WANT_TrackList_AdjustWindows
|
#define REAPERAPI_WANT_TrackList_AdjustWindows
|
||||||
#define REAPERAPI_WANT_UpdateArrange
|
#define REAPERAPI_WANT_UpdateArrange
|
||||||
|
#define REAPERAPI_WANT_UpdateTimeline
|
||||||
|
// Lane minting (D2 Wave 3): enumerate a track's items and read/write item-side lane
|
||||||
|
// state to assign each item to its mode's managed lane.
|
||||||
|
#define REAPERAPI_WANT_CountTrackMediaItems
|
||||||
|
#define REAPERAPI_WANT_GetTrackMediaItem
|
||||||
|
#define REAPERAPI_WANT_GetMediaItemInfo_Value
|
||||||
|
#define REAPERAPI_WANT_SetMediaItemInfo_Value
|
||||||
#include "reaper_plugin_functions.h"
|
#include "reaper_plugin_functions.h"
|
||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
// Track fixed-lane mode value (I_FREEMODE=2). See SDK: 0=normal, 1=free item
|
||||||
|
// positioning, 2=fixed lanes.
|
||||||
|
constexpr int kFreeModeFixedLanes = 2;
|
||||||
|
|
||||||
|
// C_LANESCOLLAPSED display value (char*). SDK: 1=lanes collapsed,
|
||||||
|
// 2=track displays as non-fixed-lanes but hidden lanes exist. Value 2 is the lever that
|
||||||
|
// makes a tool-split track read like a NORMAL single-lane track showing only the playing
|
||||||
|
// lane — the inactive/silenced managed lanes are present but not drawn as separate rows.
|
||||||
|
constexpr int kLanesDisplayAsNormal = 2;
|
||||||
|
|
||||||
|
// C_LANESETTINGS bit (char* bitmask). SDK: &32=hide lane buttons. We OR this in (never
|
||||||
|
// clobber the whole mask) to strip the per-lane button chrome from a tool-split track, so
|
||||||
|
// it reads as an ordinary track. We deliberately do NOT set &1 (auto-remove empty lanes at
|
||||||
|
// bottom): a managed lane whose item is later deleted would be silently removed out from
|
||||||
|
// under the ownership index. The lazy-mint decision already avoids ever minting an empty
|
||||||
|
// lane, so &1 buys nothing and risks a reconcile hazard.
|
||||||
|
constexpr int kLaneSettingsHideButtons = 32;
|
||||||
|
|
||||||
|
// Drives a TOOL-SPLIT track's display transparent: C_LANESCOLLAPSED=2 (render like a normal
|
||||||
|
// single-lane track showing only the playing lane) + OR C_LANESETTINGS &32 (hide lane
|
||||||
|
// buttons). Both are char* params driven through the double API, same convention as
|
||||||
|
// C_LANEPLAYS:N. C_LANESETTINGS is read-modify-write so any pre-existing bit is preserved.
|
||||||
|
//
|
||||||
|
// MANAGED-VS-MANUAL BOUNDARY (load-bearing): these are TRACK-LEVEL settings that affect the
|
||||||
|
// whole track including a user's own manual comp lanes. Every caller gates this on the
|
||||||
|
// tool-driven transition INTO fixed lanes (freeMode != 2 before the flip), so a track the
|
||||||
|
// user already had in fixed-lane mode never reaches it and the user's comp-lane display
|
||||||
|
// prefs are never stomped. Idempotent: a re-run finds the track already at I_FREEMODE==2,
|
||||||
|
// the transition branch is skipped, and these writes do not fire again.
|
||||||
|
void applyTransparentLaneDisplay(MediaTrack* tr) {
|
||||||
|
SetMediaTrackInfo_Value(tr, "C_LANESCOLLAPSED",
|
||||||
|
static_cast<double>(kLanesDisplayAsNormal));
|
||||||
|
const int settings = static_cast<int>(GetMediaTrackInfo_Value(tr, "C_LANESETTINGS"));
|
||||||
|
SetMediaTrackInfo_Value(tr, "C_LANESETTINGS",
|
||||||
|
static_cast<double>(settings | kLaneSettingsHideButtons));
|
||||||
|
}
|
||||||
|
|
||||||
// The parmname for each planner Flag. All four are documented bool*/int* track
|
// The parmname for each planner Flag. All four are documented bool*/int* track
|
||||||
// info params driven through the double-valued Get/SetMediaTrackInfo_Value API.
|
// info params driven through the double-valued Get/SetMediaTrackInfo_Value API.
|
||||||
const char* flagParm(Flag f) {
|
const char* flagParm(Flag f) {
|
||||||
@@ -132,6 +183,278 @@ void restoreFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& fxOffline)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Managed-lane application (D2 Wave 2) ------------------------------------
|
||||||
|
//
|
||||||
|
// The pure planner emits LanePlayOps keyed by (trackGuid, laneKey) where laneKey is
|
||||||
|
// the lane's DURABLE name (lane_keys convention: "reasampler:<mode>"). REAPER's
|
||||||
|
// C_LANEPLAYS:N is keyed by the lane's CURRENT ORDINAL, which renumbers on reorder.
|
||||||
|
// So before applying, we build the ordinal<->key reconcile for a track by reading each
|
||||||
|
// lane's P_LANENAME:n; the write then targets the correct current ordinal for a given
|
||||||
|
// durable key even after a reorder (design point #2). A lane whose name lacks the
|
||||||
|
// managed prefix is manual and never appears in this map, so it can never be driven.
|
||||||
|
|
||||||
|
// Reads lane index `laneIdx`'s durable name off track `tr` (P_LANENAME:n). Empty if
|
||||||
|
// the lane is unnamed or the param is unavailable (non-fixed-lane track).
|
||||||
|
std::string laneName(MediaTrack* tr, int laneIdx) {
|
||||||
|
char parm[32];
|
||||||
|
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
|
||||||
|
char buf[512] = {0};
|
||||||
|
if (!GetSetMediaTrackInfo_String(tr, parm, buf, false)) return {};
|
||||||
|
return std::string(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps each MANAGED lane's durable key -> its current ordinal on `tr`, by walking the
|
||||||
|
// track's I_NUMFIXEDLANES lanes and reading each name. Manual (unprefixed/unnamed)
|
||||||
|
// lanes are omitted, so a key absent from the map is a lane the tool must not drive.
|
||||||
|
std::map<std::string, int> managedLaneOrdinals(MediaTrack* tr) {
|
||||||
|
std::map<std::string, int> byKey;
|
||||||
|
const int numLanes = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
|
||||||
|
for (int lane = 0; lane < numLanes; ++lane) {
|
||||||
|
std::optional<std::string> key = managedLaneKey(laneName(tr, lane));
|
||||||
|
if (key) byKey.emplace(*key, lane); // first ordinal wins if names collide
|
||||||
|
}
|
||||||
|
return byKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drives one managed lane on `tr` to `lanePlays` (C_LANEPLAYS value) via the
|
||||||
|
// TRACK-SIDE C_LANEPLAYS:N write. Track-side C_LANEPLAYS:N alone produces the
|
||||||
|
// hide+silence effect for all items on lane N — no per-item write is needed or
|
||||||
|
// possible (item-side C_LANEPLAYS is marked read-only in the SDK).
|
||||||
|
// B_FIXEDLANE_HIDDEN is READ-ONLY (SDK) — hide/show follows from C_LANEPLAYS=0/1,
|
||||||
|
// never written directly. Non-destructive: only reversible play/show flags; no item
|
||||||
|
// is moved or deleted.
|
||||||
|
//
|
||||||
|
// DAW-VERIFY: confirm that track-side C_LANEPLAYS:N alone hides+silences all items
|
||||||
|
// on lane N without a per-item write. (SDK marks item-side C_LANEPLAYS as read-only;
|
||||||
|
// the track-side write is the documented mechanism.)
|
||||||
|
void applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) {
|
||||||
|
char parm[32];
|
||||||
|
std::snprintf(parm, sizeof(parm), "C_LANEPLAYS:%d", laneIdx);
|
||||||
|
SetMediaTrackInfo_Value(tr, parm, static_cast<double>(lanePlays));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies the plan's managed-lane ops. Groups ops by track, resolves each op's durable
|
||||||
|
// laneKey to the track's current ordinal (skipping any key not present on the live
|
||||||
|
// track — a stale/renamed/deleted managed lane is pruned, never mis-driven), enables
|
||||||
|
// fixed-lane mode on any track that carries a managed lane, and drives C_LANEPLAYS.
|
||||||
|
// UpdateTimeline() is called ONCE at the end (SDK: required after I_FREEMODE changes).
|
||||||
|
// Returns true if any track's I_FREEMODE was (re)set to fixed lanes (⇒ needs timeline
|
||||||
|
// refresh). MANAGED lanes only — plan.lanes never contains a manual lane (pure planner
|
||||||
|
// gates on the ownership index), and a manual lane's name never resolves to a key here,
|
||||||
|
// so the invariant is enforced twice.
|
||||||
|
bool applyLaneOps(const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid,
|
||||||
|
const std::vector<LanePlayOp>& lanes) {
|
||||||
|
if (lanes.empty()) return false;
|
||||||
|
|
||||||
|
// Group op indices by track guid so we read each track's lane map once.
|
||||||
|
std::map<std::string, std::vector<const LanePlayOp*>> byTrack;
|
||||||
|
for (const LanePlayOp& op : lanes) byTrack[op.trackGuid].push_back(&op);
|
||||||
|
|
||||||
|
bool touchedFreeMode = false;
|
||||||
|
for (const auto& [guid, ops] : byTrack) {
|
||||||
|
MediaTrack* tr = resolve(handleByGuid, guid);
|
||||||
|
if (!tr) continue; // stale GUID — prune
|
||||||
|
|
||||||
|
// Ensure fixed-lane mode is on before driving lane play state. A track carrying
|
||||||
|
// a managed lane must be in I_FREEMODE=2; set it only if not already, and flag
|
||||||
|
// that a timeline refresh is owed. Every track reaching this loop is already in the
|
||||||
|
// managed-lane ownership index (planToggle only emits ops for managed lanes), so a
|
||||||
|
// track here is one the TOOL split — a re-assert of fixed-lane mode is a tool-driven
|
||||||
|
// (re)split and must carry the same transparent display, mirroring applyMintPlan's
|
||||||
|
// transition branch. It is never a user's untouched manual-fixed-lane track.
|
||||||
|
const int freeMode = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
|
||||||
|
if (freeMode != kFreeModeFixedLanes) {
|
||||||
|
SetMediaTrackInfo_Value(tr, "I_FREEMODE",
|
||||||
|
static_cast<double>(kFreeModeFixedLanes));
|
||||||
|
applyTransparentLaneDisplay(tr); // tool-managed track ⇒ read like a normal track
|
||||||
|
touchedFreeMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile durable keys -> current ordinals on THIS track, then drive each op.
|
||||||
|
const std::map<std::string, int> ordinals = managedLaneOrdinals(tr);
|
||||||
|
for (const LanePlayOp* op : ops) {
|
||||||
|
auto it = ordinals.find(op->laneKey);
|
||||||
|
if (it == ordinals.end()) continue; // key not live on this track — prune
|
||||||
|
applyLanePlays(tr, it->second, op->lanePlays);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return touchedFreeMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Managed-lane minting (D2 Wave 3) ----------------------------------------
|
||||||
|
//
|
||||||
|
// Mints one managed fixed lane per mode on any track that now holds content of MORE
|
||||||
|
// THAN ONE mode, and assigns each item to its mode's managed lane. The DECISION —
|
||||||
|
// which tracks split, which lanes to mint, which item goes where — is the pure
|
||||||
|
// planLaneMinting; this shell only reads live per-item mode+lane state, calls the
|
||||||
|
// decision, and applies the resulting REAPER + ownership-index writes.
|
||||||
|
|
||||||
|
// Item GUID + fixed-lane name reads come from the shared item_read seam (item_read.h):
|
||||||
|
// itemGuid(it) and itemLaneName(tr, it). view.cpp no longer carries its own copies.
|
||||||
|
|
||||||
|
// Maps every item GUID on `tr` to its MediaItem* handle, in one pass. The assign pass
|
||||||
|
// resolves plan item GUIDs back to handles through this map rather than re-scanning the
|
||||||
|
// track per item (avoids the quadratic that a per-item find would incur).
|
||||||
|
std::map<std::string, MediaItem*> itemHandlesByGuid(MediaTrack* tr) {
|
||||||
|
std::map<std::string, MediaItem*> byGuid;
|
||||||
|
const int itemCount = CountTrackMediaItems(tr);
|
||||||
|
for (int i = 0; i < itemCount; ++i) {
|
||||||
|
MediaItem* it = GetTrackMediaItem(tr, i);
|
||||||
|
if (!it) continue;
|
||||||
|
std::string ig = itemGuid(it);
|
||||||
|
if (!ig.empty()) byGuid.emplace(std::move(ig), it);
|
||||||
|
}
|
||||||
|
return byGuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves the mode one item's content belongs to, from the model's membership index.
|
||||||
|
// An item tagged into exactly one mode returns that mode; an untagged item is an
|
||||||
|
// Arrange member by default (mirrors leafBelongsToMode's untagged rule). A show-both or
|
||||||
|
// multi-mode item resolves to its first mode id — such items are unusual for lane
|
||||||
|
// content, and the pure decision only needs A mode per item; the managed-lane it lands
|
||||||
|
// on is that mode's lane. Never returns empty for a real item.
|
||||||
|
std::string itemModeFromMembership(const ViewModeModel& model, const std::string& itemGuid) {
|
||||||
|
const std::set<std::string> modes = model.membership().modesOf(itemGuid);
|
||||||
|
if (modes.empty()) return kArrangeModeId; // untagged ⇒ Arrange default
|
||||||
|
return *modes.begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the per-track LaneItem picture the pure decision consumes. For each track and
|
||||||
|
// each item: resolve the item's mode from membership, and — only on a track already in
|
||||||
|
// fixed-lane mode — read whether it sits on a MANUAL lane (exempt). On a non-fixed-lane
|
||||||
|
// track no item is on a manual lane (isOnManualLane returns false for the empty name),
|
||||||
|
// so the manual read is skipped entirely there.
|
||||||
|
std::vector<LaneTrack> readLaneTracks(
|
||||||
|
const ViewModeModel& model,
|
||||||
|
const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
|
||||||
|
std::vector<LaneTrack> tracks;
|
||||||
|
tracks.reserve(handleByGuid.size());
|
||||||
|
for (const auto& [guid, tr] : handleByGuid) {
|
||||||
|
LaneTrack lt;
|
||||||
|
lt.trackGuid = guid;
|
||||||
|
|
||||||
|
const bool fixedLane =
|
||||||
|
static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
|
||||||
|
|
||||||
|
const int itemCount = CountTrackMediaItems(tr);
|
||||||
|
lt.items.reserve(static_cast<std::size_t>(itemCount));
|
||||||
|
for (int i = 0; i < itemCount; ++i) {
|
||||||
|
MediaItem* it = GetTrackMediaItem(tr, i);
|
||||||
|
if (!it) continue;
|
||||||
|
const std::string ig = itemGuid(it);
|
||||||
|
if (ig.empty()) continue;
|
||||||
|
LaneItem li;
|
||||||
|
li.guid = ig;
|
||||||
|
li.modeId = itemModeFromMembership(model, ig);
|
||||||
|
// Manual-lane exemption: only meaningful on a fixed-lane track. The shared
|
||||||
|
// pure predicate decides; on a normal track it returns false regardless of
|
||||||
|
// name, so we pass an empty name and skip the P_LANENAME read.
|
||||||
|
const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{};
|
||||||
|
li.onManualLane = isOnManualLane(fixedLane, ln);
|
||||||
|
lt.items.push_back(std::move(li));
|
||||||
|
}
|
||||||
|
tracks.push_back(std::move(lt));
|
||||||
|
}
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assigns item `it` to the managed lane whose durable key resolves to a current ordinal
|
||||||
|
// on `tr` (via managedLaneOrdinals). Idempotent: writes I_FIXEDLANE only when it differs
|
||||||
|
// from the item's current lane, so a re-run does not thrash the item or the undo state.
|
||||||
|
// Returns true iff a write actually changed the item's lane. Non-destructive: only the
|
||||||
|
// reversible I_FIXEDLANE flag is written — the item is never moved in time or across
|
||||||
|
// tracks. (I_FIXEDLANE is settable per SDK: "fine to call with setNewValue".)
|
||||||
|
bool assignItemToLane(MediaTrack* tr, MediaItem* it, int laneOrdinal) {
|
||||||
|
const int current = static_cast<int>(GetMediaItemInfo_Value(it, "I_FIXEDLANE"));
|
||||||
|
if (current == laneOrdinal) return false; // already there — no-op
|
||||||
|
SetMediaItemInfo_Value(it, "I_FIXEDLANE", static_cast<double>(laneOrdinal));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies the pure LaneMintPlan to the live project. For each track that must split:
|
||||||
|
// enables fixed lanes, ensures the lane count, stamps each managed lane's durable name,
|
||||||
|
// records ownership in the model, then assigns each item to its mode's lane by resolving
|
||||||
|
// the durable key to the lane's current ordinal. Returns true if ANY project write
|
||||||
|
// changed state (⇒ the caller keeps the Undo block and refreshes the timeline).
|
||||||
|
//
|
||||||
|
// MANAGED-LANES-ONLY: the plan only ever names lanes with the managed prefix and only
|
||||||
|
// ever assigns managed-eligible items (manual-lane items were reported exempt and are
|
||||||
|
// absent from the plan). We only ever GROW I_NUMFIXEDLANES to fit the managed lanes and
|
||||||
|
// stamp names on the lanes we mint — a user's existing manual lanes keep their ordinals
|
||||||
|
// below/around ours and are never renamed or reassigned.
|
||||||
|
bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan,
|
||||||
|
const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
|
||||||
|
bool changed = false;
|
||||||
|
|
||||||
|
// Group mints + assigns by track so each track is set up once.
|
||||||
|
std::map<std::string, std::vector<const LaneMint*>> mintsByTrack;
|
||||||
|
for (const LaneMint& m : plan.mints) mintsByTrack[m.trackGuid].push_back(&m);
|
||||||
|
std::map<std::string, std::vector<const LaneAssign*>> assignsByTrack;
|
||||||
|
for (const LaneAssign& a : plan.assigns) assignsByTrack[a.trackGuid].push_back(&a);
|
||||||
|
|
||||||
|
for (const LaneMintPlan::TrackSplit& split : plan.splits) {
|
||||||
|
MediaTrack* tr = resolve(handleByGuid, split.trackGuid);
|
||||||
|
if (!tr) continue; // stale GUID — prune
|
||||||
|
|
||||||
|
// Enable fixed-lane mode if not already (SDK: UpdateTimeline() owed after). The
|
||||||
|
// pre-write freeMode read is ALSO the managed-vs-manual boundary signal: a track that
|
||||||
|
// was NOT in fixed-lane mode here is one the TOOL is splitting now, so the tool owns
|
||||||
|
// its lane display and drives it transparent. A track already at I_FREEMODE==2 (user
|
||||||
|
// had fixed lanes, or a prior tool run) skips this branch — its C_LANESCOLLAPSED /
|
||||||
|
// C_LANESETTINGS are left exactly as the user set them.
|
||||||
|
const int freeMode = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
|
||||||
|
if (freeMode != kFreeModeFixedLanes) {
|
||||||
|
SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast<double>(kFreeModeFixedLanes));
|
||||||
|
applyTransparentLaneDisplay(tr); // tool-split track ⇒ read like a normal track
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure enough lanes for the managed set WITHOUT shrinking: a track may already
|
||||||
|
// carry the user's manual lanes, so only GROW the count, never reduce it (which
|
||||||
|
// would delete a user lane). The managed lanes we mint occupy the tail ordinals.
|
||||||
|
// laneCount tracks the live I_NUMFIXEDLANES as we grow it: read ONCE here, then
|
||||||
|
// each mint appends at laneCount and bumps it. No per-mint I_NUMFIXEDLANES re-read
|
||||||
|
// is needed — nextOrdinal and laneCount are the same running value.
|
||||||
|
int laneCount = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
|
||||||
|
|
||||||
|
// Which managed keys are already present on this track (durable-name reconcile).
|
||||||
|
std::map<std::string, int> present = managedLaneOrdinals(tr);
|
||||||
|
|
||||||
|
// Mint each managed lane that is not already present, appending at the tail so an
|
||||||
|
// existing manual lane is never overwritten. Record ownership in the model.
|
||||||
|
for (const LaneMint* m : mintsByTrack[split.trackGuid]) {
|
||||||
|
model.lanes().setManaged(m->trackGuid, m->laneKey, m->modeId); // ownership
|
||||||
|
if (present.count(m->laneKey)) continue; // already minted — idempotent
|
||||||
|
|
||||||
|
// Append at the current tail ordinal, grow the tracked count, stamp its name.
|
||||||
|
const int laneIdx = laneCount++;
|
||||||
|
SetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES", static_cast<double>(laneCount));
|
||||||
|
char parm[32];
|
||||||
|
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
|
||||||
|
std::vector<char> name(m->laneKey.begin(), m->laneKey.end());
|
||||||
|
name.push_back('\0');
|
||||||
|
GetSetMediaTrackInfo_String(tr, parm, name.data(), true);
|
||||||
|
present.emplace(m->laneKey, laneIdx); // now resolvable for the assign pass
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign each item to its mode's managed lane, resolving the durable key to the
|
||||||
|
// lane's current ordinal on THIS track. A key not present (shouldn't happen — we
|
||||||
|
// just minted them all) is skipped rather than mis-assigned. Item handles are
|
||||||
|
// resolved through a one-pass GUID map (avoids re-scanning the track per item).
|
||||||
|
const std::map<std::string, int> ordinals = managedLaneOrdinals(tr);
|
||||||
|
const std::map<std::string, MediaItem*> itemsByGuid = itemHandlesByGuid(tr);
|
||||||
|
for (const LaneAssign* a : assignsByTrack[split.trackGuid]) {
|
||||||
|
auto ord = ordinals.find(a->laneKey);
|
||||||
|
if (ord == ordinals.end()) continue; // key not live — prune, never mis-assign
|
||||||
|
auto handle = itemsByGuid.find(a->itemGuid);
|
||||||
|
if (handle == itemsByGuid.end()) continue; // stale item GUID — prune
|
||||||
|
if (assignItemToLane(tr, handle->second, ord->second)) changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) {
|
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) {
|
||||||
@@ -194,6 +517,16 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
|||||||
model.clearSnapshot(guid);
|
model.clearSnapshot(guid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MANAGED LANES (D2 item-level projection): drive C_LANEPLAYS so the active mode's
|
||||||
|
// managed lane plays+shows and every inactive-mode managed lane is silenced+hidden.
|
||||||
|
// plan.lanes carries MANAGED lanes only (the pure planner gates on the ownership
|
||||||
|
// index); applyLaneOps additionally resolves each op's durable key against the live
|
||||||
|
// track's lane names, so a manual lane — which never carries the managed prefix —
|
||||||
|
// can never be driven. Empty for a D1-only project (no fixed lanes), leaving D1
|
||||||
|
// behavior byte-identical. UpdateTimeline() is owed only if a track's I_FREEMODE
|
||||||
|
// was (re)set to fixed lanes (SDK requirement); deferred to the refresh block below.
|
||||||
|
const bool laneModeChanged = applyLaneOps(handleByGuid, plan.lanes);
|
||||||
|
|
||||||
// PARENT VISIBILITY (never parked): visibleTracks() marks a parent visible when
|
// PARENT VISIBILITY (never parked): visibleTracks() marks a parent visible when
|
||||||
// a descendant leaf is visible in the target mode OR the parent belongs to the
|
// a descendant leaf is visible in the target mode OR the parent belongs to the
|
||||||
// mode by its own membership (untagged folder → Arrange default). Recomputed
|
// mode by its own membership (untagged folder → Arrange default). Recomputed
|
||||||
@@ -228,8 +561,117 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
|||||||
TrackList_AdjustWindows(false);
|
TrackList_AdjustWindows(false);
|
||||||
UpdateArrange();
|
UpdateArrange();
|
||||||
|
|
||||||
|
// A fixed-lane mode change (I_FREEMODE -> 2) requires UpdateTimeline() to take
|
||||||
|
// visible effect (SDK). Call it only when we actually toggled a track into fixed
|
||||||
|
// lanes this apply; the C_LANEPLAYS writes themselves are picked up by the arrange
|
||||||
|
// refresh above.
|
||||||
|
if (laneModeChanged) UpdateTimeline();
|
||||||
|
|
||||||
Undo_EndBlock2(proj, undoLabel.c_str(), -1);
|
Undo_EndBlock2(proj, undoLabel.c_str(), -1);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) {
|
||||||
|
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
|
||||||
|
std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
|
||||||
|
// The minting decision is now folder-tree / visibility aware: it needs the tree to
|
||||||
|
// detect a content-bearing folder derived-visible in >1 mode (which must lane-separate
|
||||||
|
// its own media even when that media is single-mode). Build it exactly as applyMode does.
|
||||||
|
const FolderTree tree = buildFolderTree(entries);
|
||||||
|
|
||||||
|
// Build the live per-track item picture and run the PURE decision. A track visible in
|
||||||
|
// exactly one mode produces no split; a track visible in >1 mode while carrying its own
|
||||||
|
// media (own items span modes, OR a folder derived-visible across modes) produces mints
|
||||||
|
// + assignments. Manual-lane items are reported exempt inside readLaneTracks; show-both
|
||||||
|
// tracks are skipped inside the decision.
|
||||||
|
const std::vector<LaneTrack> tracks = readLaneTracks(model, handleByGuid);
|
||||||
|
const LaneMintPlan plan = planLaneMinting(model, tree, tracks);
|
||||||
|
if (plan.empty()) return false; // nothing to mint — no Undo point for a no-op tick
|
||||||
|
|
||||||
|
// Wrap the structural mutation in ONE Undo block (unlike the invisible membership
|
||||||
|
// tag). Only opened when the plan is non-empty; applyMintPlan reports whether any
|
||||||
|
// write actually changed state so we can label the undo meaningfully.
|
||||||
|
Undo_BeginBlock2(proj);
|
||||||
|
const bool changed = applyMintPlan(model, plan, handleByGuid);
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
// The plan was non-empty but every REAPER write was already satisfied. Close the
|
||||||
|
// block with no description so REAPER discards the empty undo point rather than
|
||||||
|
// flooding history with a no-change entry every detection tick.
|
||||||
|
Undo_EndBlock2(proj, "", 0);
|
||||||
|
|
||||||
|
// BUT the arrange still needs a redraw. On the detect-tick caller (bankPanelRefresh)
|
||||||
|
// mintManagedLanes runs only when this tick just tagged new content, and a NON-EMPTY
|
||||||
|
// plan means that content sits on a managed-split track. The idempotent no-op path is
|
||||||
|
// reached when a freshly-inserted item ALREADY landed on the active mode's playing
|
||||||
|
// lane (REAPER places a new item on the playing lane; the active mode's lane IS the
|
||||||
|
// playing lane, so assignItemToLane sees I_FIXEDLANE unchanged and writes nothing).
|
||||||
|
// The item is correctly placed and confined, but the arrange was never told to
|
||||||
|
// repaint it onto the lane — so it stayed invisible until a manual mode toggle forced
|
||||||
|
// applyMode's refresh. Force the redraw here so the item appears immediately without a
|
||||||
|
// toggle. UpdateArrange() only repaints (no I_FREEMODE transition happened on this
|
||||||
|
// path, so UpdateTimeline is not owed); it is NOT a project mutation, so it stays
|
||||||
|
// outside the undo block and adds no history entry. On the action caller (doMoveItems)
|
||||||
|
// this is a harmless repaint immediately before its own reapplyActiveMode() refresh.
|
||||||
|
UpdateArrange();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reapply the active mode's lane visibility so the freshly-minted lanes take their
|
||||||
|
// correct play/show state immediately: the active mode's lane plays+shows, every
|
||||||
|
// other managed lane hides+silences. Reusing planToggle's lane ops keeps the drive
|
||||||
|
// logic in one place; applyLaneOps also (re)asserts I_FREEMODE and drives C_LANEPLAYS.
|
||||||
|
// NOTE: applyMode is NOT reused here — it would re-park/restore whole tracks and
|
||||||
|
// recompute parent visibility, which the minting tick must not do (it only just
|
||||||
|
// changed item lanes). Driving lane play state directly is the minimal correct step.
|
||||||
|
const TogglePlan togglePlan = model.planToggle(FolderTree{}, model.activeModeId());
|
||||||
|
applyLaneOps(handleByGuid, togglePlan.lanes);
|
||||||
|
|
||||||
|
// I_FREEMODE was (re)set to fixed lanes on at least one track (the plan minted a
|
||||||
|
// split), so a timeline refresh is owed (SDK). Repaint the arrange too so the new
|
||||||
|
// lane layout appears immediately.
|
||||||
|
UpdateTimeline();
|
||||||
|
UpdateArrange();
|
||||||
|
|
||||||
|
Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", -1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) {
|
||||||
|
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
|
||||||
|
readFolderEntries(proj, handleByGuid); // populates handleByGuid (tree unused here)
|
||||||
|
|
||||||
|
// Walk every track's lanes; for each lane whose durable name carries the managed
|
||||||
|
// prefix, record it MANAGED-for-its-mode in the ownership index. This is a pure READ
|
||||||
|
// of REAPER state (no lane is created, no I_FREEMODE/I_NUMFIXEDLANES/I_FIXEDLANE is
|
||||||
|
// written) plus an index write — self-healing classification from the source of
|
||||||
|
// truth (the durable name) without re-minting or mass-tagging. A lane lacking the
|
||||||
|
// prefix is left alone (manual by default), so a user's own lanes stay off the index.
|
||||||
|
for (const auto& [guid, tr] : handleByGuid) {
|
||||||
|
const int freeMode = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE"));
|
||||||
|
if (freeMode != kFreeModeFixedLanes) continue; // no fixed lanes ⇒ nothing managed
|
||||||
|
|
||||||
|
const int numLanes = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"));
|
||||||
|
for (int lane = 0; lane < numLanes; ++lane) {
|
||||||
|
const std::string name = laneName(tr, lane);
|
||||||
|
std::optional<std::string> key = managedLaneKey(name);
|
||||||
|
if (!key) continue; // manual/unnamed lane — leave off the index
|
||||||
|
std::optional<std::string> mode = modeIdFromLaneName(name);
|
||||||
|
if (!mode) continue; // prefix-only/illegal name — skip defensively
|
||||||
|
|
||||||
|
// UNREGISTERED-MODE GUARD: the durable name encodes a mode id, but that mode
|
||||||
|
// may no longer be a registered Mode (e.g. a mode removed from the registry
|
||||||
|
// after the project was saved with lanes minted for it). Recording it MANAGED
|
||||||
|
// would make the toggle planner drive a lane keyed to a mode that can never be
|
||||||
|
// the active mode — the lane would stay silenced+hidden forever, orphaning its
|
||||||
|
// items with no way for the user to reach them. So we do NOT record it: the
|
||||||
|
// lane is left off the ownership index and thus treated as manual-by-default
|
||||||
|
// (never driven). Its durable name is preserved on the track, so if the mode is
|
||||||
|
// ever re-registered a later reconcile recovers the ownership cleanly.
|
||||||
|
if (!model.modes().contains(*mode)) continue;
|
||||||
|
model.lanes().setManaged(guid, *key, *mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
+40
@@ -52,4 +52,44 @@ namespace reasampler {
|
|||||||
// registered mode. `proj` may be nullptr to mean REAPER's current project.
|
// registered mode. `proj` may be nullptr to mean REAPER's current project.
|
||||||
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj);
|
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj);
|
||||||
|
|
||||||
|
// Mints managed fixed lanes for any track in `proj` that is VISIBLE IN MORE THAN ONE
|
||||||
|
// MODE while carrying its own media, and assigns each item to its mode's managed lane
|
||||||
|
// (Phase D2 Wave 3; visibility trigger added by the folder-media fix).
|
||||||
|
// 1. Enumerates every track + its items; resolves each item's mode from the model's
|
||||||
|
// membership (untagged ⇒ Arrange) and reads whether it currently sits on a MANUAL
|
||||||
|
// lane (exempt). Builds the FolderTree (I_FOLDERDEPTH) so derived visibility counts.
|
||||||
|
// 2. Runs the pure planLaneMinting decision (model + tree aware). A track visible in
|
||||||
|
// exactly one mode is left whole-track-parked (D1) — NOT lane-split. A track visible
|
||||||
|
// in >1 mode while carrying own media splits: its own items span modes, OR it is a
|
||||||
|
// content-bearing folder derived-visible across modes. show-both tracks never split.
|
||||||
|
// 3. For each track that must split: enables fixed-lane mode (I_FREEMODE=2), ensures
|
||||||
|
// enough fixed lanes (I_NUMFIXEDLANES), stamps each managed lane's durable name
|
||||||
|
// (P_LANENAME:n), records the lane MANAGED-for-its-mode in the model's ownership
|
||||||
|
// index, and assigns each managed-eligible item to its mode's lane (I_FIXEDLANE).
|
||||||
|
// Manual lanes and the items on them are NEVER minted-over or reassigned.
|
||||||
|
// 4. Reapplies the active mode's lane visibility so the just-minted lanes take their
|
||||||
|
// correct play/show state immediately (the active mode's lane plays; others hide).
|
||||||
|
// The whole structural mutation is wrapped in ONE Undo_BeginBlock2/EndBlock2 — but only
|
||||||
|
// when the plan is non-empty (no undo point for a tick that mints nothing).
|
||||||
|
//
|
||||||
|
// Returns true if any lane was minted this call (⇒ the caller may want a repaint).
|
||||||
|
// `proj` may be nullptr to mean REAPER's current project. READ of the membership index
|
||||||
|
// only; the sole model mutation is recording new managed-lane ownership.
|
||||||
|
bool mintManagedLanes(ViewModeModel& model, ReaProject* proj);
|
||||||
|
|
||||||
|
// Reconciles the model's lane-ownership index against the live project's lanes on
|
||||||
|
// project open (Phase D2 Wave 3). REAPER's durable P_LANENAME is the source of truth for
|
||||||
|
// lane identity across sessions (design point #2): a lane whose name carries the managed
|
||||||
|
// prefix is tool-managed and owned by the mode encoded in that name. This walks every
|
||||||
|
// track's lanes and records each managed-named lane MANAGED-for-its-mode in the index —
|
||||||
|
// self-healing a saved project's classification WITHOUT re-minting (it never creates a
|
||||||
|
// lane, changes I_FREEMODE/I_NUMFIXEDLANES, or reassigns an item) and WITHOUT mass-
|
||||||
|
// tagging (it never touches membership). A lane without the managed prefix is left
|
||||||
|
// untouched (manual by default). Reload's active-mode lane visibility is then reapplied
|
||||||
|
// by the caller's applyMode, mirroring D1's reapply-on-open.
|
||||||
|
//
|
||||||
|
// `proj` may be nullptr to mean REAPER's current project. The only model mutation is
|
||||||
|
// recording managed ownership recovered from durable lane names.
|
||||||
|
void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj);
|
||||||
|
|
||||||
} // namespace reasampler
|
} // namespace reasampler
|
||||||
|
|||||||
+142
-4
@@ -1,10 +1,15 @@
|
|||||||
#include "view_mode_model.h"
|
#include "view_mode_model.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cassert>
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
#include <climits>
|
#include <climits>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
|
#include <set>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include "lane_keys.h" // laneNameForMode — the ONE durable managed-lane-key convention
|
||||||
|
|
||||||
// view_mode_model implementation.
|
// view_mode_model implementation.
|
||||||
//
|
//
|
||||||
@@ -121,6 +126,13 @@ int laneModeState(const std::string& managedMode, const std::string& activeMode)
|
|||||||
// and hidden (C_LANEPLAYS = 0). Exclusive membership: only one stance's lane at a
|
// and hidden (C_LANEPLAYS = 0). Exclusive membership: only one stance's lane at a
|
||||||
// time. Show-both, which keeps a lane audible across modes, is a per-lane opt-out
|
// time. Show-both, which keeps a lane audible across modes, is a per-lane opt-out
|
||||||
// the shell layers on; the default per-mode decision here is exclusive.
|
// the shell layers on; the default per-mode decision here is exclusive.
|
||||||
|
//
|
||||||
|
// EXCLUSIVITY ASSUMPTION (one managed lane per mode per track): the model assumes a
|
||||||
|
// given (track, mode) owns AT MOST ONE managed lane. C_LANEPLAYS=1 means "this lane
|
||||||
|
// plays EXCLUSIVELY" — two lanes on the same track both claiming mode M would both
|
||||||
|
// be told to play exclusively on M's toggle, which REAPER cannot honor coherently
|
||||||
|
// (the last write wins in the DAW). The Wave-3 lane-minting path is responsible for
|
||||||
|
// upholding one-lane-per-(track,mode); planToggle asserts it in debug builds.
|
||||||
return managedMode == activeMode ? kLanePlaysExclusive : kLaneSilent;
|
return managedMode == activeMode ? kLanePlaysExclusive : kLaneSilent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +158,116 @@ std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackG
|
|||||||
return tags;
|
return tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<ItemRetagOp> planItemRetag(const std::vector<RetagItem>& selected,
|
||||||
|
const std::string& targetMode) {
|
||||||
|
std::vector<ItemRetagOp> ops;
|
||||||
|
const bool untag = targetMode.empty(); // empty target ⇒ untag (→ Arrange default)
|
||||||
|
for (const RetagItem& item : selected) {
|
||||||
|
if (item.guid.empty()) continue; // defensive; a real item always has a GUID
|
||||||
|
if (item.onManualLane) continue; // manual-lane item is EXEMPT — never retagged
|
||||||
|
ops.push_back(ItemRetagOp{item.guid, untag, untag ? std::string{} : targetMode});
|
||||||
|
}
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// lane minting decision
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree,
|
||||||
|
const std::vector<LaneTrack>& tracks) {
|
||||||
|
LaneMintPlan plan;
|
||||||
|
|
||||||
|
// Precompute, per track GUID, the count of modes it is VISIBLE in and the set of
|
||||||
|
// those mode ids — tree-aware, so a content-bearing folder's DERIVED visibility
|
||||||
|
// (visibleTracks marks a parent visible in every mode a descendant is visible in)
|
||||||
|
// is captured, not only the track's own item mode-span. This is the visibility
|
||||||
|
// trigger source (b): a folder derived-visible in >= 2 modes must lane-separate its
|
||||||
|
// own media even when that media is single-mode. Computed once for all tracks.
|
||||||
|
std::map<std::string, std::set<std::string>> visibleModesOf;
|
||||||
|
for (const Mode& mode : model.modes().all()) {
|
||||||
|
const std::set<std::string> vis = model.visibleTracks(tree, mode.id);
|
||||||
|
for (const std::string& guid : vis)
|
||||||
|
visibleModesOf[guid].insert(mode.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const LaneTrack& track : tracks) {
|
||||||
|
if (track.trackGuid.empty()) continue;
|
||||||
|
|
||||||
|
// SHOW-BOTH escape hatch: never force-split. A show-both track is visible in
|
||||||
|
// every mode ON PURPOSE and its content is meant to play across all of them, so
|
||||||
|
// neither the visibility trigger nor the own-item-span trigger confines it. Skip
|
||||||
|
// it entirely (no split/mint/assign) so its items stay cross-mode-visible.
|
||||||
|
if (model.membership().isShowBoth(track.trackGuid)) continue;
|
||||||
|
|
||||||
|
// Collect the DISTINCT modes the track's managed-eligible OWN items belong to, in
|
||||||
|
// deterministic (sorted) order so the mint list and lane count are stable across
|
||||||
|
// runs (a set orders by mode id). Items on a manual lane are EXEMPT — never
|
||||||
|
// counted toward the multi-mode test and never reassigned (the managed-only
|
||||||
|
// invariant, upheld at the source of the decision).
|
||||||
|
std::set<std::string> ownItemModes;
|
||||||
|
for (const LaneItem& item : track.items) {
|
||||||
|
if (item.guid.empty() || item.modeId.empty()) continue;
|
||||||
|
if (item.onManualLane) continue; // exempt — user's hand-managed lane
|
||||||
|
ownItemModes.insert(item.modeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A track with NO managed-eligible own media never splits: there is nothing to
|
||||||
|
// confine (lane separation projects OWN items across modes). A folder derived-
|
||||||
|
// visible in many modes but carrying no own content stays whole-track visibility-
|
||||||
|
// only (D1 parent handling) — this guards the "carries its own media" clause.
|
||||||
|
if (ownItemModes.empty()) continue;
|
||||||
|
|
||||||
|
// The two visibility sources, OR'd:
|
||||||
|
// (a) own items span >= 2 modes (W3-A trigger), and
|
||||||
|
// (b) the track is derived-visible in >= 2 modes (the folder-media case).
|
||||||
|
// A track qualifies for a split if EITHER makes it multi-mode.
|
||||||
|
const auto visIt = visibleModesOf.find(track.trackGuid);
|
||||||
|
const std::size_t visibleModeCount =
|
||||||
|
visIt == visibleModesOf.end() ? 0 : visIt->second.size();
|
||||||
|
const bool multiMode = ownItemModes.size() >= 2 || visibleModeCount >= 2;
|
||||||
|
|
||||||
|
// Single-mode (visible in exactly one mode, own items single-mode): whole-track
|
||||||
|
// parking (D1) still separates the stances. NO split, NO mint, NO assignment —
|
||||||
|
// this is the load-bearing "don't lane-split single-mode tracks" rule.
|
||||||
|
if (!multiMode) continue;
|
||||||
|
|
||||||
|
// Lazy-mint: lanes to mint = ONLY the modes the track's OWN items actually occupy —
|
||||||
|
// never an empty reserved lane for a mode the track is merely derived-visible in.
|
||||||
|
// A folder whose own item is Design-only but which is derived-visible in Arrange too
|
||||||
|
// mints a Design lane ONLY (holding the item); it mints NO Arrange lane. Confinement
|
||||||
|
// still holds: with only a Design lane present, toggling to Arrange drives that lane's
|
||||||
|
// C_LANEPLAYS to 0 (it hides+silences) and no lane plays, so the track reads as an
|
||||||
|
// empty normal track — the Design item does not leak. The Arrange lane is minted on
|
||||||
|
// demand the moment an Arrange item first lands (a later mint tick sees ownItemModes
|
||||||
|
// gain Arrange). The visibility trigger above still decides WHETHER to split; it no
|
||||||
|
// longer inflates WHICH lanes are minted.
|
||||||
|
const std::set<std::string>& laneModes = ownItemModes;
|
||||||
|
|
||||||
|
// Transition to lane-split: one managed lane per own-content mode (durable key =
|
||||||
|
// laneNameForMode(mode)), owned by that mode.
|
||||||
|
plan.splits.push_back(LaneMintPlan::TrackSplit{
|
||||||
|
track.trackGuid, static_cast<int>(laneModes.size())});
|
||||||
|
for (const std::string& mode : laneModes) {
|
||||||
|
plan.mints.push_back(
|
||||||
|
LaneMint{track.trackGuid, laneNameForMode(mode), mode});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign EVERY managed-eligible OWN item onto its tagged mode's lane — including
|
||||||
|
// the pre-existing single-mode items, so a folder carrying one own Design item
|
||||||
|
// while derived-visible in Arrange still lanes that item to the Design lane (it
|
||||||
|
// then hides+silences whenever Arrange is active — the exact failing-case fix).
|
||||||
|
for (const LaneItem& item : track.items) {
|
||||||
|
if (item.guid.empty() || item.modeId.empty()) continue;
|
||||||
|
if (item.onManualLane) continue; // exempt — never reassigned
|
||||||
|
plan.assigns.push_back(LaneAssign{
|
||||||
|
item.guid, track.trackGuid, laneNameForMode(item.modeId)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// planner helpers
|
// planner helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -319,8 +441,20 @@ TogglePlan ViewModeModel::planToggle(const FolderTree& tree, const std::string&
|
|||||||
// "never touch mute/solo"). Lane ownership is not a tree property, so this walks the
|
// "never touch mute/solo"). Lane ownership is not a tree property, so this walks the
|
||||||
// ownership index directly, not the FolderTree; a project with no fixed lanes leaves
|
// ownership index directly, not the FolderTree; a project with no fixed lanes leaves
|
||||||
// plan.lanes empty and the plan is byte-identical to a D1 plan.
|
// plan.lanes empty and the plan is byte-identical to a D1 plan.
|
||||||
|
#ifndef NDEBUG
|
||||||
|
// Debug-time guard for the one-managed-lane-per-mode-per-track exclusivity
|
||||||
|
// assumption (see laneModeState). Two managed lanes on the same track claiming the
|
||||||
|
// same mode would both be told to play exclusively on that mode's toggle, which
|
||||||
|
// REAPER cannot honor. Cheap set membership over the (usually tiny) managed-lane
|
||||||
|
// set; compiled out of release builds.
|
||||||
|
std::set<std::pair<std::string, std::string>> seenTrackMode; // (trackGuid, mode)
|
||||||
|
#endif
|
||||||
for (const auto& [ref, ownership] : lanes_.all()) {
|
for (const auto& [ref, ownership] : lanes_.all()) {
|
||||||
if (!ownership.isManaged()) continue; // manual lanes are off-limits
|
if (!ownership.isManaged()) continue; // manual lanes are off-limits
|
||||||
|
#ifndef NDEBUG
|
||||||
|
assert(seenTrackMode.insert({ref.trackGuid, *ownership.managedMode}).second &&
|
||||||
|
"two managed lanes on one track claim the same mode (exclusivity broken)");
|
||||||
|
#endif
|
||||||
const int lanePlays = laneModeState(*ownership.managedMode, targetMode);
|
const int lanePlays = laneModeState(*ownership.managedMode, targetMode);
|
||||||
plan.lanes.push_back(LanePlayOp{ref.trackGuid, ref.laneKey, lanePlays});
|
plan.lanes.push_back(LanePlayOp{ref.trackGuid, ref.laneKey, lanePlays});
|
||||||
}
|
}
|
||||||
@@ -448,7 +582,8 @@ std::string ViewModeModel::serialize() const {
|
|||||||
{
|
{
|
||||||
bool first = true;
|
bool first = true;
|
||||||
for (const auto& [guid, mem] : membership_.all()) {
|
for (const auto& [guid, mem] : membership_.all()) {
|
||||||
if (!first) out += ','; first = false;
|
if (!first) out += ',';
|
||||||
|
first = false;
|
||||||
ObjWriter e(out);
|
ObjWriter e(out);
|
||||||
e.keyStr("guid", guid);
|
e.keyStr("guid", guid);
|
||||||
e.keyBegin("modes");
|
e.keyBegin("modes");
|
||||||
@@ -456,7 +591,8 @@ std::string ViewModeModel::serialize() const {
|
|||||||
{
|
{
|
||||||
bool mf = true;
|
bool mf = true;
|
||||||
for (const auto& id : mem.modeIds) {
|
for (const auto& id : mem.modeIds) {
|
||||||
if (!mf) out += ','; mf = false;
|
if (!mf) out += ',';
|
||||||
|
mf = false;
|
||||||
writeEscaped(out, id);
|
writeEscaped(out, id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,7 +608,8 @@ std::string ViewModeModel::serialize() const {
|
|||||||
{
|
{
|
||||||
bool first = true;
|
bool first = true;
|
||||||
for (const auto& [guid, snap] : snapshots_) {
|
for (const auto& [guid, snap] : snapshots_) {
|
||||||
if (!first) out += ','; first = false;
|
if (!first) out += ',';
|
||||||
|
first = false;
|
||||||
ObjWriter e(out);
|
ObjWriter e(out);
|
||||||
e.keyStr("guid", guid);
|
e.keyStr("guid", guid);
|
||||||
e.keyRaw("showInTcp", intToStr(snap.showInTcp));
|
e.keyRaw("showInTcp", intToStr(snap.showInTcp));
|
||||||
@@ -494,7 +631,8 @@ std::string ViewModeModel::serialize() const {
|
|||||||
{
|
{
|
||||||
bool first = true;
|
bool first = true;
|
||||||
for (const auto& [ref, ownership] : lanes_.all()) {
|
for (const auto& [ref, ownership] : lanes_.all()) {
|
||||||
if (!first) out += ','; first = false;
|
if (!first) out += ',';
|
||||||
|
first = false;
|
||||||
ObjWriter e(out);
|
ObjWriter e(out);
|
||||||
e.keyStr("trackGuid", ref.trackGuid);
|
e.keyStr("trackGuid", ref.trackGuid);
|
||||||
e.keyStr("laneKey", ref.laneKey);
|
e.keyStr("laneKey", ref.laneKey);
|
||||||
|
|||||||
@@ -519,6 +519,196 @@ std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackG
|
|||||||
const std::vector<NewItem>& newItems,
|
const std::vector<NewItem>& newItems,
|
||||||
const std::string& activeMode);
|
const std::string& activeMode);
|
||||||
|
|
||||||
|
// -- Item-level mode-move decision (Phase D2 / Wave 3-B) ---------------------
|
||||||
|
//
|
||||||
|
// The bindable item actions (Move selected items -> Design / -> Arrange / Untag)
|
||||||
|
// retag the CURRENT item selection's membership, then re-drive the minting/apply
|
||||||
|
// path so each moved item lands on its target mode's managed lane. The DECISION —
|
||||||
|
// which selected items to retag, and to what — is pure and unit-tested here; the
|
||||||
|
// shell only reads the item selection (GUID + manual-lane disposition) and applies
|
||||||
|
// the resulting membership writes + re-lane pass.
|
||||||
|
//
|
||||||
|
// MANAGED-LANES-ONLY INVARIANT (upheld at the source, exactly as auto-tag does): an
|
||||||
|
// item the shell reports as already on a MANUAL lane is EXEMPT — it is never retagged,
|
||||||
|
// never untagged, never re-laned. The tool drives only what it minted, even under an
|
||||||
|
// explicit user action. The shell reports `onManualLane` per item and this decision
|
||||||
|
// emits NO op for such items; the shell then skips them entirely.
|
||||||
|
|
||||||
|
// One selected item the shell reports for the retag decision: its GUID and whether it
|
||||||
|
// currently sits on a MANUAL lane (⇒ EXEMPT: no membership change, no re-lane).
|
||||||
|
struct RetagItem {
|
||||||
|
std::string guid;
|
||||||
|
bool onManualLane = false; // true ⇒ EXEMPT from the item mode-move actions
|
||||||
|
};
|
||||||
|
|
||||||
|
// One membership op the item mode-move decision produced for one selected item. `untag`
|
||||||
|
// true ⇒ remove the item from the index (return it to the Arrange default); otherwise
|
||||||
|
// tag it into `modeId`. The shell applies each verbatim to the MembershipIndex.
|
||||||
|
struct ItemRetagOp {
|
||||||
|
std::string guid;
|
||||||
|
bool untag = false; // true ⇒ untag; false ⇒ tag into modeId
|
||||||
|
std::string modeId; // the target mode when !untag (empty when untag)
|
||||||
|
|
||||||
|
bool operator==(const ItemRetagOp& o) const {
|
||||||
|
return guid == o.guid && untag == o.untag && modeId == o.modeId;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The pure item mode-move decision: given the selected items and a target mode, produce
|
||||||
|
// the membership ops. An EMPTY `targetMode` means UNTAG (the "Untag selected items" and
|
||||||
|
// "Move -> Arrange" actions collapse to the same act — Arrange is the absence of a tag,
|
||||||
|
// mirroring the track-level doUntag). A non-empty `targetMode` tags each eligible item
|
||||||
|
// into it. Manual-lane items are skipped (no op emitted); items with an empty GUID are
|
||||||
|
// skipped (defensive). The function mutates nothing — it returns a plan the shell applies.
|
||||||
|
std::vector<ItemRetagOp> planItemRetag(const std::vector<RetagItem>& selected,
|
||||||
|
const std::string& targetMode);
|
||||||
|
|
||||||
|
// -- Lane minting decision (Phase D2 / Wave 3) -------------------------------
|
||||||
|
//
|
||||||
|
// D1 parks a whole track when it holds content of only ONE mode. The moment a track
|
||||||
|
// is VISIBLE IN MORE THAN ONE MODE while carrying its OWN media, whole-track parking
|
||||||
|
// can no longer keep the stances separate (the track shows in every mode it is visible
|
||||||
|
// in, so its items leak across all of them), so the projection drops to the ITEM level:
|
||||||
|
// the track becomes a fixed-lane track, each involved mode gets its own MANAGED lane,
|
||||||
|
// and each item is assigned to its mode's lane. A toggle then shows+plays only the
|
||||||
|
// active mode's lane.
|
||||||
|
//
|
||||||
|
// "Visible in more than one mode" has TWO sources, and both trigger a split:
|
||||||
|
// (1) the track's OWN managed-eligible items span >= 2 modes (a leaf carrying both
|
||||||
|
// an Arrange take and a Design take), OR
|
||||||
|
// (2) the track is a content-bearing FOLDER whose descendant leaves span modes, so
|
||||||
|
// it is DERIVED-VISIBLE in >= 2 modes (ViewModeModel::visibleTracks) even though
|
||||||
|
// its own single item is single-mode. This second source is why the decision is
|
||||||
|
// folder-tree / visibility aware — mirroring visibleTracks — rather than looking
|
||||||
|
// only at the track's own item mode-span. Without it, one MIDI item or capture
|
||||||
|
// dropped straight onto such a folder sits on the default lane and leaks into
|
||||||
|
// every mode the folder derives visibility in.
|
||||||
|
//
|
||||||
|
// SHOW-BOTH is the deliberate escape hatch: a show-both track is visible in every mode
|
||||||
|
// ON PURPOSE and its content is meant to play in all of them. It is NEVER force-split —
|
||||||
|
// neither the visibility trigger nor the own-item-span trigger confines its items to
|
||||||
|
// per-mode lanes. (Confining show-both content would contradict "stay audible across
|
||||||
|
// modes.") The decision skips show-both tracks entirely.
|
||||||
|
//
|
||||||
|
// This is the pure DECISION behind that transition — REAPER-free and unit-tested.
|
||||||
|
// The shell reads each track's items and their live mode+lane disposition, builds the
|
||||||
|
// FolderTree (via the existing view_tree helper, exactly as the D1 shell does), calls
|
||||||
|
// this with the model + tree, and applies the resulting REAPER writes (I_FREEMODE /
|
||||||
|
// I_NUMFIXEDLANES / P_LANENAME / I_FIXEDLANE) plus the ownership-index writes. The
|
||||||
|
// DECISION never lives in the shell.
|
||||||
|
//
|
||||||
|
// THE MANAGED-LANES-ONLY INVARIANT is upheld here at the source: an item the shell
|
||||||
|
// reports as already on a MANUAL lane is EXEMPT — it is never counted toward the
|
||||||
|
// multi-mode test, never reassigned, and its lane is never minted-over. The plan only
|
||||||
|
// ever names lanes with the managed prefix (laneNameForMode) and only ever moves
|
||||||
|
// managed-eligible items. A track the user already lane-splits for their own comping
|
||||||
|
// is handled by minting ADDITIONAL managed lanes alongside the user's manual lanes;
|
||||||
|
// the manual lanes and the items on them are untouched (they are reported exempt).
|
||||||
|
|
||||||
|
// One item the shell reports for the minting decision: its GUID, the mode its
|
||||||
|
// membership resolves to (untagged ⇒ Arrange, resolved by the shell via
|
||||||
|
// leafBelongsToMode / the active-mode default), and whether it currently sits on a
|
||||||
|
// MANUAL lane (⇒ exempt: never counted, never reassigned).
|
||||||
|
struct LaneItem {
|
||||||
|
std::string guid;
|
||||||
|
std::string modeId; // the mode this item's content belongs to
|
||||||
|
bool onManualLane = false; // true ⇒ EXEMPT (user's hand-managed lane)
|
||||||
|
};
|
||||||
|
|
||||||
|
// One track the shell reports: its GUID plus the items on it. The shell builds this by
|
||||||
|
// enumerating the track's media items and resolving each item's mode from membership.
|
||||||
|
struct LaneTrack {
|
||||||
|
std::string trackGuid;
|
||||||
|
std::vector<LaneItem> items;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One item→lane assignment the shell must apply (I_FIXEDLANE = the lane the durable
|
||||||
|
// key `laneKey` currently occupies; the shell resolves key→ordinal exactly as the
|
||||||
|
// C_LANEPLAYS apply path does). Only managed-eligible items appear here.
|
||||||
|
struct LaneAssign {
|
||||||
|
std::string itemGuid;
|
||||||
|
std::string trackGuid;
|
||||||
|
std::string laneKey; // durable managed-lane key (laneNameForMode(modeId))
|
||||||
|
|
||||||
|
bool operator==(const LaneAssign& o) const {
|
||||||
|
return itemGuid == o.itemGuid && trackGuid == o.trackGuid && laneKey == o.laneKey;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// One managed lane the shell must mint on a track: its durable key (== the name to
|
||||||
|
// stamp via P_LANENAME) and the mode that owns it (recorded in the ownership index).
|
||||||
|
struct LaneMint {
|
||||||
|
std::string trackGuid;
|
||||||
|
std::string laneKey; // == laneNameForMode(modeId); the P_LANENAME to stamp
|
||||||
|
std::string modeId; // the owning mode (ownership-index managed-for-mode write)
|
||||||
|
|
||||||
|
bool operator==(const LaneMint& o) const {
|
||||||
|
return trackGuid == o.trackGuid && laneKey == o.laneKey && modeId == o.modeId;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The complete lane-minting plan for the tracks the shell reported. Empty (all three
|
||||||
|
// vectors) when NO track needs splitting — a single-mode-only project produces an empty
|
||||||
|
// plan and the shell does nothing (D1 behavior unchanged). The shell wraps the whole
|
||||||
|
// application in ONE Undo block because it is a visible structural mutation.
|
||||||
|
struct LaneMintPlan {
|
||||||
|
// Tracks to switch into fixed-lane mode, each with the number of managed lanes to
|
||||||
|
// ensure (I_FREEMODE=2, I_NUMFIXEDLANES >= laneCount). Only tracks that need a
|
||||||
|
// split appear; a track already carrying the tool's managed lanes for exactly the
|
||||||
|
// involved modes still appears (idempotent — the shell's ensure is a no-op then).
|
||||||
|
struct TrackSplit {
|
||||||
|
std::string trackGuid;
|
||||||
|
int laneCount = 0; // number of managed lanes this track needs
|
||||||
|
};
|
||||||
|
std::vector<TrackSplit> splits;
|
||||||
|
std::vector<LaneMint> mints; // managed lanes to mint (name + ownership write)
|
||||||
|
std::vector<LaneAssign> assigns; // item→managed-lane assignments
|
||||||
|
|
||||||
|
bool empty() const {
|
||||||
|
return splits.empty() && mints.empty() && assigns.empty();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The pure lane-minting decision, folder-tree / visibility aware. `model` supplies the
|
||||||
|
// membership + show-both state; `tree` supplies the folder structure so a content-bearing
|
||||||
|
// folder's DERIVED visibility is accounted for (mirrors ViewModeModel::visibleTracks).
|
||||||
|
// For each reported track:
|
||||||
|
// * SHOW-BOTH tracks are skipped outright — never force-split (the escape hatch: their
|
||||||
|
// content is meant to stay audible in every mode). No split, mint, or assignment.
|
||||||
|
// * Ignore items on manual lanes entirely (exempt — the managed-only invariant).
|
||||||
|
// * A track splits iff it CARRIES OWN managed-eligible media AND is VISIBLE IN >= 2
|
||||||
|
// MODES. Visibility spans two sources, either of which qualifies:
|
||||||
|
// (a) the track's own managed-eligible items span >= 2 modes (leaf carrying an
|
||||||
|
// Arrange take and a Design take), OR
|
||||||
|
// (b) the track is derived-visible in >= 2 modes per visibleTracks (a content-
|
||||||
|
// bearing folder whose descendant leaves span modes) — the missed case.
|
||||||
|
// * A track visible in exactly ONE mode (single-mode leaf, single-mode folder) stays
|
||||||
|
// whole-track-parked (D1) — NO split. This is the single-mode-track rule.
|
||||||
|
// * On a split: one TrackSplit (laneCount == number of lanes to mint), one LaneMint per
|
||||||
|
// mode the track's OWN items occupy, and one LaneAssign per managed-eligible OWN item
|
||||||
|
// onto ITS tagged mode's lane — INCLUDING pre-existing items, so a folder carrying one
|
||||||
|
// own Design item while derived-visible in Arrange too still lanes that item to the
|
||||||
|
// Design lane (it then hides+silences whenever Arrange is active).
|
||||||
|
// * LAZY-MINT: lanes are minted ONLY for modes the track's own items actually occupy —
|
||||||
|
// never an empty reserved lane for a mode the track is merely derived-visible in. So a
|
||||||
|
// folder whose own item is Design-only but which is derived-visible in Arrange mints a
|
||||||
|
// Design lane ONLY (holding the item), NOT an empty Arrange lane. Confinement still
|
||||||
|
// holds: with only a Design lane present, toggling to Arrange drives that lane's
|
||||||
|
// C_LANEPLAYS to 0 (hide+silence) and no lane plays, so the track reads as an empty
|
||||||
|
// normal track and the Design item does not leak. The Arrange lane is minted on demand
|
||||||
|
// when an Arrange item first lands. The derived-visibility trigger still decides WHETHER
|
||||||
|
// to split; it no longer inflates WHICH lanes are minted.
|
||||||
|
//
|
||||||
|
// Items with an empty GUID or empty modeId are skipped (defensive; a real item always
|
||||||
|
// resolves to a mode). The function mutates nothing — it returns a plan the shell
|
||||||
|
// applies. Idempotency: re-reporting an already-split track yields the same mints and
|
||||||
|
// assignments; the shell's ensure/assign writes are no-ops when the state already
|
||||||
|
// matches, so re-running the detection path does not thrash the project or the undo
|
||||||
|
// history (the shell only opens an Undo block when the plan is non-empty AND some
|
||||||
|
// write actually changes state — see the shell).
|
||||||
|
LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree,
|
||||||
|
const std::vector<LaneTrack>& tracks);
|
||||||
|
|
||||||
// The next mode id in the registry's ordinal order, cycling past `currentModeId`
|
// The next mode id in the registry's ordinal order, cycling past `currentModeId`
|
||||||
// and wrapping to the first mode after the last (Arrange -> Design -> Arrange with
|
// and wrapping to the first mode after the last (Arrange -> Design -> Arrange with
|
||||||
// the two seed modes; the same cycle scales to N modes with no call-site change).
|
// the two seed modes; the same cycle scales to N modes with no call-site change).
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// wav_trim — pure implementation. See wav_trim.h. NO REAPER / SWELL / vendor.
|
||||||
|
|
||||||
|
#include "wav_trim.h"
|
||||||
|
|
||||||
|
#include <cstring> // std::memcpy, std::memcmp
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
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_trim.h FORMAT ASSUMPTION).
|
||||||
|
constexpr std::uint16_t kWaveFormatIeeeFloat = 0x0003;
|
||||||
|
constexpr std::uint16_t kWaveFormatExtensible = 0xFFFE;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes) {
|
||||||
|
WavLayout out;
|
||||||
|
|
||||||
|
// Minimum viable RIFF/WAVE: "RIFF"(4) size(4) "WAVE"(4) = 12 bytes.
|
||||||
|
if (bytes.size() < 12) return out;
|
||||||
|
if (!tagEquals(bytes, 0, "RIFF")) return out;
|
||||||
|
if (!tagEquals(bytes, 8, "WAVE")) 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). Each is: id(4) size(4) body(size),
|
||||||
|
// body padded to an even byte count (RIFF word alignment). Stop cleanly if a
|
||||||
|
// header would run past the buffer — a malformed/truncated file is "invalid",
|
||||||
|
// never an OOB read.
|
||||||
|
std::size_t pos = 12;
|
||||||
|
while (pos + 8 <= bytes.size()) {
|
||||||
|
const std::size_t bodyOffset = pos + 8;
|
||||||
|
const std::uint32_t bodySize = readU32LE(bytes, pos + 4);
|
||||||
|
|
||||||
|
if (tagEquals(bytes, pos, "fmt ")) {
|
||||||
|
// fmt body: at least 16 bytes (PCM/float common fields).
|
||||||
|
if (bodyOffset + 16 > bytes.size() || bodySize < 16) return out;
|
||||||
|
fmtTag = readU16LE(bytes, bodyOffset + 0);
|
||||||
|
channels = readU16LE(bytes, bodyOffset + 2);
|
||||||
|
sampleRate = readU32LE(bytes, bodyOffset + 4);
|
||||||
|
bitsPerSample = readU16LE(bytes, 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 (bodySize >= 40 && bodyOffset + 40 <= bytes.size()) {
|
||||||
|
extensibleSubFormatTag = readU16LE(bytes, bodyOffset + 24);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
haveFmt = true;
|
||||||
|
} else if (tagEquals(bytes, pos, "data")) {
|
||||||
|
// The data chunk: PCM starts at bodyOffset, declared length bodySize.
|
||||||
|
// Reject if it runs past the buffer (truncated / lying header).
|
||||||
|
if (bodyOffset + bodySize > bytes.size()) 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 = bodyOffset;
|
||||||
|
out.dataByteLength = bodySize;
|
||||||
|
out.riffSizeFieldOffset = 4;
|
||||||
|
out.dataSizeFieldOffset = pos + 4; // the `data` size field (LE uint32)
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance past this chunk's body, honoring RIFF even-byte padding. Guard the
|
||||||
|
// additions against size_t overflow (a hostile bodySize near SIZE_MAX).
|
||||||
|
std::size_t advance = bodySize;
|
||||||
|
if (advance & 1u) ++advance; // pad byte
|
||||||
|
if (advance > bytes.size() - bodyOffset) break; // would overrun -> stop
|
||||||
|
pos = bodyOffset + advance;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#pragma once
|
||||||
|
// wav_trim — pure parse + truncate-plan for the realtime tail's PCM decay-scan trim.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// backend records a generous tail window, then trims the trailing decay by
|
||||||
|
// truncating the recorded WAV at a frame boundary. Truncating a WAV correctly is
|
||||||
|
// not "chop the bytes": the RIFF container's size fields (the top-level RIFF chunk
|
||||||
|
// size and the `data` sub-chunk size) must be patched to the kept byte count, or
|
||||||
|
// the file is a corrupt / mis-lengthed WAV. That header arithmetic — chunk walking,
|
||||||
|
// format verification, and the size-field patch offsets — is exactly the fiddly,
|
||||||
|
// easy-to-get-wrong logic the discipline unit-tests OUTSIDE the DAW. The REAPER
|
||||||
|
// shell (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.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// therefore verifies canonical PCM/IEEE-float WAV: a RIFF/WAVE container, a `fmt `
|
||||||
|
// chunk declaring 32-bit float (format tag 3, or tag 0xFFFE WAVE_FORMAT_EXTENSIBLE
|
||||||
|
// with 32 bits), and a `data` chunk of interleaved little-endian float32. Anything
|
||||||
|
// else (a different depth, a non-WAV, a compressed source) is reported invalid and
|
||||||
|
// the shell SKIPS the trim (keeps the untrimmed window) rather than corrupting a
|
||||||
|
// file it does not understand. This is deliberately conservative.
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "peaks.h" // AudioSample (float)
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
// The parsed geometry of a canonical 32-bit-float WAV. `valid` is false when the
|
||||||
|
// bytes are not a WAV we can safely trim (see FORMAT ASSUMPTION); every other field
|
||||||
|
// is meaningful only when valid.
|
||||||
|
struct WavLayout {
|
||||||
|
bool valid = false;
|
||||||
|
|
||||||
|
std::uint16_t channelCount = 0; // from `fmt ` (the interleave stride)
|
||||||
|
std::uint32_t sampleRate = 0; // from `fmt ` (for frame<->seconds, if needed)
|
||||||
|
|
||||||
|
// The `data` chunk: byte offset of its first PCM byte within the file, and its
|
||||||
|
// declared PCM byte length. frameCount = dataByteLength / (channelCount * 4).
|
||||||
|
std::size_t dataByteOffset = 0;
|
||||||
|
std::size_t dataByteLength = 0;
|
||||||
|
|
||||||
|
// Byte offset of the two little-endian uint32 size fields the truncate patch
|
||||||
|
// rewrites: the top-level RIFF chunk size (bytes 4..7) and the `data` sub-chunk
|
||||||
|
// size (the 4 bytes immediately before dataByteOffset).
|
||||||
|
std::size_t riffSizeFieldOffset = 4; // always 4 for a RIFF file
|
||||||
|
std::size_t dataSizeFieldOffset = 0;
|
||||||
|
|
||||||
|
std::size_t frameCount() const {
|
||||||
|
const std::size_t bytesPerFrame = static_cast<std::size_t>(channelCount) * 4u;
|
||||||
|
return bytesPerFrame ? dataByteLength / bytesPerFrame : 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parses a WAV byte buffer's header geometry. Returns {valid=false} for anything
|
||||||
|
// that is not a canonical 32-bit-float RIFF/WAVE with a `fmt ` and a `data` chunk,
|
||||||
|
// or whose declared `data` length runs past the buffer. Does NOT copy PCM — it only
|
||||||
|
// locates it (extractFloatFrames does the copy). Pure + total (no throw, no UB).
|
||||||
|
WavLayout parseWavLayout(const std::vector<std::uint8_t>& bytes);
|
||||||
|
|
||||||
|
// Copies `frameCount` interleaved float frames starting at `startFrame` out of the
|
||||||
|
// WAV's `data` region into a flat [f0c0,f0c1,...] buffer (the shape peaks consumes).
|
||||||
|
// Clamps to the frames the buffer actually holds — never reads past `data`. Returns
|
||||||
|
// empty for an invalid layout or an out-of-range start. The floats are read
|
||||||
|
// little-endian via std::memcpy (no aliasing UB); on a big-endian host they would
|
||||||
|
// need a byte-swap — flagged, not handled, because the target (Windows/macOS/Linux
|
||||||
|
// on x86/ARM-LE) is little-endian and REAPER writes LE WAV.
|
||||||
|
std::vector<AudioSample> extractFloatFrames(const std::vector<std::uint8_t>& bytes,
|
||||||
|
const WavLayout& layout,
|
||||||
|
std::size_t startFrame,
|
||||||
|
std::size_t frameCount);
|
||||||
|
|
||||||
|
// The plan to truncate a parsed WAV to `keptFrames` frames: the new total file byte
|
||||||
|
// length and the two size-field values to patch. `valid` is false if the layout is
|
||||||
|
// invalid or keptFrames exceeds the file's frames (never GROW a file — the caller
|
||||||
|
// clamps beforehand; this guards it too).
|
||||||
|
struct WavTruncatePlan {
|
||||||
|
bool valid = false;
|
||||||
|
|
||||||
|
std::size_t newFileByteLength = 0; // truncate the file to exactly this length
|
||||||
|
std::size_t dataSizeFieldOffset = 0; // where to write newDataSize (LE uint32)
|
||||||
|
std::uint32_t newDataSize = 0; // kept PCM byte length
|
||||||
|
std::size_t riffSizeFieldOffset = 4; // where to write newRiffSize (LE uint32)
|
||||||
|
std::uint32_t newRiffSize = 0; // newFileByteLength - 8 (RIFF size excludes
|
||||||
|
// the 8-byte "RIFF"+size prefix)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Computes the truncate plan to keep exactly `keptFrames` frames of a parsed WAV.
|
||||||
|
// keptFrames == layout.frameCount() is a valid no-op plan (file unchanged). Pure +
|
||||||
|
// total. The shell applies it: patch the two size fields in the byte buffer, then
|
||||||
|
// truncate the file to newFileByteLength.
|
||||||
|
WavTruncatePlan planWavTruncate(const WavLayout& layout, std::size_t keptFrames);
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
#include "../src/bank_grid.h"
|
#include "../src/bank_grid.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -322,6 +323,52 @@ static void testNavDegenerate() {
|
|||||||
CHECK(selEq(navigate(Selection{{0}, 0, 0}, NavKey::Down, 0, 4, false), {1}, 1, 1));
|
CHECK(selEq(navigate(Selection{{0}, 0, 0}, NavKey::Down, 0, 4, false), {1}, 1, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- compressAmplitudeForDisplay ----------------------------------------------
|
||||||
|
|
||||||
|
// Full scale: magnitude 1.0 must reach the full display fraction exactly.
|
||||||
|
static void testCompressFullScale() {
|
||||||
|
CHECK(compressAmplitudeForDisplay(1.0f) == 1.0f);
|
||||||
|
CHECK(compressAmplitudeForDisplay(-1.0f) == -1.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact zero must stay on the midline (no log of zero; guards the singularity).
|
||||||
|
static void testCompressZeroIsMidline() {
|
||||||
|
CHECK(compressAmplitudeForDisplay(0.0f) == 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -20 dB (0.1 linear) and -40 dB (0.01 linear) must both produce clearly visible
|
||||||
|
// (non-zero) fractions, with -20 dB > -40 dB (monotonic), and both well above
|
||||||
|
// the midline (arbitrary threshold of 0.15 chosen conservatively — at a -60 dB
|
||||||
|
// floor, -20 dB normalizes to 2/3 and -40 dB to 1/3).
|
||||||
|
static void testCompressMidValuesVisible() {
|
||||||
|
const float f20 = compressAmplitudeForDisplay(0.1f); // -20 dBFS
|
||||||
|
const float f40 = compressAmplitudeForDisplay(0.01f); // -40 dBFS
|
||||||
|
CHECK(f20 > 0.15f); // clearly non-zero
|
||||||
|
CHECK(f40 > 0.15f); // clearly non-zero
|
||||||
|
CHECK(f20 > f40); // monotonic: louder -> taller bar
|
||||||
|
}
|
||||||
|
|
||||||
|
// At and below the floor (-60 dB = 0.001 linear) the result is ~0 (silence).
|
||||||
|
// We test at exactly the floor magnitude and well below it.
|
||||||
|
static void testCompressAtAndBelowFloor() {
|
||||||
|
// 0.001 == 10^(-60/20) is the floor ratio. Magnitude at or below it -> 0.
|
||||||
|
const float floorMag = std::pow(10.0f, kDisplayFloorDb / 20.0f); // ~0.001
|
||||||
|
CHECK(compressAmplitudeForDisplay(floorMag) == 0.0f);
|
||||||
|
CHECK(compressAmplitudeForDisplay(floorMag * 0.5f) == 0.0f);
|
||||||
|
CHECK(compressAmplitudeForDisplay(0.0001f) == 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign is preserved: negative input produces a negative fraction of the same
|
||||||
|
// magnitude as its positive counterpart.
|
||||||
|
static void testCompressSignPreserved() {
|
||||||
|
const float pos = compressAmplitudeForDisplay(0.1f);
|
||||||
|
const float neg = compressAmplitudeForDisplay(-0.1f);
|
||||||
|
CHECK(neg < 0.0f);
|
||||||
|
// Magnitudes must be equal (sign-symmetric).
|
||||||
|
const float diff = pos + neg; // pos - |neg|
|
||||||
|
CHECK(diff > -0.001f && diff < 0.001f);
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testColumnsForWidth();
|
testColumnsForWidth();
|
||||||
testTooNarrowClampsToOneColumn();
|
testTooNarrowClampsToOneColumn();
|
||||||
@@ -355,6 +402,12 @@ int main() {
|
|||||||
testNavFromEmptyFocusesFirst();
|
testNavFromEmptyFocusesFirst();
|
||||||
testNavDegenerate();
|
testNavDegenerate();
|
||||||
|
|
||||||
|
testCompressFullScale();
|
||||||
|
testCompressZeroIsMidline();
|
||||||
|
testCompressMidValuesVisible();
|
||||||
|
testCompressAtAndBelowFloor();
|
||||||
|
testCompressSignPreserved();
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||||
return g_fail ? 1 : 0;
|
return g_fail ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// Standalone tests for reasampler::newGuids + GuidBaseline — no REAPER, no test
|
||||||
|
// framework. Mirror of test_view_mode_model: iterate the hard logic outside the DAW.
|
||||||
|
//
|
||||||
|
// Covers (D2 Wave-2 new-content detection):
|
||||||
|
// 1. newGuids: current \ previous, empty-GUID filtering, determinism.
|
||||||
|
// 2. GuidBaseline first-poll guard: the first observe() after open reports NOTHING
|
||||||
|
// new (pre-existing content stays Arrange) and establishes the baseline.
|
||||||
|
// 3. Incremental detection: only GUIDs added since the prior observe() are returned.
|
||||||
|
// 4. Deletion drops from the baseline so a reused GUID is re-detected.
|
||||||
|
// 5. reset() (project switch) re-arms the first-poll guard: the next observe()
|
||||||
|
// re-baselines and reports nothing new — never diffs across projects.
|
||||||
|
|
||||||
|
#include "../src/guid_diff.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <set>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace reasampler;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
static bool has(const std::vector<std::string>& v, const std::string& g) {
|
||||||
|
for (const auto& e : v) if (e == g) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 1. newGuids set difference ----------------------------------------------
|
||||||
|
|
||||||
|
static void testNewGuidsDifference() {
|
||||||
|
std::set<std::string> prev{"{A}", "{B}"};
|
||||||
|
std::set<std::string> cur{"{A}", "{B}", "{C}", "{D}"};
|
||||||
|
|
||||||
|
auto added = newGuids(prev, cur);
|
||||||
|
CHECK(added.size() == 2);
|
||||||
|
CHECK(has(added, "{C}"));
|
||||||
|
CHECK(has(added, "{D}"));
|
||||||
|
CHECK(!has(added, "{A}")); // pre-existing, not new
|
||||||
|
CHECK(!has(added, "{B}"));
|
||||||
|
|
||||||
|
// No change ⇒ nothing new.
|
||||||
|
CHECK(newGuids(cur, cur).empty());
|
||||||
|
|
||||||
|
// A removed GUID is not "new" (it is absent from current).
|
||||||
|
std::set<std::string> shrunk{"{A}"};
|
||||||
|
CHECK(newGuids(prev, shrunk).empty());
|
||||||
|
|
||||||
|
// Determinism: ascending set order.
|
||||||
|
std::set<std::string> p2;
|
||||||
|
std::set<std::string> c2{"{Z}", "{A}", "{M}"};
|
||||||
|
auto ordered = newGuids(p2, c2);
|
||||||
|
CHECK(ordered.size() == 3);
|
||||||
|
CHECK(ordered[0] == "{A}" && ordered[1] == "{M}" && ordered[2] == "{Z}");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testNewGuidsIgnoresEmpty() {
|
||||||
|
std::set<std::string> prev{"{A}"};
|
||||||
|
std::set<std::string> cur{"", "{A}", "{B}"}; // empty ⇒ a GUID-read failure
|
||||||
|
auto added = newGuids(prev, cur);
|
||||||
|
CHECK(added.size() == 1);
|
||||||
|
CHECK(has(added, "{B}"));
|
||||||
|
CHECK(!has(added, "")); // never tag an empty GUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 2. First-poll guard -----------------------------------------------------
|
||||||
|
|
||||||
|
static void testBaselineFirstPollReportsNothing() {
|
||||||
|
GuidBaseline b;
|
||||||
|
CHECK(!b.primed());
|
||||||
|
// First observe after open: pre-existing content must NOT be tagged.
|
||||||
|
auto first = b.observe({"{A}", "{B}", "{C}"});
|
||||||
|
CHECK(first.empty()); // nothing new at open
|
||||||
|
CHECK(b.primed());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 3. Incremental detection ------------------------------------------------
|
||||||
|
|
||||||
|
static void testBaselineIncremental() {
|
||||||
|
GuidBaseline b;
|
||||||
|
b.observe({"{A}", "{B}"}); // baseline
|
||||||
|
auto t1 = b.observe({"{A}", "{B}", "{C}"});
|
||||||
|
CHECK(t1.size() == 1 && has(t1, "{C}")); // only the newly-added GUID
|
||||||
|
|
||||||
|
// Next tick with a further addition — earlier-added {C} is now baseline.
|
||||||
|
auto t2 = b.observe({"{A}", "{B}", "{C}", "{D}"});
|
||||||
|
CHECK(t2.size() == 1 && has(t2, "{D}"));
|
||||||
|
CHECK(!has(t2, "{C}"));
|
||||||
|
|
||||||
|
// A steady state reports nothing new.
|
||||||
|
CHECK(b.observe({"{A}", "{B}", "{C}", "{D}"}).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 4. Deletion drops from baseline; reused GUID re-detected ----------------
|
||||||
|
|
||||||
|
static void testBaselineDeletionReDetect() {
|
||||||
|
GuidBaseline b;
|
||||||
|
b.observe({"{A}", "{B}"});
|
||||||
|
// Delete {B}: not "new", and drops out of the baseline.
|
||||||
|
CHECK(b.observe({"{A}"}).empty());
|
||||||
|
// {B} reappears (REAPER reused the GUID or the user re-added) ⇒ detected again.
|
||||||
|
auto again = b.observe({"{A}", "{B}"});
|
||||||
|
CHECK(again.size() == 1 && has(again, "{B}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 5. reset() re-arms the first-poll guard (project switch) -----------------
|
||||||
|
|
||||||
|
static void testResetReBaselines() {
|
||||||
|
GuidBaseline b;
|
||||||
|
b.observe({"{A}"}); // project 1 baseline
|
||||||
|
b.observe({"{A}", "{B}"}); // {B} detected in project 1
|
||||||
|
|
||||||
|
b.reset();
|
||||||
|
CHECK(!b.primed());
|
||||||
|
// Switching to project 2: its pre-existing content must NOT be mass-tagged even
|
||||||
|
// though those GUIDs were never seen before reset.
|
||||||
|
auto afterSwitch = b.observe({"{X}", "{Y}", "{Z}"});
|
||||||
|
CHECK(afterSwitch.empty()); // re-baselined, nothing new
|
||||||
|
CHECK(b.primed());
|
||||||
|
// Content created in project 2 after the switch IS detected.
|
||||||
|
auto p2new = b.observe({"{X}", "{Y}", "{Z}", "{W}"});
|
||||||
|
CHECK(p2new.size() == 1 && has(p2new, "{W}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 6. Reload-mis-tag regression: a project LOAD must re-baseline before the first
|
||||||
|
// post-load observe, so the newly-loaded project's PRE-EXISTING content is never
|
||||||
|
// reported as new. This locks the exact failure behind the reload-mis-tag bug:
|
||||||
|
// the detector used to re-arm on a `proj != lastProject` pointer compare, which a
|
||||||
|
// recycled ReaProject* address defeats; the previous project's stale baseline then
|
||||||
|
// reported the whole just-loaded project as new content and it got mass-tagged into
|
||||||
|
// the active mode. The fix routes the re-arm through persist's authoritative load
|
||||||
|
// signal (bankPanelNotifyProjectLoaded -> reset()), modeled here as: on a load,
|
||||||
|
// reset() runs BEFORE the first observe of the new project's set.
|
||||||
|
//
|
||||||
|
// The seam under test is GuidBaseline; the shell wiring (main.cpp notify ->
|
||||||
|
// bank_panel reset()) is DAW-verified, but the load-then-observe DECISION lives
|
||||||
|
// here and is what the bug got wrong.
|
||||||
|
static void testReloadReBaselinesBeforeFirstObserve() {
|
||||||
|
// Project A is open and settled: its content is the baseline, steady state reports
|
||||||
|
// nothing new. This is the "extension already running against project A" precondition
|
||||||
|
// the bug needs (a NON-empty stale baseline to mis-diff the next project against).
|
||||||
|
GuidBaseline b;
|
||||||
|
b.observe({"{A1}", "{A2}"}); // A baseline (first-poll guard)
|
||||||
|
CHECK(b.observe({"{A1}", "{A2}"}).empty()); // steady: nothing new
|
||||||
|
CHECK(b.primed());
|
||||||
|
|
||||||
|
// Daniel opens project B (saved in Design). B's pre-existing tracks are an ENTIRELY
|
||||||
|
// different GUID set from A. persist raises its load signal; the fix calls reset()
|
||||||
|
// (via bankPanelNotifyProjectLoaded) BEFORE the first post-load observe.
|
||||||
|
b.reset();
|
||||||
|
auto afterLoad = b.observe({"{B1}", "{B2}", "{B3}"});
|
||||||
|
// The load must tag NOTHING: B's pre-existing content is the baseline, not "new".
|
||||||
|
// Untagged/Arrange leaves stay Arrange; nothing is mass-tagged into Design.
|
||||||
|
CHECK(afterLoad.empty());
|
||||||
|
|
||||||
|
// And genuine post-load creation in B is still detected (the fix must not deafen the
|
||||||
|
// detector — only suppress the pre-existing set at the load boundary).
|
||||||
|
auto createdInB = b.observe({"{B1}", "{B2}", "{B3}", "{B4}"});
|
||||||
|
CHECK(createdInB.size() == 1 && has(createdInB, "{B4}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 6b. Negative control: WITHOUT the load re-baseline (the old pointer-miss path where
|
||||||
|
// reset() never fired), the just-loaded project's pre-existing content IS reported
|
||||||
|
// as new — i.e. it would be mass-tagged. This proves the assertion in test 6 is
|
||||||
|
// load-bearing (the reset() is what prevents the mis-tag), not self-affirming.
|
||||||
|
static void testMissingReBaselineWouldMisTag() {
|
||||||
|
GuidBaseline b;
|
||||||
|
b.observe({"{A1}", "{A2}"}); // A baseline
|
||||||
|
b.observe({"{A1}", "{A2}"}); // settled against A
|
||||||
|
|
||||||
|
// Simulate the BUG: no reset() on the load (the pointer compare missed a recycled
|
||||||
|
// ReaProject*). The next observe diffs B's set against A's stale baseline.
|
||||||
|
auto misdetected = b.observe({"{B1}", "{B2}", "{B3}"});
|
||||||
|
// Every one of B's pre-existing tracks looks "new" — exactly the mass-tag that
|
||||||
|
// parked the Arrange tracks into Design on open. This is the failure the fix removes.
|
||||||
|
CHECK(misdetected.size() == 3);
|
||||||
|
CHECK(has(misdetected, "{B1}") && has(misdetected, "{B2}") && has(misdetected, "{B3}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testNewGuidsDifference();
|
||||||
|
testNewGuidsIgnoresEmpty();
|
||||||
|
testBaselineFirstPollReportsNothing();
|
||||||
|
testBaselineIncremental();
|
||||||
|
testBaselineDeletionReDetect();
|
||||||
|
testResetReBaselines();
|
||||||
|
testReloadReBaselinesBeforeFirstObserve();
|
||||||
|
testMissingReBaselineWouldMisTag();
|
||||||
|
|
||||||
|
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||||
|
return g_fail ? 1 : 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Standalone tests for reasampler::lane_keys — no REAPER, no test framework. The pure
|
||||||
|
// managed/manual lane-name heuristic that resolves D2 design points #1 (auto-tag
|
||||||
|
// exemption) and #2 (durable lane identity vs ordinal renumber).
|
||||||
|
//
|
||||||
|
// Covers:
|
||||||
|
// 1. isManagedLaneName: only the "reasampler:" prefix is managed; everything else
|
||||||
|
// (empty, user comp names, near-miss prefixes) is manual.
|
||||||
|
// 2. managedLaneKey: managed name -> its durable key; manual/unnamed -> nullopt.
|
||||||
|
// 3. Round-trip: managedLaneKey(laneNameForMode(m)) == "reasampler:" + m, so the
|
||||||
|
// Wave-3 minting path and the read path cannot drift.
|
||||||
|
|
||||||
|
#include "../src/lane_keys.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
using namespace reasampler;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
static void testIsManagedLaneName() {
|
||||||
|
// Tool-minted managed names.
|
||||||
|
CHECK(isManagedLaneName("reasampler:design"));
|
||||||
|
CHECK(isManagedLaneName("reasampler:arrange"));
|
||||||
|
CHECK(isManagedLaneName("reasampler:")); // prefix alone still ours (odd but managed)
|
||||||
|
|
||||||
|
// Manual / user lanes are never managed.
|
||||||
|
CHECK(!isManagedLaneName("")); // unnamed lane ⇒ manual
|
||||||
|
CHECK(!isManagedLaneName("Comp 1")); // user comp lane
|
||||||
|
CHECK(!isManagedLaneName("Lead vocal"));
|
||||||
|
CHECK(!isManagedLaneName("reasample")); // near-miss, no colon ⇒ not ours
|
||||||
|
CHECK(!isManagedLaneName("Reasampler:design")); // case-sensitive prefix
|
||||||
|
CHECK(!isManagedLaneName(" reasampler:x")); // leading space ⇒ not a prefix match
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testManagedLaneKey() {
|
||||||
|
// Managed lane: the durable name IS the key.
|
||||||
|
auto k = managedLaneKey("reasampler:design");
|
||||||
|
CHECK(k.has_value() && *k == "reasampler:design");
|
||||||
|
|
||||||
|
// Manual / unnamed lanes have no managed key (⇒ treated as manual, never driven).
|
||||||
|
CHECK(!managedLaneKey("").has_value());
|
||||||
|
CHECK(!managedLaneKey("Comp 1").has_value());
|
||||||
|
CHECK(!managedLaneKey("guitar-double").has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testIsOnManualLane() {
|
||||||
|
// Non-fixed-lane track: concept does not apply regardless of name.
|
||||||
|
CHECK(!isOnManualLane(false, "")); // normal track, unnamed ⇒ not manual
|
||||||
|
CHECK(!isOnManualLane(false, "Comp 1")); // normal track, user name ⇒ not manual
|
||||||
|
CHECK(!isOnManualLane(false, "reasampler:design")); // normal track, managed name ⇒ not manual
|
||||||
|
|
||||||
|
// Fixed-lane track: managed lane (tool-prefixed) ⇒ NOT manual (tool drives it).
|
||||||
|
CHECK(!isOnManualLane(true, "reasampler:design"));
|
||||||
|
CHECK(!isOnManualLane(true, "reasampler:arrange"));
|
||||||
|
CHECK(!isOnManualLane(true, "reasampler:")); // prefix-only: still managed
|
||||||
|
|
||||||
|
// Fixed-lane track: unnamed lane (empty P_LANENAME) ⇒ manual.
|
||||||
|
// REAPER starts fixed lanes unnamed; an item on an unnamed fixed lane is a user
|
||||||
|
// comp lane and must be exempt from auto-tag.
|
||||||
|
CHECK(isOnManualLane(true, ""));
|
||||||
|
|
||||||
|
// Fixed-lane track: user-named but non-managed ⇒ manual.
|
||||||
|
CHECK(isOnManualLane(true, "Comp 1"));
|
||||||
|
CHECK(isOnManualLane(true, "Lead vocal"));
|
||||||
|
CHECK(isOnManualLane(true, "reasample")); // near-miss, no colon ⇒ manual
|
||||||
|
CHECK(isOnManualLane(true, "Reasampler:x")); // wrong case ⇒ manual
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRoundTrip() {
|
||||||
|
// Minting then reading must agree: managedLaneKey(laneNameForMode(m)) recovers the
|
||||||
|
// prefixed name for every mode id.
|
||||||
|
for (const std::string mode : {std::string("arrange"), std::string("design"),
|
||||||
|
std::string("mixdown")}) {
|
||||||
|
const std::string name = laneNameForMode(mode);
|
||||||
|
CHECK(name == "reasampler:" + mode);
|
||||||
|
CHECK(isManagedLaneName(name));
|
||||||
|
auto key = managedLaneKey(name);
|
||||||
|
CHECK(key.has_value() && *key == "reasampler:" + mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testModeIdFromLaneName() {
|
||||||
|
// The exact inverse of laneNameForMode: recover the owning mode from a managed name.
|
||||||
|
// Used by the Wave-3 load-time reconcile to rebuild ownership from durable names.
|
||||||
|
for (const std::string mode : {std::string("arrange"), std::string("design"),
|
||||||
|
std::string("mixdown"), std::string("mode:with:colons")}) {
|
||||||
|
auto recovered = modeIdFromLaneName(laneNameForMode(mode));
|
||||||
|
CHECK(recovered.has_value() && *recovered == mode); // modeIdFromLaneName∘laneNameForMode == id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual / unnamed lanes carry no mode (⇒ left off the ownership index on reconcile).
|
||||||
|
CHECK(!modeIdFromLaneName("").has_value());
|
||||||
|
CHECK(!modeIdFromLaneName("Comp 1").has_value());
|
||||||
|
CHECK(!modeIdFromLaneName("Reasampler:design").has_value()); // wrong case ⇒ manual
|
||||||
|
|
||||||
|
// Prefix-only with no mode suffix is illegal for a managed lane ⇒ no mode recovered
|
||||||
|
// (defensive: reconcile skips it rather than recording an empty-mode ownership).
|
||||||
|
CHECK(!modeIdFromLaneName("reasampler:").has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testIsManagedLaneName();
|
||||||
|
testManagedLaneKey();
|
||||||
|
testIsOnManualLane();
|
||||||
|
testRoundTrip();
|
||||||
|
testModeIdFromLaneName();
|
||||||
|
|
||||||
|
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||||
|
return g_fail ? 1 : 0;
|
||||||
|
}
|
||||||
@@ -278,6 +278,76 @@ static void testLargeBinCountOverflowGuard() {
|
|||||||
CHECK(env[0][7].min == 0.0f && env[0][7].max == 0.0f);
|
CHECK(env[0][7].min == 0.0f && env[0][7].max == 0.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- lastFrameAboveThreshold: the realtime tail's decay-scan boundary primitive --
|
||||||
|
|
||||||
|
// A mono decaying ramp: frame i has amplitude that falls linearly to zero. With a
|
||||||
|
// threshold set between two frames' levels, the last frame above it is deterministic.
|
||||||
|
static void testLastFrameDecayingRamp() {
|
||||||
|
// 10 mono frames, amplitude 1.0 - i*0.1: frame0=1.0 ... frame9=0.1.
|
||||||
|
std::vector<AudioSample> buf(10);
|
||||||
|
for (std::size_t i = 0; i < 10; ++i) buf[i] = 1.0f - 0.1f * (float)i;
|
||||||
|
|
||||||
|
// Threshold 0.35: frames 0..6 (levels 1.0..0.4) exceed it; frame 6 is the last
|
||||||
|
// (level 0.4 > 0.35), frame 7 (0.3) does not. Strict > semantics.
|
||||||
|
CHECK(lastFrameAboveThreshold(buf, 1, 10, 0.35f) == 6);
|
||||||
|
|
||||||
|
// Threshold just under frame 9's level (0.1): the very last frame stays.
|
||||||
|
CHECK(lastFrameAboveThreshold(buf, 1, 10, 0.05f) == 9);
|
||||||
|
|
||||||
|
// Threshold above the loudest frame: nothing survives.
|
||||||
|
CHECK(lastFrameAboveThreshold(buf, 1, 10, 1.5f) == kNoFrameAboveThreshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure silence at or below the threshold -> sentinel (the "trim back to end" case:
|
||||||
|
// no frame in the tail window exceeds -72 dB).
|
||||||
|
static void testLastFrameSilence() {
|
||||||
|
std::vector<AudioSample> zeros(20, 0.0f);
|
||||||
|
CHECK(lastFrameAboveThreshold(zeros, 2, 10, 0.001f) == kNoFrameAboveThreshold);
|
||||||
|
|
||||||
|
// A DC level exactly AT the threshold does not count (strict >).
|
||||||
|
std::vector<AudioSample> atThresh(8, 0.25f);
|
||||||
|
CHECK(lastFrameAboveThreshold(atThresh, 1, 8, 0.25f) == kNoFrameAboveThreshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every frame above the threshold (a non-decaying source): the last frame is the
|
||||||
|
// boundary — the caller keeps the whole window (the 8 s cap did its job).
|
||||||
|
static void testLastFrameAllAbove() {
|
||||||
|
std::vector<AudioSample> loud(12, 0.8f); // 6 stereo frames
|
||||||
|
CHECK(lastFrameAboveThreshold(loud, 2, 6, 0.1f) == 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-frame peak is the MAX abs across channels (no fold): a frame with one loud
|
||||||
|
// channel and one silent channel is "above" on the strength of the loud one, and a
|
||||||
|
// negative sample is compared by magnitude.
|
||||||
|
static void testLastFramePerChannelMaxAbs() {
|
||||||
|
// 3 stereo frames. Frame0: (0.9, 0.0) loud L. Frame1: (0.0, -0.9) loud R (negative
|
||||||
|
// -> abs). Frame2: (0.05, -0.05) both quiet.
|
||||||
|
std::vector<AudioSample> buf = {0.9f, 0.0f, 0.0f, -0.9f, 0.05f, -0.05f};
|
||||||
|
// Threshold 0.5: frame2 is below (peak 0.05), frame1 is above (|-0.9|=0.9).
|
||||||
|
CHECK(lastFrameAboveThreshold(buf, 2, 3, 0.5f) == 1);
|
||||||
|
// If both channels of the last frame mattered independently, a fold-average
|
||||||
|
// (0.9+0.0)/2 = 0.45 on frame0 would fall below 0.5 — but frame0's L alone (0.9)
|
||||||
|
// is above, proving max-abs, not average. Lower the threshold to isolate frame0.
|
||||||
|
std::vector<AudioSample> f0 = {0.9f, 0.0f};
|
||||||
|
CHECK(lastFrameAboveThreshold(f0, 2, 1, 0.5f) == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Degenerate: zero channels, zero frames, and a frameCount that overstates the
|
||||||
|
// buffer (must clamp to available frames, no OOB read).
|
||||||
|
static void testLastFrameDegenerate() {
|
||||||
|
std::vector<AudioSample> buf = {0.5f, 0.5f, 0.5f, 0.5f}; // 2 stereo frames
|
||||||
|
|
||||||
|
CHECK(lastFrameAboveThreshold(buf, 0, 2, 0.1f) == kNoFrameAboveThreshold);
|
||||||
|
CHECK(lastFrameAboveThreshold(buf, 2, 0, 0.1f) == kNoFrameAboveThreshold);
|
||||||
|
|
||||||
|
std::vector<AudioSample> empty;
|
||||||
|
CHECK(lastFrameAboveThreshold(empty, 2, 10, 0.1f) == kNoFrameAboveThreshold);
|
||||||
|
|
||||||
|
// frameCount=100 but only 2 real stereo frames: clamps to frame 1 (the last real
|
||||||
|
// frame), which is above -> index 1, no read past the buffer.
|
||||||
|
CHECK(lastFrameAboveThreshold(buf, 2, 100, 0.1f) == 1);
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testSineEnvelope();
|
testSineEnvelope();
|
||||||
testRampMonotonic();
|
testRampMonotonic();
|
||||||
@@ -289,6 +359,11 @@ int main() {
|
|||||||
testSingleBinWholeBuffer();
|
testSingleBinWholeBuffer();
|
||||||
testDegenerateInputs();
|
testDegenerateInputs();
|
||||||
testLargeBinCountOverflowGuard();
|
testLargeBinCountOverflowGuard();
|
||||||
|
testLastFrameDecayingRamp();
|
||||||
|
testLastFrameSilence();
|
||||||
|
testLastFrameAllAbove();
|
||||||
|
testLastFramePerChannelMaxAbs();
|
||||||
|
testLastFrameDegenerate();
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||||
return g_fail ? 1 : 0;
|
return g_fail ? 1 : 0;
|
||||||
|
|||||||
@@ -126,6 +126,31 @@ static void testTailManualClampsToCap() {
|
|||||||
CHECK(tailRenderSettingsFor(TailMode::Manual, -50.0).tailMs == 0.0);
|
CHECK(tailRenderSettingsFor(TailMode::Manual, -50.0).tailMs == 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- realtimeRecordWindowEnd: the T2 record-window extension -----------------
|
||||||
|
|
||||||
|
static void testRealtimeWindowNoneIsExact() {
|
||||||
|
// None -> the exact range end, no extra recording (byte-identical to today).
|
||||||
|
CHECK(realtimeRecordWindowEnd(TailMode::None, 12.5, 2000.0) == 12.5);
|
||||||
|
// manualTailMs is ignored for None.
|
||||||
|
CHECK(realtimeRecordWindowEnd(TailMode::None, 12.5, 0.0) == 12.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRealtimeWindowAutoAddsCap() {
|
||||||
|
// Auto -> range end + the 8 s runaway cap (trimmed later by the decay scan).
|
||||||
|
CHECK(realtimeRecordWindowEnd(TailMode::Auto, 10.0, 0.0) == 10.0 + kMaxTailSeconds);
|
||||||
|
// manualTailMs is ignored for Auto (the cap is fixed).
|
||||||
|
CHECK(realtimeRecordWindowEnd(TailMode::Auto, 10.0, 3000.0) == 10.0 + kMaxTailSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRealtimeWindowManualAddsClampedLength() {
|
||||||
|
// Manual -> range end + the set length in seconds (fixed, no trim).
|
||||||
|
CHECK(realtimeRecordWindowEnd(TailMode::Manual, 5.0, 2000.0) == 5.0 + 2.0);
|
||||||
|
// Clamped to the 8 s cap: > 8000 ms -> +8 s.
|
||||||
|
CHECK(realtimeRecordWindowEnd(TailMode::Manual, 5.0, 9000.0) == 5.0 + kMaxTailSeconds);
|
||||||
|
// Negative floors to 0 -> no extra window (never records before the range end).
|
||||||
|
CHECK(realtimeRecordWindowEnd(TailMode::Manual, 5.0, -100.0) == 5.0);
|
||||||
|
}
|
||||||
|
|
||||||
// --- parseRazorEdits: P_RAZOREDITS string -> ranges --------------------------
|
// --- parseRazorEdits: P_RAZOREDITS string -> ranges --------------------------
|
||||||
|
|
||||||
static void testParseSingleTrackAudioArea() {
|
static void testParseSingleTrackAudioArea() {
|
||||||
@@ -267,6 +292,9 @@ int main() {
|
|||||||
testAutoTrimRatioDerivesFromDb();
|
testAutoTrimRatioDerivesFromDb();
|
||||||
testTailManualFixedNoTrim();
|
testTailManualFixedNoTrim();
|
||||||
testTailManualClampsToCap();
|
testTailManualClampsToCap();
|
||||||
|
testRealtimeWindowNoneIsExact();
|
||||||
|
testRealtimeWindowAutoAddsCap();
|
||||||
|
testRealtimeWindowManualAddsClampedLength();
|
||||||
testParseSingleTrackAudioArea();
|
testParseSingleTrackAudioArea();
|
||||||
testParseMultipleAreas();
|
testParseMultipleAreas();
|
||||||
testParseSkipsEnvelopeLaneAreas();
|
testParseSkipsEnvelopeLaneAreas();
|
||||||
|
|||||||
@@ -49,15 +49,53 @@ static void testManualClampCapsAtEightSeconds() {
|
|||||||
CHECK(clampManualMs(-100.0) == 0.0);
|
CHECK(clampManualMs(-100.0) == 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- adjustManualMs: the scroll-wheel fine-adjust arithmetic ------------------
|
||||||
|
|
||||||
|
static void testAdjustUpAndDownBySteps() {
|
||||||
|
// Positive notches lengthen, negative shorten, in whole kManualStepMs increments.
|
||||||
|
CHECK(adjustManualMs(2000.0, 1, kManualStepMs) == 2250.0);
|
||||||
|
CHECK(adjustManualMs(2000.0, -1, kManualStepMs) == 1750.0);
|
||||||
|
CHECK(adjustManualMs(2000.0, 4, kManualStepMs) == 3000.0); // 4 * 250
|
||||||
|
CHECK(adjustManualMs(2000.0, 0, kManualStepMs) == 2000.0); // no notch, no move
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testAdjustClampsAtUpperBound() {
|
||||||
|
// Scrolling up past the 8 s cap saturates AT the cap, never beyond.
|
||||||
|
CHECK(adjustManualMs(kMaxTailMs, 1, kManualStepMs) == kMaxTailMs);
|
||||||
|
CHECK(adjustManualMs(kMaxTailMs - 100.0, 10, kManualStepMs) == kMaxTailMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testAdjustClampsAtLowerBound() {
|
||||||
|
// Scrolling down past 0 floors at 0, never negative.
|
||||||
|
CHECK(adjustManualMs(0.0, -1, kManualStepMs) == 0.0);
|
||||||
|
CHECK(adjustManualMs(100.0, -10, kManualStepMs) == 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
// --- tailToggleLabel: the exact strings the panel draws -----------------------
|
// --- tailToggleLabel: the exact strings the panel draws -----------------------
|
||||||
|
|
||||||
static void testLabelStringsPerMode() {
|
static void testLabelStringsPerMode() {
|
||||||
TailSetting off; off.mode = TailMode::None;
|
TailSetting off; off.mode = TailMode::None;
|
||||||
TailSetting autoM; autoM.mode = TailMode::Auto;
|
TailSetting autoM; autoM.mode = TailMode::Auto;
|
||||||
TailSetting man; man.mode = TailMode::Manual;
|
// Off/Auto carry NO length regardless of manualMs.
|
||||||
|
off.manualMs = 5000.0;
|
||||||
|
autoM.manualMs = 5000.0;
|
||||||
CHECK(tailToggleLabel(off) == "Tail: Off");
|
CHECK(tailToggleLabel(off) == "Tail: Off");
|
||||||
CHECK(tailToggleLabel(autoM) == "Tail: Auto");
|
CHECK(tailToggleLabel(autoM) == "Tail: Auto");
|
||||||
CHECK(tailToggleLabel(man) == "Tail: Manual");
|
}
|
||||||
|
|
||||||
|
static void testManualLabelRendersLengthInSeconds() {
|
||||||
|
// Manual appends the length in seconds to one decimal — pin the format and the
|
||||||
|
// boundary values (0.0s, the 2 s default, the 8 s cap).
|
||||||
|
TailSetting man; man.mode = TailMode::Manual;
|
||||||
|
man.manualMs = 0.0;
|
||||||
|
CHECK(tailToggleLabel(man) == "Tail: Manual 0.0s");
|
||||||
|
man.manualMs = kDefaultManualTailMs; // 2000 ms
|
||||||
|
CHECK(tailToggleLabel(man) == "Tail: Manual 2.0s");
|
||||||
|
man.manualMs = kMaxTailMs; // 8000 ms
|
||||||
|
CHECK(tailToggleLabel(man) == "Tail: Manual 8.0s");
|
||||||
|
// An over-cap stored value renders at the CLAMPED length, never past the cap.
|
||||||
|
man.manualMs = kMaxTailMs + 3000.0;
|
||||||
|
CHECK(tailToggleLabel(man) == "Tail: Manual 8.0s");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testDefaultSettingIsOff() {
|
static void testDefaultSettingIsOff() {
|
||||||
@@ -69,13 +107,65 @@ static void testDefaultSettingIsOff() {
|
|||||||
CHECK(tailToggleLabel(s) == "Tail: Off");
|
CHECK(tailToggleLabel(s) == "Tail: Off");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- serialize/deserialize: per-project persistence round-trip ----------------
|
||||||
|
|
||||||
|
static bool settingsEqual(const TailSetting& a, const TailSetting& b) {
|
||||||
|
return a.mode == b.mode && a.manualMs == b.manualMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRoundTripNoneDefault() {
|
||||||
|
TailSetting s; // None + 2 s default
|
||||||
|
auto back = deserializeTailSetting(serializeTailSetting(s));
|
||||||
|
CHECK(back.has_value());
|
||||||
|
CHECK(back && settingsEqual(*back, s));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRoundTripManualArbitraryMs() {
|
||||||
|
// A non-round manual length must round-trip bit-for-bit (17-sig-digit emit).
|
||||||
|
TailSetting s; s.mode = TailMode::Manual; s.manualMs = 3141.592653589793;
|
||||||
|
auto back = deserializeTailSetting(serializeTailSetting(s));
|
||||||
|
CHECK(back.has_value());
|
||||||
|
CHECK(back && settingsEqual(*back, s));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRoundTripAuto() {
|
||||||
|
TailSetting s; s.mode = TailMode::Auto; s.manualMs = 500.0;
|
||||||
|
auto back = deserializeTailSetting(serializeTailSetting(s));
|
||||||
|
CHECK(back.has_value());
|
||||||
|
CHECK(back && settingsEqual(*back, s));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testDeserializeEmptyIsDefault() {
|
||||||
|
// An absent/empty stored value (older project) -> nullopt, so the caller falls
|
||||||
|
// back to the default. This is the graceful-old-project path the brief requires.
|
||||||
|
CHECK(!deserializeTailSetting("").has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testDeserializeMalformedIsDefault() {
|
||||||
|
// Garbage, a missing key, or an unknown mode enumerant -> nullopt (no crash).
|
||||||
|
CHECK(!deserializeTailSetting("not json at all").has_value());
|
||||||
|
CHECK(!deserializeTailSetting("{\"mode\":1}").has_value()); // manualMs missing
|
||||||
|
CHECK(!deserializeTailSetting("{\"manualMs\":2000}").has_value()); // mode missing
|
||||||
|
CHECK(!deserializeTailSetting("{\"mode\":9,\"manualMs\":2000}").has_value()); // bad enum
|
||||||
|
CHECK(!deserializeTailSetting("{\"mode\":x,\"manualMs\":2000}").has_value()); // non-numeric
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testCycleOrderIsNoneAutoManualNone();
|
testCycleOrderIsNoneAutoManualNone();
|
||||||
testCycleThreeStepsReturnsToStart();
|
testCycleThreeStepsReturnsToStart();
|
||||||
testManualClampInRangeIsUnchanged();
|
testManualClampInRangeIsUnchanged();
|
||||||
testManualClampCapsAtEightSeconds();
|
testManualClampCapsAtEightSeconds();
|
||||||
|
testAdjustUpAndDownBySteps();
|
||||||
|
testAdjustClampsAtUpperBound();
|
||||||
|
testAdjustClampsAtLowerBound();
|
||||||
testLabelStringsPerMode();
|
testLabelStringsPerMode();
|
||||||
|
testManualLabelRendersLengthInSeconds();
|
||||||
testDefaultSettingIsOff();
|
testDefaultSettingIsOff();
|
||||||
|
testRoundTripNoneDefault();
|
||||||
|
testRoundTripManualArbitraryMs();
|
||||||
|
testRoundTripAuto();
|
||||||
|
testDeserializeEmptyIsDefault();
|
||||||
|
testDeserializeMalformedIsDefault();
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("tail_control: all tests passed\n");
|
if (g_fail == 0) std::printf("tail_control: all tests passed\n");
|
||||||
else std::printf("tail_control: %d CHECK(s) FAILED\n", g_fail);
|
else std::printf("tail_control: %d CHECK(s) FAILED\n", g_fail);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
// guards the in-DAW "all leaves hidden after toggling twice" regression.
|
// guards the in-DAW "all leaves hidden after toggling twice" regression.
|
||||||
|
|
||||||
#include "../src/view_mode_model.h"
|
#include "../src/view_mode_model.h"
|
||||||
|
#include "../src/lane_keys.h" // laneNameForMode — assert the minting plan's durable keys
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
@@ -940,6 +941,40 @@ static void testLaneOwnershipIndex() {
|
|||||||
CHECK(!idx.remove("{T}", "l0"));
|
CHECK(!idx.remove("{T}", "l0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- D2.2b Last-writer-wins ownership replace (round-trip) --------------------
|
||||||
|
//
|
||||||
|
// setManual then setManaged on the SAME (guid, laneKey) must leave EXACTLY ONE
|
||||||
|
// managed entry — the ownership record is replaced, not accumulated. Guards the
|
||||||
|
// "retag a lane the tool now owns" contract and its persistence: the replace must
|
||||||
|
// survive a serialize/deserialize round-trip with no stray manual duplicate.
|
||||||
|
static void testLaneOwnershipLastWriterWins() {
|
||||||
|
ViewModeModel vm;
|
||||||
|
|
||||||
|
// Manual first, then managed on the same lane — the managed write replaces.
|
||||||
|
CHECK(vm.lanes().setManual("{T}", "l0"));
|
||||||
|
CHECK(vm.lanes().setManaged("{T}", "l0", kDesignModeId));
|
||||||
|
CHECK(vm.lanes().size() == 1); // one entry, not two
|
||||||
|
const LaneOwnership* o = vm.lanes().query("{T}", "l0");
|
||||||
|
CHECK(o && o->isManaged() && *o->managedMode == kDesignModeId);
|
||||||
|
|
||||||
|
// The reverse also replaces: managed -> manual leaves exactly one manual entry.
|
||||||
|
CHECK(vm.lanes().setManual("{T}", "l0"));
|
||||||
|
CHECK(vm.lanes().size() == 1);
|
||||||
|
const LaneOwnership* m = vm.lanes().query("{T}", "l0");
|
||||||
|
CHECK(m && m->isManual());
|
||||||
|
|
||||||
|
// Back to managed, then round-trip: exactly one managed entry survives, no stray
|
||||||
|
// manual duplicate resurrected by (de)serialization.
|
||||||
|
CHECK(vm.lanes().setManaged("{T}", "l0", kArrangeModeId));
|
||||||
|
auto back = ViewModeModel::deserialize(vm.serialize());
|
||||||
|
CHECK(back.has_value());
|
||||||
|
if (back) {
|
||||||
|
CHECK(back->lanes().size() == 1);
|
||||||
|
const LaneOwnership* r = back->lanes().query("{T}", "l0");
|
||||||
|
CHECK(r && r->isManaged() && *r->managedMode == kArrangeModeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -- D2.3/D2.4 Managed-only: planner + query never emit a manual lane --------
|
// -- D2.3/D2.4 Managed-only: planner + query never emit a manual lane --------
|
||||||
//
|
//
|
||||||
// Required case: a track with a manual lane + managed mode lanes — neither the planner
|
// Required case: a track with a manual lane + managed mode lanes — neither the planner
|
||||||
@@ -1039,6 +1074,510 @@ static void testAutoTagDecision() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- D2 W3-B item-level mode-move decision -----------------------------------
|
||||||
|
//
|
||||||
|
// planItemRetag: the pure decision behind the three item actions. A non-empty target
|
||||||
|
// tags each eligible selected item into it; an EMPTY target untags (→ Arrange default).
|
||||||
|
// Manual-lane items are EXEMPT (no op) and empty-GUID items are skipped.
|
||||||
|
|
||||||
|
// Find the single op for `guid`, or nullptr.
|
||||||
|
static const ItemRetagOp* retagOpFor(const std::vector<ItemRetagOp>& ops,
|
||||||
|
const std::string& guid) {
|
||||||
|
for (const auto& o : ops)
|
||||||
|
if (o.guid == guid) return &o;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testPlanItemRetag() {
|
||||||
|
// Move -> Design: a managed-lane / normal item is tagged into Design (untag=false).
|
||||||
|
{
|
||||||
|
std::vector<RetagItem> sel{
|
||||||
|
RetagItem{"{A}", /*onManualLane=*/false},
|
||||||
|
RetagItem{"{B}", false},
|
||||||
|
};
|
||||||
|
auto ops = planItemRetag(sel, kDesignModeId);
|
||||||
|
CHECK(ops.size() == 2);
|
||||||
|
const ItemRetagOp* a = retagOpFor(ops, "{A}");
|
||||||
|
CHECK(a != nullptr);
|
||||||
|
CHECK(a && !a->untag); // a tag, not an untag
|
||||||
|
CHECK(a && a->modeId == kDesignModeId); // into Design specifically
|
||||||
|
const ItemRetagOp* b = retagOpFor(ops, "{B}");
|
||||||
|
CHECK(b && !b->untag && b->modeId == kDesignModeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty target ⇒ UNTAG each item (Move -> Arrange / Untag items collapse to this).
|
||||||
|
// untag must be true and modeId empty — NOT a tag into "arrange".
|
||||||
|
{
|
||||||
|
std::vector<RetagItem> sel{ RetagItem{"{A}", false} };
|
||||||
|
auto ops = planItemRetag(sel, std::string{});
|
||||||
|
CHECK(ops.size() == 1);
|
||||||
|
const ItemRetagOp* a = retagOpFor(ops, "{A}");
|
||||||
|
CHECK(a != nullptr);
|
||||||
|
CHECK(a && a->untag); // an untag
|
||||||
|
CHECK(a && a->modeId.empty()); // no target mode carried on an untag
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual-lane exemption: a manual-lane item yields NO op — not for Move nor for Untag.
|
||||||
|
{
|
||||||
|
std::vector<RetagItem> sel{
|
||||||
|
RetagItem{"{NORMAL}", false},
|
||||||
|
RetagItem{"{MANUAL}", true}, // on a hand-managed lane ⇒ EXEMPT
|
||||||
|
};
|
||||||
|
auto design = planItemRetag(sel, kDesignModeId);
|
||||||
|
CHECK(design.size() == 1);
|
||||||
|
CHECK(retagOpFor(design, "{NORMAL}") != nullptr);
|
||||||
|
CHECK(retagOpFor(design, "{MANUAL}") == nullptr); // exempt — never retagged
|
||||||
|
|
||||||
|
auto untag = planItemRetag(sel, std::string{});
|
||||||
|
CHECK(untag.size() == 1);
|
||||||
|
CHECK(retagOpFor(untag, "{NORMAL}") != nullptr);
|
||||||
|
CHECK(retagOpFor(untag, "{MANUAL}") == nullptr); // exempt — never untagged
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty-GUID items are skipped defensively; empty selection ⇒ no ops.
|
||||||
|
{
|
||||||
|
std::vector<RetagItem> sel{ RetagItem{"", false}, RetagItem{"{A}", false} };
|
||||||
|
auto ops = planItemRetag(sel, kDesignModeId);
|
||||||
|
CHECK(ops.size() == 1);
|
||||||
|
CHECK(retagOpFor(ops, "{A}") != nullptr);
|
||||||
|
|
||||||
|
CHECK(planItemRetag({}, kDesignModeId).empty());
|
||||||
|
CHECK(planItemRetag({}, std::string{}).empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- D2 W3-B reconcile unregistered-mode guard (pure decision) ---------------
|
||||||
|
//
|
||||||
|
// reconcileManagedLanes (shell) recovers a lane's managed ownership from its durable
|
||||||
|
// name, but must NOT record ownership for a mode the registry no longer knows — a lane
|
||||||
|
// keyed to an unregistered mode can never become the active mode's lane and would stay
|
||||||
|
// silenced+hidden forever, orphaning its items. The guard's pure decision is exactly
|
||||||
|
// modeIdFromLaneName(name) ∈ modes(): this locks that composition so the shell's guard
|
||||||
|
// (which calls model.modes().contains(*mode)) cannot silently drift.
|
||||||
|
|
||||||
|
static void testReconcileUnregisteredModeGuardDecision() {
|
||||||
|
ViewModeModel vm; // seeds Arrange + Design only
|
||||||
|
|
||||||
|
// A managed lane naming a REGISTERED mode: mode decodes and IS contained ⇒ record.
|
||||||
|
{
|
||||||
|
const std::string name = laneNameForMode(kDesignModeId);
|
||||||
|
auto mode = modeIdFromLaneName(name);
|
||||||
|
CHECK(mode.has_value());
|
||||||
|
CHECK(vm.modes().contains(*mode)); // guard passes ⇒ shell records ownership
|
||||||
|
}
|
||||||
|
|
||||||
|
// A managed lane naming an UNREGISTERED mode: mode decodes but is NOT contained ⇒
|
||||||
|
// the guard rejects it and the shell leaves the lane off the index (manual-by-default).
|
||||||
|
{
|
||||||
|
const std::string name = laneNameForMode("removed_mode");
|
||||||
|
auto mode = modeIdFromLaneName(name);
|
||||||
|
CHECK(mode.has_value());
|
||||||
|
CHECK(*mode == "removed_mode");
|
||||||
|
CHECK(!vm.modes().contains(*mode)); // guard fails ⇒ shell must skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- D2.7 Lane minting decision (Wave 3) -------------------------------------
|
||||||
|
//
|
||||||
|
// planLaneMinting: a track with content of only ONE mode is NOT split (D1 unchanged);
|
||||||
|
// a track that holds >1 mode's content mints one managed lane per mode and assigns EVERY
|
||||||
|
// managed-eligible item (incl. pre-existing) to its mode's lane; manual-lane items are
|
||||||
|
// exempt (never counted, never reassigned, their lane never minted-over).
|
||||||
|
|
||||||
|
static bool hasMint(const LaneMintPlan& p, const std::string& track,
|
||||||
|
const std::string& mode) {
|
||||||
|
for (const auto& m : p.mints)
|
||||||
|
if (m.trackGuid == track && m.modeId == mode &&
|
||||||
|
m.laneKey == laneNameForMode(mode))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool hasAssign(const LaneMintPlan& p, const std::string& item,
|
||||||
|
const std::string& track, const std::string& mode) {
|
||||||
|
for (const auto& a : p.assigns)
|
||||||
|
if (a.itemGuid == item && a.trackGuid == track &&
|
||||||
|
a.laneKey == laneNameForMode(mode))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int splitLaneCount(const LaneMintPlan& p, const std::string& track) {
|
||||||
|
for (const auto& s : p.splits)
|
||||||
|
if (s.trackGuid == track) return s.laneCount;
|
||||||
|
return -1; // no split for this track
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plain LEAF track (not a folder) carrying its own items, with no tree derivation:
|
||||||
|
// an empty model + empty tree means visibleTracks contributes nothing, so the ONLY
|
||||||
|
// trigger is the track's own-item mode span — exactly the W3-A behavior. These helpers
|
||||||
|
// keep the W3-A leaf tests reading against a neutral model/tree.
|
||||||
|
static const ViewModeModel& bareModel() { static ViewModeModel m; return m; }
|
||||||
|
static const FolderTree& emptyTree() { static FolderTree t; return t; }
|
||||||
|
|
||||||
|
static void testLaneMintingSingleModeNoSplit() {
|
||||||
|
// A track whose items all belong to ONE mode is NOT lane-split — D1 whole-track
|
||||||
|
// parking still separates the stances. No split, no mint, no assignment.
|
||||||
|
std::vector<LaneTrack> tracks{
|
||||||
|
LaneTrack{"{T}", {
|
||||||
|
LaneItem{"{i1}", kArrangeModeId, false},
|
||||||
|
LaneItem{"{i2}", kArrangeModeId, false},
|
||||||
|
}},
|
||||||
|
};
|
||||||
|
const LaneMintPlan plan = planLaneMinting(bareModel(), emptyTree(), tracks);
|
||||||
|
CHECK(plan.empty());
|
||||||
|
CHECK(splitLaneCount(plan, "{T}") == -1);
|
||||||
|
|
||||||
|
// An empty track (no items) is likewise never split.
|
||||||
|
CHECK(planLaneMinting(bareModel(), emptyTree(), {LaneTrack{"{E}", {}}}).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testLaneMintingMultiModeMintsAndAssignsAll() {
|
||||||
|
// A track that gained a second mode's item: it now holds Arrange + Design content.
|
||||||
|
// Both modes get a managed lane; ALL managed-eligible items are assigned — including
|
||||||
|
// the pre-existing Arrange item (retroactive lane assignment), not only the new one.
|
||||||
|
std::vector<LaneTrack> tracks{
|
||||||
|
LaneTrack{"{T}", {
|
||||||
|
LaneItem{"{arr1}", kArrangeModeId, false}, // pre-existing single-mode item
|
||||||
|
LaneItem{"{arr2}", kArrangeModeId, false}, // pre-existing single-mode item
|
||||||
|
LaneItem{"{des1}", kDesignModeId, false}, // the newly-added 2nd-mode item
|
||||||
|
}},
|
||||||
|
};
|
||||||
|
const LaneMintPlan plan = planLaneMinting(bareModel(), emptyTree(), tracks);
|
||||||
|
CHECK(!plan.empty());
|
||||||
|
|
||||||
|
// One split with two managed lanes (one per involved mode).
|
||||||
|
CHECK(splitLaneCount(plan, "{T}") == 2);
|
||||||
|
CHECK(plan.mints.size() == 2);
|
||||||
|
CHECK(hasMint(plan, "{T}", kArrangeModeId));
|
||||||
|
CHECK(hasMint(plan, "{T}", kDesignModeId));
|
||||||
|
|
||||||
|
// EVERY managed-eligible item assigned to its mode's lane — pre-existing included.
|
||||||
|
CHECK(plan.assigns.size() == 3);
|
||||||
|
CHECK(hasAssign(plan, "{arr1}", "{T}", kArrangeModeId)); // retroactive
|
||||||
|
CHECK(hasAssign(plan, "{arr2}", "{T}", kArrangeModeId)); // retroactive
|
||||||
|
CHECK(hasAssign(plan, "{des1}", "{T}", kDesignModeId)); // the new item
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testLaneMintingManualLaneExempt() {
|
||||||
|
// A track with Arrange + Design managed-eligible content AND an item the user placed
|
||||||
|
// on a manual lane: the manual item is EXEMPT — it is not counted, not assigned, and
|
||||||
|
// its lane is never minted-over. The managed split proceeds around it.
|
||||||
|
std::vector<LaneTrack> tracks{
|
||||||
|
LaneTrack{"{T}", {
|
||||||
|
LaneItem{"{arr}", kArrangeModeId, false},
|
||||||
|
LaneItem{"{des}", kDesignModeId, false},
|
||||||
|
LaneItem{"{comp}", kDesignModeId, /*onManualLane=*/true}, // user's comp take
|
||||||
|
}},
|
||||||
|
};
|
||||||
|
const LaneMintPlan plan = planLaneMinting(bareModel(), emptyTree(), tracks);
|
||||||
|
|
||||||
|
// Split for the two managed modes; the manual item never appears in assigns.
|
||||||
|
CHECK(splitLaneCount(plan, "{T}") == 2);
|
||||||
|
CHECK(plan.assigns.size() == 2);
|
||||||
|
CHECK(hasAssign(plan, "{arr}", "{T}", kArrangeModeId));
|
||||||
|
CHECK(hasAssign(plan, "{des}", "{T}", kDesignModeId));
|
||||||
|
for (const auto& a : plan.assigns)
|
||||||
|
CHECK(a.itemGuid != "{comp}"); // manual-lane item NEVER reassigned
|
||||||
|
|
||||||
|
// Manual-lane exemption can also SUPPRESS a split: if the ONLY second mode is
|
||||||
|
// supplied by a manual-lane item, the managed-eligible items are single-mode ⇒ NO
|
||||||
|
// split (the user's manual lane is not a mode the tool separates).
|
||||||
|
std::vector<LaneTrack> t2{
|
||||||
|
LaneTrack{"{U}", {
|
||||||
|
LaneItem{"{a}", kArrangeModeId, false},
|
||||||
|
LaneItem{"{d}", kDesignModeId, /*onManualLane=*/true}, // only 2nd mode, exempt
|
||||||
|
}},
|
||||||
|
};
|
||||||
|
// managed-eligible content is single-mode ⇒ no split (leaf, no tree derivation).
|
||||||
|
CHECK(planLaneMinting(bareModel(), emptyTree(), t2).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testLaneMintingThreeModesAndOwnershipKeys() {
|
||||||
|
// N-mode proof + the ownership writes the shell will apply: three modes on one track
|
||||||
|
// mint three managed lanes, each keyed by its durable name (== laneNameForMode), each
|
||||||
|
// owning the right mode. Applying the mints to a real ownership index reproduces the
|
||||||
|
// managed classification the toggle planner then gates on.
|
||||||
|
ViewModeModel vm;
|
||||||
|
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
|
||||||
|
|
||||||
|
std::vector<LaneTrack> tracks{
|
||||||
|
LaneTrack{"{T}", {
|
||||||
|
LaneItem{"{a}", kArrangeModeId, false},
|
||||||
|
LaneItem{"{d}", kDesignModeId, false},
|
||||||
|
LaneItem{"{m}", "mixdown", false},
|
||||||
|
}},
|
||||||
|
};
|
||||||
|
// Own items span three modes (leaf; empty tree ⇒ own-item-span is the sole trigger).
|
||||||
|
const LaneMintPlan plan = planLaneMinting(vm, FolderTree{}, tracks);
|
||||||
|
CHECK(splitLaneCount(plan, "{T}") == 3);
|
||||||
|
CHECK(plan.mints.size() == 3);
|
||||||
|
|
||||||
|
// Apply the mints exactly as the shell does — record managed ownership — then assert
|
||||||
|
// the ownership index classifies each lane managed-for-its-mode and the toggle
|
||||||
|
// planner would drive exactly these three lanes (managed-only invariant intact).
|
||||||
|
for (const auto& m : plan.mints)
|
||||||
|
CHECK(vm.lanes().setManaged(m.trackGuid, m.laneKey, m.modeId));
|
||||||
|
CHECK(vm.lanes().size() == 3);
|
||||||
|
CHECK(vm.lanes().isManaged("{T}", laneNameForMode(kArrangeModeId)));
|
||||||
|
CHECK(vm.lanes().isManaged("{T}", laneNameForMode(kDesignModeId)));
|
||||||
|
CHECK(vm.lanes().isManaged("{T}", laneNameForMode("mixdown")));
|
||||||
|
CHECK(vm.lanesTouchedByToggle().size() == 3);
|
||||||
|
|
||||||
|
// Persist round-trip of the just-minted lane-split project: the ownership index (and
|
||||||
|
// the whole model) survives serialize/deserialize unchanged, so a saved lane-split
|
||||||
|
// project restores its managed classification without re-minting.
|
||||||
|
auto back = ViewModeModel::deserialize(vm.serialize());
|
||||||
|
CHECK(back.has_value());
|
||||||
|
CHECK(back && *back == vm);
|
||||||
|
if (back) CHECK(back->lanes().size() == 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Fix: content-bearing folder derived-visible in >1 mode splits its own media ----
|
||||||
|
//
|
||||||
|
// The exact failing case. A folder {F} has descendant leaves in BOTH modes ({LD} Design,
|
||||||
|
// {LA} Arrange) and carries ONE OWN item ({own}) tagged Design. W3-A's own-item-span test
|
||||||
|
// alone would NOT split {F} (its own content is single-mode Design), so the item leaked
|
||||||
|
// into every mode the folder was derived-visible in. The visibility-aware decision splits
|
||||||
|
// {F} and lanes {own} onto the Design lane — so it hides+silences whenever Arrange is
|
||||||
|
// active. LAZY-MINT: {F} mints ONLY the Design lane (holding the item), NOT an empty
|
||||||
|
// reserved Arrange lane — confinement holds via C_LANEPLAYS=0 on the lone Design lane when
|
||||||
|
// Arrange is active. This is the load-bearing fix; assert it hard.
|
||||||
|
static void testLaneMintingFolderDerivedVisibleSplitsOwnMedia() {
|
||||||
|
ViewModeModel vm;
|
||||||
|
vm.membership().tag("{LD}", kDesignModeId); // a Design leaf under the folder
|
||||||
|
// {LA} left untagged ⇒ Arrange member; both stances thus live under {F}.
|
||||||
|
vm.membership().tag("{own}", kDesignModeId); // the folder's OWN dropped item (Design)
|
||||||
|
|
||||||
|
FolderTree tree;
|
||||||
|
tree.nodes.push_back(FolderNode{"{F}", "", /*isParent=*/true});
|
||||||
|
tree.nodes.push_back(FolderNode{"{LD}", "{F}", false});
|
||||||
|
tree.nodes.push_back(FolderNode{"{LA}", "{F}", false});
|
||||||
|
|
||||||
|
// Sanity: the folder really is derived-visible in BOTH modes (the precondition the
|
||||||
|
// W3-A trigger ignored). If this ever stops holding, the fix's premise is gone.
|
||||||
|
CHECK(vm.visibleTracks(tree, kArrangeModeId).count("{F}") == 1);
|
||||||
|
CHECK(vm.visibleTracks(tree, kDesignModeId).count("{F}") == 1);
|
||||||
|
|
||||||
|
// The folder track {F} carries its own single Design item; its child leaves are the
|
||||||
|
// separate leaf tracks (not reported as items on {F}).
|
||||||
|
std::vector<LaneTrack> tracks{
|
||||||
|
LaneTrack{"{F}", {LaneItem{"{own}", kDesignModeId, false}}},
|
||||||
|
};
|
||||||
|
|
||||||
|
const LaneMintPlan plan = planLaneMinting(vm, tree, tracks);
|
||||||
|
|
||||||
|
// {F} MUST split even though its own item is single-mode: it is visible in 2 modes.
|
||||||
|
CHECK(!plan.empty());
|
||||||
|
|
||||||
|
// LAZY-MINT: ONE lane only — the Design lane that holds the item. No empty reserved
|
||||||
|
// Arrange lane is minted, even though {F} is derived-visible in Arrange. The Arrange
|
||||||
|
// lane appears on demand when an Arrange item first lands on {F}.
|
||||||
|
CHECK(splitLaneCount(plan, "{F}") == 1); // Design lane only — no reserved lane
|
||||||
|
CHECK(plan.mints.size() == 1);
|
||||||
|
CHECK(hasMint(plan, "{F}", kDesignModeId)); // Design lane (holds the item)
|
||||||
|
CHECK(!hasMint(plan, "{F}", kArrangeModeId)); // NO empty reserved Arrange lane
|
||||||
|
|
||||||
|
// The own item is confined to its tagged (Design) lane — the exact hide-in-Arrange fix.
|
||||||
|
// With only the Design lane present, toggling to Arrange sets its C_LANEPLAYS to 0, so
|
||||||
|
// the item hides+silences and the track reads as an empty normal track (no leak).
|
||||||
|
CHECK(plan.assigns.size() == 1);
|
||||||
|
CHECK(hasAssign(plan, "{own}", "{F}", kDesignModeId));
|
||||||
|
for (const auto& a : plan.assigns)
|
||||||
|
CHECK(!(a.itemGuid == "{own}" && a.laneKey == laneNameForMode(kArrangeModeId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// LAZY-MINT confinement proof. Same single-own-mode / dual-visibility folder, but instead
|
||||||
|
// of asserting the mint COUNT we prove the FUNCTIONAL confinement the lazy split preserves:
|
||||||
|
// apply the minted lane's ownership to a live model, then drive the toggle planner and show
|
||||||
|
// the lone Design lane SILENCES when Arrange is active (C_LANEPLAYS = 0). That is the whole
|
||||||
|
// point — a single managed lane still hides its item in every other mode, so removing the
|
||||||
|
// empty reserved Arrange lane costs nothing functionally. Fails if the split ever leaves the
|
||||||
|
// Design item audible in Arrange (the leak the D2 fix closed) or mints a spurious lane.
|
||||||
|
static void testLaneMintingLazySingleLaneStillConfines() {
|
||||||
|
ViewModeModel vm;
|
||||||
|
vm.membership().tag("{LD}", kDesignModeId); // Design leaf ⇒ folder visible in Design
|
||||||
|
// {LA} untagged ⇒ Arrange member ⇒ folder ALSO derived-visible in Arrange.
|
||||||
|
vm.membership().tag("{own}", kDesignModeId); // the folder's one own item (Design)
|
||||||
|
|
||||||
|
FolderTree tree;
|
||||||
|
tree.nodes.push_back(FolderNode{"{F}", "", /*isParent=*/true});
|
||||||
|
tree.nodes.push_back(FolderNode{"{LD}", "{F}", false});
|
||||||
|
tree.nodes.push_back(FolderNode{"{LA}", "{F}", false});
|
||||||
|
|
||||||
|
std::vector<LaneTrack> tracks{
|
||||||
|
LaneTrack{"{F}", {LaneItem{"{own}", kDesignModeId, false}}},
|
||||||
|
};
|
||||||
|
const LaneMintPlan plan = planLaneMinting(vm, tree, tracks);
|
||||||
|
|
||||||
|
// Exactly one lane minted (the Design lane) — no empty reserved Arrange lane.
|
||||||
|
CHECK(plan.mints.size() == 1);
|
||||||
|
CHECK(hasMint(plan, "{F}", kDesignModeId));
|
||||||
|
|
||||||
|
// Apply the mint's ownership exactly as the shell does, then drive the toggle planner.
|
||||||
|
for (const auto& m : plan.mints)
|
||||||
|
CHECK(vm.lanes().setManaged(m.trackGuid, m.laneKey, m.modeId));
|
||||||
|
CHECK(vm.lanes().size() == 1); // one managed lane on {F}, not two
|
||||||
|
|
||||||
|
const std::string designLane = laneNameForMode(kDesignModeId);
|
||||||
|
|
||||||
|
// Active = Design: the lone Design lane PLAYS (item visible+audible in its own mode).
|
||||||
|
const auto design = vm.planToggle(tree, kDesignModeId);
|
||||||
|
CHECK(lanePlaysFor(design, "{F}", designLane) == kLanePlaysExclusive);
|
||||||
|
|
||||||
|
// Active = Arrange: the lone Design lane SILENCES — with no lane playing, the track
|
||||||
|
// reads as an empty normal track and the Design item does NOT leak. This is the
|
||||||
|
// confinement guarantee that lets us drop the reserved Arrange lane.
|
||||||
|
const auto arrange = vm.planToggle(tree, kArrangeModeId);
|
||||||
|
CHECK(lanePlaysFor(arrange, "{F}", designLane) == kLaneSilent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A folder carrying its OWN items that already span both modes → still split (the two
|
||||||
|
// triggers OR: own-item span AND derived visibility both point the same way here). Both
|
||||||
|
// own items separate to their tagged lanes.
|
||||||
|
static void testLaneMintingFolderOwnItemsSpanBothModes() {
|
||||||
|
ViewModeModel vm;
|
||||||
|
vm.membership().tag("{LD}", kDesignModeId);
|
||||||
|
vm.membership().tag("{d}", kDesignModeId);
|
||||||
|
// {a} untagged ⇒ Arrange.
|
||||||
|
|
||||||
|
FolderTree tree;
|
||||||
|
tree.nodes.push_back(FolderNode{"{F}", "", true});
|
||||||
|
tree.nodes.push_back(FolderNode{"{LD}", "{F}", false});
|
||||||
|
tree.nodes.push_back(FolderNode{"{LA}", "{F}", false}); // untagged ⇒ Arrange
|
||||||
|
|
||||||
|
std::vector<LaneTrack> tracks{
|
||||||
|
LaneTrack{"{F}", {
|
||||||
|
LaneItem{"{a}", kArrangeModeId, false},
|
||||||
|
LaneItem{"{d}", kDesignModeId, false},
|
||||||
|
}},
|
||||||
|
};
|
||||||
|
const LaneMintPlan plan = planLaneMinting(vm, tree, tracks);
|
||||||
|
CHECK(splitLaneCount(plan, "{F}") == 2);
|
||||||
|
CHECK(plan.assigns.size() == 2);
|
||||||
|
CHECK(hasAssign(plan, "{a}", "{F}", kArrangeModeId));
|
||||||
|
CHECK(hasAssign(plan, "{d}", "{F}", kDesignModeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Fix (pd2): item inserted onto an ALREADY-split folder still yields a non-empty plan --
|
||||||
|
//
|
||||||
|
// Regression guard for the "inserted item invisible until a manual toggle" bug. When a new
|
||||||
|
// item lands (via insert/capture) on a folder that is ALREADY lane-split and derived-visible
|
||||||
|
// in >1 mode, and REAPER placed it on the active mode's currently-playing lane, the shell's
|
||||||
|
// assignItemToLane sees I_FIXEDLANE unchanged and writes nothing — applyMintPlan reports
|
||||||
|
// changed==false. The shell must STILL treat this tick as "content landed on a managed track"
|
||||||
|
// and refresh the arrange (so the item draws immediately, no toggle). The pure signal the
|
||||||
|
// shell keys on is: planLaneMinting returns a NON-EMPTY plan carrying an assign for the new
|
||||||
|
// item. This test locks that signal; if planLaneMinting ever went empty here, the shell would
|
||||||
|
// have nothing to refresh on and the bug would return.
|
||||||
|
static void testLaneMintingNewItemOnAlreadySplitFolderYieldsPlan() {
|
||||||
|
ViewModeModel vm;
|
||||||
|
vm.membership().tag("{LD}", kDesignModeId); // Design leaf ⇒ folder derived-visible Design
|
||||||
|
// {LA} untagged ⇒ Arrange ⇒ folder ALSO derived-visible in Arrange (dual-visible).
|
||||||
|
vm.membership().tag("{own}", kDesignModeId); // the pre-existing own Design item
|
||||||
|
vm.membership().tag("{new}", kDesignModeId); // the JUST-INSERTED item (auto-tagged Design)
|
||||||
|
|
||||||
|
FolderTree tree;
|
||||||
|
tree.nodes.push_back(FolderNode{"{F}", "", /*isParent=*/true});
|
||||||
|
tree.nodes.push_back(FolderNode{"{LD}", "{F}", false});
|
||||||
|
tree.nodes.push_back(FolderNode{"{LA}", "{F}", false});
|
||||||
|
|
||||||
|
// The folder is already split for Design (its lane exists + is owned). This mirrors the
|
||||||
|
// live "already auto-split" track the bug reproduces on.
|
||||||
|
CHECK(vm.lanes().setManaged("{F}", laneNameForMode(kDesignModeId), kDesignModeId));
|
||||||
|
|
||||||
|
// {F} now carries its original own item PLUS the freshly-inserted one, both Design.
|
||||||
|
std::vector<LaneTrack> tracks{
|
||||||
|
LaneTrack{"{F}", {
|
||||||
|
LaneItem{"{own}", kDesignModeId, false},
|
||||||
|
LaneItem{"{new}", kDesignModeId, false},
|
||||||
|
}},
|
||||||
|
};
|
||||||
|
const LaneMintPlan plan = planLaneMinting(vm, tree, tracks);
|
||||||
|
|
||||||
|
// The plan is NON-EMPTY (folder is dual-visible ⇒ splits) and carries an assign for the
|
||||||
|
// new item onto the Design lane. In the shell this is the exact branch that must force a
|
||||||
|
// redraw even when the assign is an idempotent no-op (item already on the playing lane).
|
||||||
|
CHECK(!plan.empty());
|
||||||
|
CHECK(hasAssign(plan, "{new}", "{F}", kDesignModeId));
|
||||||
|
CHECK(hasAssign(plan, "{own}", "{F}", kDesignModeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHOW-BOTH escape hatch: a show-both track carrying its own items is visible in every
|
||||||
|
// mode ON PURPOSE and must NOT be force-split — its content stays cross-mode-visible.
|
||||||
|
// Even with own items that would otherwise span modes, the decision skips it entirely.
|
||||||
|
static void testLaneMintingShowBothNotForceSplit() {
|
||||||
|
ViewModeModel vm;
|
||||||
|
vm.membership().setShowBoth("{SB}", true);
|
||||||
|
|
||||||
|
// A show-both track whose OWN items even span two modes — the W3-A own-span trigger
|
||||||
|
// would fire, but show-both must override it (its items are meant to play everywhere).
|
||||||
|
std::vector<LaneTrack> tracks{
|
||||||
|
LaneTrack{"{SB}", {
|
||||||
|
LaneItem{"{a}", kArrangeModeId, false},
|
||||||
|
LaneItem{"{d}", kDesignModeId, false},
|
||||||
|
}},
|
||||||
|
};
|
||||||
|
const LaneMintPlan plan = planLaneMinting(vm, FolderTree{}, tracks);
|
||||||
|
CHECK(plan.empty()); // NOT split — the escape hatch holds
|
||||||
|
CHECK(splitLaneCount(plan, "{SB}") == -1);
|
||||||
|
|
||||||
|
// And a show-both FOLDER derived-visible in both modes carrying an own item: still not
|
||||||
|
// split. Visibility is the deliberate point of show-both.
|
||||||
|
ViewModeModel vm2;
|
||||||
|
vm2.membership().setShowBoth("{F}", true);
|
||||||
|
vm2.membership().tag("{LD}", kDesignModeId);
|
||||||
|
vm2.membership().tag("{own}", kDesignModeId);
|
||||||
|
FolderTree tree;
|
||||||
|
tree.nodes.push_back(FolderNode{"{F}", "", true});
|
||||||
|
tree.nodes.push_back(FolderNode{"{LD}", "{F}", false});
|
||||||
|
tree.nodes.push_back(FolderNode{"{LA}", "{F}", false});
|
||||||
|
std::vector<LaneTrack> t2{LaneTrack{"{F}", {LaneItem{"{own}", kDesignModeId, false}}}};
|
||||||
|
CHECK(planLaneMinting(vm2, tree, t2).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single-mode LEAF visible in exactly one mode is still never split — the D1 whole-track
|
||||||
|
// parking case. A leaf under a folder, tagged Design, whose sibling is also Design: the
|
||||||
|
// leaf is visible in one mode only, carries its own Design item, and must NOT lane-split.
|
||||||
|
static void testLaneMintingSingleModeLeafVisibleOnceNoSplit() {
|
||||||
|
ViewModeModel vm;
|
||||||
|
vm.membership().tag("{L}", kDesignModeId);
|
||||||
|
vm.membership().tag("{own}", kDesignModeId);
|
||||||
|
|
||||||
|
FolderTree tree;
|
||||||
|
tree.nodes.push_back(FolderNode{"{F}", "", true});
|
||||||
|
tree.nodes.push_back(FolderNode{"{L}", "{F}", false}); // the leaf under test
|
||||||
|
|
||||||
|
// The leaf {L} is visible only in Design (its one tagged mode).
|
||||||
|
CHECK(vm.visibleTracks(tree, kDesignModeId).count("{L}") == 1);
|
||||||
|
CHECK(vm.visibleTracks(tree, kArrangeModeId).count("{L}") == 0);
|
||||||
|
|
||||||
|
std::vector<LaneTrack> tracks{
|
||||||
|
LaneTrack{"{L}", {LaneItem{"{own}", kDesignModeId, false}}},
|
||||||
|
};
|
||||||
|
const LaneMintPlan plan = planLaneMinting(vm, tree, tracks);
|
||||||
|
CHECK(plan.empty()); // single-mode, visible once ⇒ D1 whole-track parking, no split
|
||||||
|
}
|
||||||
|
|
||||||
|
// A content-EMPTY folder derived-visible in many modes carries NO own media, so there is
|
||||||
|
// nothing to lane-separate: it stays visibility-only (D1 parent handling), never split.
|
||||||
|
static void testLaneMintingEmptyFolderNotSplit() {
|
||||||
|
ViewModeModel vm;
|
||||||
|
vm.membership().tag("{LD}", kDesignModeId);
|
||||||
|
// {LA} untagged ⇒ Arrange; folder derived-visible in both modes but holds no own item.
|
||||||
|
|
||||||
|
FolderTree tree;
|
||||||
|
tree.nodes.push_back(FolderNode{"{F}", "", true});
|
||||||
|
tree.nodes.push_back(FolderNode{"{LD}", "{F}", false});
|
||||||
|
tree.nodes.push_back(FolderNode{"{LA}", "{F}", false});
|
||||||
|
|
||||||
|
CHECK(vm.visibleTracks(tree, kArrangeModeId).count("{F}") == 1);
|
||||||
|
CHECK(vm.visibleTracks(tree, kDesignModeId).count("{F}") == 1);
|
||||||
|
|
||||||
|
std::vector<LaneTrack> tracks{LaneTrack{"{F}", {}}}; // no own media
|
||||||
|
CHECK(planLaneMinting(vm, tree, tracks).empty());
|
||||||
|
}
|
||||||
|
|
||||||
// -- D2.6 JSON round-trip with lane index + membership -----------------------
|
// -- D2.6 JSON round-trip with lane index + membership -----------------------
|
||||||
|
|
||||||
static void testLaneJsonRoundTrip() {
|
static void testLaneJsonRoundTrip() {
|
||||||
@@ -1124,8 +1663,22 @@ int main() {
|
|||||||
// D2 two-canvas lane extension
|
// D2 two-canvas lane extension
|
||||||
testLaneModeStateAndPlayValues();
|
testLaneModeStateAndPlayValues();
|
||||||
testLaneOwnershipIndex();
|
testLaneOwnershipIndex();
|
||||||
|
testLaneOwnershipLastWriterWins();
|
||||||
testManagedOnlyPlannerAndQuery();
|
testManagedOnlyPlannerAndQuery();
|
||||||
testAutoTagDecision();
|
testAutoTagDecision();
|
||||||
|
testPlanItemRetag();
|
||||||
|
testReconcileUnregisteredModeGuardDecision();
|
||||||
|
testLaneMintingSingleModeNoSplit();
|
||||||
|
testLaneMintingMultiModeMintsAndAssignsAll();
|
||||||
|
testLaneMintingManualLaneExempt();
|
||||||
|
testLaneMintingThreeModesAndOwnershipKeys();
|
||||||
|
testLaneMintingFolderDerivedVisibleSplitsOwnMedia();
|
||||||
|
testLaneMintingLazySingleLaneStillConfines();
|
||||||
|
testLaneMintingFolderOwnItemsSpanBothModes();
|
||||||
|
testLaneMintingNewItemOnAlreadySplitFolderYieldsPlan();
|
||||||
|
testLaneMintingShowBothNotForceSplit();
|
||||||
|
testLaneMintingSingleModeLeafVisibleOnceNoSplit();
|
||||||
|
testLaneMintingEmptyFolderNotSplit();
|
||||||
testLaneJsonRoundTrip();
|
testLaneJsonRoundTrip();
|
||||||
testLaneMalformedJson();
|
testLaneMalformedJson();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
// Standalone tests for reasampler::wav_trim — no REAPER, no test framework.
|
||||||
|
// Builds synthetic 32-bit-float WAV byte buffers, asserts the parse geometry, the
|
||||||
|
// float extraction, and the truncate-plan arithmetic (the header size-field patch).
|
||||||
|
//
|
||||||
|
// Covers: canonical stereo/mono 32-bit-float parse; a leading unknown chunk skipped;
|
||||||
|
// format rejection (16-bit PCM, non-WAV, data-before-fmt, truncated data); frame
|
||||||
|
// extraction (whole / tail window / clamp / out-of-range); truncate plan (kept<all,
|
||||||
|
// no-op keep-all, kept==0, grow rejected) with exact size-field values.
|
||||||
|
|
||||||
|
#include "../src/wav_trim.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using namespace reasampler;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
// --- Synthetic WAV builder ---------------------------------------------------
|
||||||
|
|
||||||
|
static void putU16(std::vector<std::uint8_t>& b, std::uint16_t v) {
|
||||||
|
b.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||||
|
b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||||
|
}
|
||||||
|
static void putU32(std::vector<std::uint8_t>& b, std::uint32_t v) {
|
||||||
|
b.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||||
|
b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||||
|
b.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
|
||||||
|
b.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
|
||||||
|
}
|
||||||
|
static void putTag(std::vector<std::uint8_t>& b, const char* t) {
|
||||||
|
for (int i = 0; i < 4; ++i) b.push_back(static_cast<std::uint8_t>(t[i]));
|
||||||
|
}
|
||||||
|
static void putFloat(std::vector<std::uint8_t>& b, float f) {
|
||||||
|
std::uint8_t tmp[4];
|
||||||
|
std::memcpy(tmp, &f, 4);
|
||||||
|
for (int i = 0; i < 4; ++i) b.push_back(tmp[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A canonical 32-bit-float WAV: RIFF/WAVE, fmt (tag 3, 16-byte body), data holding
|
||||||
|
// `frames` interleaved frames of `channels`. `leadingJunk` optionally inserts an
|
||||||
|
// unknown chunk before fmt to exercise the chunk walk. Samples: frame f, channel c
|
||||||
|
// = value(f,c).
|
||||||
|
template <typename Fn>
|
||||||
|
static std::vector<std::uint8_t> buildFloatWav(std::uint16_t channels,
|
||||||
|
std::uint32_t sampleRate,
|
||||||
|
std::size_t frames,
|
||||||
|
Fn value,
|
||||||
|
bool leadingJunk = false,
|
||||||
|
std::uint16_t fmtTag = 3,
|
||||||
|
std::uint16_t bits = 32) {
|
||||||
|
const std::uint32_t dataBytes =
|
||||||
|
static_cast<std::uint32_t>(frames * channels * (bits / 8));
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> chunks; // everything after "WAVE"
|
||||||
|
if (leadingJunk) {
|
||||||
|
putTag(chunks, "LIST");
|
||||||
|
putU32(chunks, 4);
|
||||||
|
putTag(chunks, "INFO"); // 4-byte body, even -> no pad
|
||||||
|
}
|
||||||
|
// fmt chunk (16-byte body).
|
||||||
|
putTag(chunks, "fmt ");
|
||||||
|
putU32(chunks, 16);
|
||||||
|
putU16(chunks, fmtTag); // format tag
|
||||||
|
putU16(chunks, channels);
|
||||||
|
putU32(chunks, sampleRate);
|
||||||
|
const std::uint32_t byteRate = sampleRate * channels * (bits / 8);
|
||||||
|
putU32(chunks, byteRate);
|
||||||
|
putU16(chunks, static_cast<std::uint16_t>(channels * (bits / 8))); // block align
|
||||||
|
putU16(chunks, bits);
|
||||||
|
// data chunk.
|
||||||
|
putTag(chunks, "data");
|
||||||
|
putU32(chunks, dataBytes);
|
||||||
|
for (std::size_t f = 0; f < frames; ++f)
|
||||||
|
for (std::uint16_t c = 0; c < channels; ++c)
|
||||||
|
putFloat(chunks, value(f, c));
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> wav;
|
||||||
|
putTag(wav, "RIFF");
|
||||||
|
putU32(wav, static_cast<std::uint32_t>(4 + chunks.size())); // "WAVE" + chunks
|
||||||
|
putTag(wav, "WAVE");
|
||||||
|
wav.insert(wav.end(), chunks.begin(), chunks.end());
|
||||||
|
return wav;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a WAVE_FORMAT_EXTENSIBLE (0xFFFE) WAV with a 40-byte fmt body.
|
||||||
|
// `subFormatTag` is the 2-byte leading tag embedded in the SubFormat GUID:
|
||||||
|
// 0x0003 = IEEE float, 0x0001 = PCM integer (and any other value to exercise rejection).
|
||||||
|
// bitsPerSample and the PCM data are always 32-bit float bytes regardless of subFormatTag
|
||||||
|
// (we're testing that the parser correctly rejects/accepts based on the GUID, not the data).
|
||||||
|
template <typename Fn>
|
||||||
|
static std::vector<std::uint8_t> buildExtensibleWav(std::uint16_t channels,
|
||||||
|
std::uint32_t sampleRate,
|
||||||
|
std::size_t frames,
|
||||||
|
Fn value,
|
||||||
|
std::uint16_t subFormatTag) {
|
||||||
|
const std::uint32_t dataBytes =
|
||||||
|
static_cast<std::uint32_t>(frames * channels * 4u);
|
||||||
|
|
||||||
|
// WAVEFORMATEXTENSIBLE fmt body (40 bytes):
|
||||||
|
// [0..1] wFormatTag = 0xFFFE
|
||||||
|
// [2..3] nChannels
|
||||||
|
// [4..7] nSamplesPerSec
|
||||||
|
// [8..11] nAvgBytesPerSec
|
||||||
|
// [12..13] nBlockAlign
|
||||||
|
// [14..15] wBitsPerSample = 32
|
||||||
|
// [16..17] cbSize = 22 (extension size beyond the 18-byte WAVEFORMATEX)
|
||||||
|
// [18..19] wValidBitsPerSample = 32
|
||||||
|
// [20..23] dwChannelMask = 0
|
||||||
|
// [24..39] SubFormat GUID: first 2 bytes = subFormatTag (LE), rest = standard
|
||||||
|
// KSDATAFORMAT_SUBTYPE base GUID {00000000-0000-0010-8000-00aa00389b71}
|
||||||
|
std::vector<std::uint8_t> fmt;
|
||||||
|
putU16(fmt, 0xFFFE); // wFormatTag
|
||||||
|
putU16(fmt, channels); // nChannels
|
||||||
|
putU32(fmt, sampleRate); // nSamplesPerSec
|
||||||
|
putU32(fmt, sampleRate * channels * 4u); // nAvgBytesPerSec
|
||||||
|
putU16(fmt, static_cast<std::uint16_t>(channels * 4)); // nBlockAlign
|
||||||
|
putU16(fmt, 32); // wBitsPerSample
|
||||||
|
putU16(fmt, 22); // cbSize
|
||||||
|
putU16(fmt, 32); // wValidBitsPerSample
|
||||||
|
putU32(fmt, 0); // dwChannelMask
|
||||||
|
// SubFormat GUID (16 bytes): [subFormatTag, 0x0000, 0x00, 0x00, 0x10, 0x00,
|
||||||
|
// 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71]
|
||||||
|
putU16(fmt, subFormatTag); // bytes [24..25]: the effective format tag
|
||||||
|
putU16(fmt, 0x0000); // bytes [26..27]
|
||||||
|
fmt.push_back(0x00); fmt.push_back(0x00); // bytes [28..29]
|
||||||
|
fmt.push_back(0x10); fmt.push_back(0x00); // bytes [30..31]
|
||||||
|
fmt.push_back(0x80); fmt.push_back(0x00); // bytes [32..33]
|
||||||
|
fmt.push_back(0x00); fmt.push_back(0xaa); // bytes [34..35]
|
||||||
|
fmt.push_back(0x00); fmt.push_back(0x38); // bytes [36..37]
|
||||||
|
fmt.push_back(0x9b); fmt.push_back(0x71); // bytes [38..39]
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> chunks;
|
||||||
|
putTag(chunks, "fmt ");
|
||||||
|
putU32(chunks, static_cast<std::uint32_t>(fmt.size())); // 40
|
||||||
|
chunks.insert(chunks.end(), fmt.begin(), fmt.end());
|
||||||
|
putTag(chunks, "data");
|
||||||
|
putU32(chunks, dataBytes);
|
||||||
|
for (std::size_t f = 0; f < frames; ++f)
|
||||||
|
for (std::uint16_t c = 0; c < channels; ++c)
|
||||||
|
putFloat(chunks, value(f, c));
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> wav;
|
||||||
|
putTag(wav, "RIFF");
|
||||||
|
putU32(wav, static_cast<std::uint32_t>(4 + chunks.size()));
|
||||||
|
putTag(wav, "WAVE");
|
||||||
|
wav.insert(wav.end(), chunks.begin(), chunks.end());
|
||||||
|
return wav;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Parse tests -------------------------------------------------------------
|
||||||
|
|
||||||
|
static void testParseCanonicalStereo() {
|
||||||
|
auto wav = buildFloatWav(2, 48000, 5,
|
||||||
|
[](std::size_t f, std::uint16_t c) {
|
||||||
|
return static_cast<float>(f) + 0.1f * c;
|
||||||
|
});
|
||||||
|
WavLayout L = parseWavLayout(wav);
|
||||||
|
CHECK(L.valid);
|
||||||
|
CHECK(L.channelCount == 2);
|
||||||
|
CHECK(L.sampleRate == 48000);
|
||||||
|
CHECK(L.dataByteLength == 5 * 2 * 4);
|
||||||
|
CHECK(L.frameCount() == 5);
|
||||||
|
// data body sits after RIFF(12) + fmt(8 header + 16 body) + data(8 header) = 44.
|
||||||
|
CHECK(L.dataByteOffset == 44);
|
||||||
|
CHECK(L.dataSizeFieldOffset == 40); // the 4 bytes before dataByteOffset
|
||||||
|
CHECK(L.riffSizeFieldOffset == 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testParseMonoAndLeadingChunk() {
|
||||||
|
// A leading LIST/INFO chunk before fmt must be skipped by the walk.
|
||||||
|
auto wav = buildFloatWav(1, 44100, 3,
|
||||||
|
[](std::size_t f, std::uint16_t) {
|
||||||
|
return static_cast<float>(f);
|
||||||
|
},
|
||||||
|
/*leadingJunk=*/true);
|
||||||
|
WavLayout L = parseWavLayout(wav);
|
||||||
|
CHECK(L.valid);
|
||||||
|
CHECK(L.channelCount == 1);
|
||||||
|
CHECK(L.frameCount() == 3);
|
||||||
|
// Data still parses correctly despite the leading chunk shifting its offset.
|
||||||
|
auto pcm = extractFloatFrames(wav, L, 0, 3);
|
||||||
|
CHECK(pcm.size() == 3);
|
||||||
|
CHECK(pcm[0] == 0.0f && pcm[1] == 1.0f && pcm[2] == 2.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testParseRejectsNon32BitAndNonWav() {
|
||||||
|
// 16-bit PCM (tag 1, bits 16) -> rejected.
|
||||||
|
auto pcm16 = buildFloatWav(2, 48000, 4,
|
||||||
|
[](std::size_t, std::uint16_t) { return 0.0f; },
|
||||||
|
false, /*fmtTag=*/1, /*bits=*/16);
|
||||||
|
CHECK(!parseWavLayout(pcm16).valid);
|
||||||
|
|
||||||
|
// Not a RIFF file.
|
||||||
|
std::vector<std::uint8_t> junk = {'N','O','P','E', 0,0,0,0, 'W','A','V','E'};
|
||||||
|
CHECK(!parseWavLayout(junk).valid);
|
||||||
|
|
||||||
|
// Too short to hold even the RIFF header.
|
||||||
|
std::vector<std::uint8_t> tiny = {'R','I','F','F'};
|
||||||
|
CHECK(!parseWavLayout(tiny).valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testParseRejectsLyingDataLength() {
|
||||||
|
// Build a valid WAV, then inflate the `data` size field so it claims more bytes
|
||||||
|
// than the buffer holds -> must be rejected (no OOB trust).
|
||||||
|
auto wav = buildFloatWav(2, 48000, 4,
|
||||||
|
[](std::size_t, std::uint16_t) { return 1.0f; });
|
||||||
|
WavLayout good = parseWavLayout(wav);
|
||||||
|
CHECK(good.valid);
|
||||||
|
// Overwrite the data size field with a huge value.
|
||||||
|
wav[good.dataSizeFieldOffset + 0] = 0xFF;
|
||||||
|
wav[good.dataSizeFieldOffset + 1] = 0xFF;
|
||||||
|
wav[good.dataSizeFieldOffset + 2] = 0xFF;
|
||||||
|
wav[good.dataSizeFieldOffset + 3] = 0x7F;
|
||||||
|
CHECK(!parseWavLayout(wav).valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Extraction tests --------------------------------------------------------
|
||||||
|
|
||||||
|
static void testExtractTailWindow() {
|
||||||
|
// Stereo, 10 frames. Sample value encodes frame+channel so a mis-index is caught.
|
||||||
|
auto wav = buildFloatWav(2, 48000, 10,
|
||||||
|
[](std::size_t f, std::uint16_t c) {
|
||||||
|
return static_cast<float>(f) * 10.0f + c;
|
||||||
|
});
|
||||||
|
WavLayout L = parseWavLayout(wav);
|
||||||
|
CHECK(L.valid);
|
||||||
|
|
||||||
|
// The "tail region" the realtime trim scans: frames 6..9 (start at frame 6).
|
||||||
|
auto tail = extractFloatFrames(wav, L, 6, 100 /*clamps*/);
|
||||||
|
CHECK(tail.size() == 4 * 2); // frames 6,7,8,9, 2 channels each
|
||||||
|
CHECK(tail[0] == 60.0f && tail[1] == 61.0f); // frame 6: L=60,R=61
|
||||||
|
CHECK(tail[6] == 90.0f && tail[7] == 91.0f); // frame 9: L=90,R=91
|
||||||
|
|
||||||
|
// Out-of-range start -> empty.
|
||||||
|
CHECK(extractFloatFrames(wav, L, 10, 4).empty());
|
||||||
|
CHECK(extractFloatFrames(wav, L, 99, 4).empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Truncate-plan tests -----------------------------------------------------
|
||||||
|
|
||||||
|
static void testTruncatePlanKeepFewer() {
|
||||||
|
auto wav = buildFloatWav(2, 48000, 10,
|
||||||
|
[](std::size_t, std::uint16_t) { return 0.0f; });
|
||||||
|
WavLayout L = parseWavLayout(wav);
|
||||||
|
CHECK(L.valid);
|
||||||
|
|
||||||
|
// Keep 4 of 10 frames.
|
||||||
|
WavTruncatePlan p = planWavTruncate(L, 4);
|
||||||
|
CHECK(p.valid);
|
||||||
|
const std::size_t bpf = 2 * 4; // channels * 4 bytes
|
||||||
|
CHECK(p.newDataSize == 4 * bpf); // 32 bytes of PCM kept
|
||||||
|
CHECK(p.newFileByteLength == L.dataByteOffset + 4 * bpf); // 44 + 32 = 76
|
||||||
|
CHECK(p.newRiffSize == p.newFileByteLength - 8);
|
||||||
|
CHECK(p.dataSizeFieldOffset == L.dataSizeFieldOffset);
|
||||||
|
CHECK(p.riffSizeFieldOffset == 4);
|
||||||
|
|
||||||
|
// Applying the plan yields a buffer that re-parses to exactly 4 frames.
|
||||||
|
std::vector<std::uint8_t> trimmed(wav.begin(),
|
||||||
|
wav.begin() + p.newFileByteLength);
|
||||||
|
// Patch the two size fields (what the shell does before truncating on disk).
|
||||||
|
auto writeU32 = [](std::vector<std::uint8_t>& b, std::size_t off, std::uint32_t v) {
|
||||||
|
b[off + 0] = static_cast<std::uint8_t>(v & 0xFF);
|
||||||
|
b[off + 1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
|
||||||
|
b[off + 2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
|
||||||
|
b[off + 3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
|
||||||
|
};
|
||||||
|
writeU32(trimmed, p.dataSizeFieldOffset, p.newDataSize);
|
||||||
|
writeU32(trimmed, p.riffSizeFieldOffset, p.newRiffSize);
|
||||||
|
|
||||||
|
WavLayout L2 = parseWavLayout(trimmed);
|
||||||
|
CHECK(L2.valid);
|
||||||
|
CHECK(L2.frameCount() == 4);
|
||||||
|
CHECK(L2.dataByteLength == 4 * bpf);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testTruncatePlanKeepAllIsNoOp() {
|
||||||
|
auto wav = buildFloatWav(1, 48000, 6,
|
||||||
|
[](std::size_t, std::uint16_t) { return 0.0f; });
|
||||||
|
WavLayout L = parseWavLayout(wav);
|
||||||
|
WavTruncatePlan p = planWavTruncate(L, 6); // keep all
|
||||||
|
CHECK(p.valid);
|
||||||
|
CHECK(p.newFileByteLength == wav.size()); // unchanged
|
||||||
|
CHECK(p.newDataSize == L.dataByteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Extensible format tests -------------------------------------------------
|
||||||
|
|
||||||
|
// A WAVE_FORMAT_EXTENSIBLE fmt with SubFormat tag 0x0001 (PCM integer) and
|
||||||
|
// bitsPerSample==32 must be REJECTED — it is 32-bit integer, not 32-bit float.
|
||||||
|
static void testExtensiblePcmIntegerRejected() {
|
||||||
|
auto wav = buildExtensibleWav(2, 48000, 4,
|
||||||
|
[](std::size_t, std::uint16_t) { return 0.0f; },
|
||||||
|
/*subFormatTag=*/0x0001); // PCM integer
|
||||||
|
CHECK(!parseWavLayout(wav).valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A WAVE_FORMAT_EXTENSIBLE fmt with SubFormat tag 0x0003 (IEEE float) and
|
||||||
|
// bitsPerSample==32 must be ACCEPTED and parse + trim correctly.
|
||||||
|
static void testExtensibleFloatAccepted() {
|
||||||
|
auto wav = buildExtensibleWav(2, 48000, 5,
|
||||||
|
[](std::size_t f, std::uint16_t c) {
|
||||||
|
return static_cast<float>(f) + 0.1f * c;
|
||||||
|
},
|
||||||
|
/*subFormatTag=*/0x0003); // IEEE float
|
||||||
|
WavLayout L = parseWavLayout(wav);
|
||||||
|
CHECK(L.valid);
|
||||||
|
CHECK(L.channelCount == 2);
|
||||||
|
CHECK(L.sampleRate == 48000);
|
||||||
|
CHECK(L.frameCount() == 5);
|
||||||
|
|
||||||
|
// Frame extraction works correctly.
|
||||||
|
auto pcm = extractFloatFrames(wav, L, 0, 2);
|
||||||
|
CHECK(pcm.size() == 4);
|
||||||
|
CHECK(pcm[0] == 0.0f); // frame 0, channel 0
|
||||||
|
CHECK(pcm[1] == 0.1f); // frame 0, channel 1
|
||||||
|
|
||||||
|
// Truncate plan is valid and re-parses cleanly.
|
||||||
|
WavTruncatePlan p = planWavTruncate(L, 3);
|
||||||
|
CHECK(p.valid);
|
||||||
|
CHECK(p.newDataSize == 3 * 2 * 4u);
|
||||||
|
std::vector<std::uint8_t> trimmed(wav.begin(), wav.begin() + p.newFileByteLength);
|
||||||
|
auto writeU32 = [](std::vector<std::uint8_t>& b, std::size_t off, std::uint32_t v) {
|
||||||
|
b[off + 0] = static_cast<std::uint8_t>(v & 0xFF);
|
||||||
|
b[off + 1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
|
||||||
|
b[off + 2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
|
||||||
|
b[off + 3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
|
||||||
|
};
|
||||||
|
writeU32(trimmed, p.dataSizeFieldOffset, p.newDataSize);
|
||||||
|
writeU32(trimmed, p.riffSizeFieldOffset, p.newRiffSize);
|
||||||
|
WavLayout L2 = parseWavLayout(trimmed);
|
||||||
|
CHECK(L2.valid);
|
||||||
|
CHECK(L2.frameCount() == 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testTruncatePlanKeepZeroAndGrowRejected() {
|
||||||
|
auto wav = buildFloatWav(2, 48000, 5,
|
||||||
|
[](std::size_t, std::uint16_t) { return 0.0f; });
|
||||||
|
WavLayout L = parseWavLayout(wav);
|
||||||
|
|
||||||
|
WavTruncatePlan zero = planWavTruncate(L, 0);
|
||||||
|
CHECK(zero.valid);
|
||||||
|
CHECK(zero.newDataSize == 0);
|
||||||
|
CHECK(zero.newFileByteLength == L.dataByteOffset); // header only
|
||||||
|
|
||||||
|
// keptFrames > total -> refused (never grow a file).
|
||||||
|
CHECK(!planWavTruncate(L, 6).valid);
|
||||||
|
|
||||||
|
// Invalid layout -> invalid plan.
|
||||||
|
WavLayout bad;
|
||||||
|
CHECK(!planWavTruncate(bad, 0).valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testParseCanonicalStereo();
|
||||||
|
testParseMonoAndLeadingChunk();
|
||||||
|
testParseRejectsNon32BitAndNonWav();
|
||||||
|
testParseRejectsLyingDataLength();
|
||||||
|
testExtractTailWindow();
|
||||||
|
testTruncatePlanKeepFewer();
|
||||||
|
testTruncatePlanKeepAllIsNoOp();
|
||||||
|
testTruncatePlanKeepZeroAndGrowRejected();
|
||||||
|
testExtensiblePcmIntegerRejected();
|
||||||
|
testExtensibleFloatAccepted();
|
||||||
|
|
||||||
|
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||||
|
return g_fail ? 1 : 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user