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:
2026-07-25 23:30:44 -04:00
42 changed files with 5289 additions and 140 deletions
+4 -2
View File
@@ -22,7 +22,7 @@ Vendors two submodules (see `.gitmodules`):
cmake --build build
ctest --test-dir build
Eight targets:
Key targets (see CMakeLists.txt for the full list):
| Target | Kind | Purpose |
|---|---|---|
@@ -33,6 +33,7 @@ Eight targets:
| `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. |
| `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`). |
### 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.
- `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`).
- `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:**
- `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.
- `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`.
- `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.
+56 -1
View File
@@ -74,6 +74,10 @@ target_include_directories(tab_strip PUBLIC src)
# ---------------------------------------------------------------------------
add_library(view_mode_model STATIC src/view_mode_model.cpp)
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
@@ -86,6 +90,28 @@ add_library(view_tree STATIC src/view_tree.cpp)
target_include_directories(view_tree PUBLIC src)
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`
# 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_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).
# ---------------------------------------------------------------------------
@@ -179,6 +219,14 @@ add_executable(view_tree_tests tests/test_view_tree.cpp)
target_link_libraries(view_tree_tests PRIVATE view_tree)
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)
target_link_libraries(insert_plan_tests PRIVATE insert_plan)
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)
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).
# ---------------------------------------------------------------------------
@@ -229,10 +281,13 @@ add_library(reaper_reasampler MODULE
src/view_tree.cpp
src/view.cpp
src/track_guid.cpp
src/guid_diff.cpp
src/lane_keys.cpp
src/item_read.cpp
src/actions.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})
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
+226 -5
View File
@@ -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
**Goal:** Bindable capture actions for master / selected tracks / selected items /
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
same disable-all value the pre-tail exact-bounds capture used.
- **`tail_control` pure module** (`src/tail_control.{h,cpp}`): REAPER-free logic for
the panel toggle. `kDefaultManualTailMs = 2000.0` (2 s). Fine-adjust UI (±
click zones / scroll) is a noted follow-on; this pass ships a fixed default.
- **Follow-ons noted, not done:** Manual fine-adjust UI; per-project persistence of
the toggle (currently extension-session lifetime, resets to None on unload); T2
realtime tail.
the panel toggle. `kDefaultManualTailMs = 2000.0` (2 s). Fine-adjust UI (scroll-wheel
in 250 ms steps) and per-project persistence landed as T1-followons (see below).
- **Follow-ons resolved:** Manual fine-adjust UI and per-project persistence of the
toggle landed as T1-followons. T2 realtime tail landed separately.
---
## 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 08 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
View File
@@ -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
header readout vs. lit-tab treatment. The "visually unmistakable" requirement is
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 B1B4, 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 B1B4),
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).
+239 -91
View File
@@ -23,21 +23,47 @@ state persists via the index.
- [ ] Slot model + slot↔sample assignment.
- [ ] "Capture to slot N" / "insert slot N" actions, MIDI-bindable.
## Milestone 10 — provenance + null-test verify action
**Goal:** Provenance (parent sample id + FX-chain snapshot) and "re-capture from
source"; ship the null-test verification action. CONTEXT.md §Precision invariants,
Build order 10.
**Verify (in DAW):** **Null test** — a dry offline capture of a range, re-inserted
at its source position, nulls to silence against the source. This action is the
tool's trust anchor and must pass.
## Milestone 10 — provenance (re-capture from source)
**Goal:** Populate `Sample.provenance` (parent sample id + a capture-recipe
fingerprint) on resample-from-sample, and ship a **"re-capture from source"**
action that regenerates a sample from its recorded source. Reconciled with the
dual-canvas (Phase D2) model. CONTEXT.md §Data model, §capture; product framing +
the settled reconciliation in `docs/product/provenance.md`.
**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
snapshot string).
- [ ] "Re-capture from source" action.
- [ ] Null-test verification action (capture → re-insert at source pos → assert
silence sum).
> **Reshaped from the old "provenance + null-test verify" M10.** **Cut (fixed by
> Daniel):** the null-test verification *action* and the true-pre-FX-dry *mechanism*
> the old note required — both dropped, see `docs/product/provenance.md` §What was
> cut. **Kept:** provenance + re-capture. The `Sample.provenance` struct and its JSON
> 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
**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.
- [ ] Conform-on-insert (explicit).
- [ ] 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 M8M11. 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)
> **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
> point lands, doc-keeper moves it to `COMPLETED.md`.
>
> **D2-W1 (pure lane extension) has landed** — see `COMPLETED.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.
- [ ] 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.
> **D2-W1 (pure lane extension), D2-W2 (shell: lane application + new-content
> detection), D2-W3-A (lane minting + item→lane assignment + persist round-trip),
> and D2-W3-B (item-level mode actions + W3-A polish) have all landed** — see
> `COMPLETED.md`. **Phase D2 is functionally complete.**
>
> **Deferred:** panel UI indicator for per-track lane/mode state (a per-track
> lane-split marker). The mode switch already shows the active mode; no natural
> cheap home for a per-track indicator was found in the bank panel. Explicitly
> deferred — not silently dropped. Can be picked up later if wanted.
---
@@ -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
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 B1B5 (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 B1B4, 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)
**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
@@ -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
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
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
@@ -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
vs. lit-tab. "Unmistakable" is settled; only placement is open. Polish detail.
(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
B1B5 (retro-touches B1B4) — 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).
+252
View File
@@ -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.
+364
View File
@@ -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 B1B4**, 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
View File
@@ -26,9 +26,11 @@
#include "bank_book.h" // BankBook, nextBankId, TransferResult, kPoolBankId (B1)
#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 "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 "reaper_plugin.h" // reaper_plugin_info_t, gaccel_register_t (full defs)
@@ -36,6 +38,10 @@
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountSelectedTracks
#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_Main_SaveProject
#define REAPERAPI_WANT_ShowConsoleMsg
@@ -43,6 +49,8 @@
#define REAPERAPI_WANT_ShowMessageBox
#define REAPERAPI_WANT_genGuid
#define REAPERAPI_WANT_guidToString
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
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* kIdUntag = "CEREBELLUM_REASAMPLER_VIEW_UNTAG";
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
// 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_cmdUntag = 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
// mirror-unregister it. One per action.
@@ -82,6 +99,9 @@ gaccel_register_t g_accelTagDesign{};
gaccel_register_t g_accelTagArrange{};
gaccel_register_t g_accelUntag{};
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
// 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);
}
// 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
// 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 —
@@ -218,6 +269,48 @@ void doShowBoth() {
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
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");
g_cmdShowBoth = registerAction(rec, kIdShowBoth, g_accelShowBoth,
"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) {
@@ -253,12 +354,25 @@ bool designViewHandleCommand(int command) {
if (command == g_cmdUntag) { doUntag(); 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
}
void designViewUnregisterActions(reaper_plugin_info_t* rec) {
// 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("-command_id", (void*)kIdShowBoth);
rec->Register("-gaccel", (void*)&g_accelUntag);
+25
View File
@@ -3,6 +3,7 @@
#include "bank_grid.h"
#include <algorithm>
#include <cmath>
namespace reasampler {
@@ -199,4 +200,28 @@ Selection navigate(const Selection& current, NavKey key, int cols, int itemCount
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
+20
View File
@@ -163,4 +163,24 @@ enum class NavKey { Left, Right, Up, Down, Home, End };
Selection navigate(const Selection& current, NavKey key, int cols, int itemCount,
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
+312 -11
View File
@@ -40,6 +40,8 @@
#include <cstdint>
#include <cstdlib> // std::abs (drag threshold)
#include <filesystem>
#include <map>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>
@@ -48,12 +50,17 @@
#include "bank_grid.h"
#include "bank_model.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 "peaks.h"
#include "persist.h"
#include "tab_strip.h"
#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_mode_model.h" // autoTagNewContent / NewItem (D2 Wave 2)
// 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).
@@ -76,9 +83,20 @@
#define REAPERAPI_WANT_DockWindowActivate
#define REAPERAPI_WANT_DockWindowRemove
#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_PCM_Source_CreateFromFile
#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_StopPreview
#define REAPERAPI_WANT_GetUserInputs
@@ -226,13 +244,43 @@ struct PanelState {
std::string dropBankId; // destination bank id when dropKind==Tab
// --- 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 -----------------------------------------------------
preview_register_t preview{};
PCM_source* previewSrc = nullptr;
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;
@@ -381,8 +429,10 @@ void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env,
const int innerW = rect.width - 4;
for (int i = 0; i < nbins; ++i) {
const int x = rect.x + 2 + (nbins > 1 ? (i * (innerW - 1)) / (nbins - 1) : 0);
int yMax = midY - static_cast<int>(bins[i].max * halfSpan);
int yMin = midY - static_cast<int>(bins[i].min * halfSpan);
// min<=max always (peaks invariant). Draw a vertical line from the
// 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 (yMin > bandTop + bandH - 1) yMin = bandTop + bandH - 1;
LICE_Line(bmp, x, yMin, x, yMax, kColWaveform, 1.0f, 0, false);
@@ -456,6 +506,15 @@ RECT panelFooter(int w, int h) {
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) {
const RECT f = panelFooter(w, h);
if (f.top >= f.bottom) return;
@@ -465,7 +524,7 @@ void drawTailFooter(LICE_IBitmap* bmp, int w, int h) {
HDC dc = bmp->getDC();
if (!dc) return;
const std::string label = tailToggleLabel(g_panel.tail);
const std::string label = tailToggleLabel(currentTail());
RECT rc = f;
rc.left += 8;
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);
}
// 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 -----------------------------------------------------------
//
// Every rect below is derived from the client size + fullHeight state, and BOTH paint
@@ -851,7 +934,146 @@ bool refreshFingerprint() {
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() {
if (g_panel.previewInited) return;
@@ -1298,10 +1520,15 @@ void handleClick(int x, int y) {
}
}
// Tail footer: a click anywhere cycles the tail mode.
const RECT f = panelFooter(w, h);
if (f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom) {
g_panel.tail.mode = cycleTailMode(g_panel.tail.mode);
// Tail footer: a click anywhere in the bottom strip cycles the tail mode
// (None -> Auto -> Manual -> None) and repaints. It mutates the SESSION's tail
// setting (which the capture actions read and persist saves with the project) and
// 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();
return;
}
@@ -1370,6 +1597,34 @@ void handleClick(int x, int y) {
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).
int columnsForRegion(Region reg) {
RECT cr{};
@@ -1597,6 +1852,19 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
invalidatePanel();
}
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:
if (GetCapture() == hwnd) ReleaseCapture();
stopAudition();
@@ -1677,14 +1945,47 @@ std::string bankPanelSelectedSourceBankId() {
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() {
// 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 (refreshFingerprint())
InvalidateRect(g_panel.hwnd, nullptr, FALSE);
}
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);
return s;
}
+11
View File
@@ -65,6 +65,17 @@ std::string bankPanelSelectedSourceBankId();
// reflected without the panel diffing the bank itself.
void bankPanelRefresh();
// Notifies the panel that persist just (re)loaded a project's view model (membership +
// active mode). main.cpp calls this on the exact tick it drains persist's load signal
// and reapplies the active mode. It re-arms the new-content detector so the just-loaded
// project's PRE-EXISTING content is taken as the baseline (reported as nothing new),
// never diffed against the previously-open project and mass-tagged into the active mode.
// This coordinates the detector's project-identity signal with persist's authoritative
// (GUID-primary) one — the two can no longer diverge on a recycled ReaProject* address,
// which is what caused a project opened in Design to mis-tag its Arrange tracks. READ/
// arm of panel state only; no project or bank mutation.
void bankPanelNotifyProjectLoaded();
// The panel's current tail-mode setting (mode + Manual length), read by the plain
// CAPTURE_ITEM / CAPTURE_TRACK actions when building a CaptureRequest so a capture
// applies whatever the panel toggle is set to. Default None (exact bounds) — a
+182 -5
View File
@@ -71,13 +71,18 @@
#include <chrono>
#include <cstdint>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
#include "capture_paths.h"
#include "peaks.h" // lastFrameAboveThreshold, AudioSample
#include "realtime_record.h"
#include "render_settings.h" // autoTrimEndRatio, realtimeRecordWindowEnd
#include "wav_trim.h" // parseWavLayout, extractFloatFrames, planWavTruncate
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
@@ -178,6 +183,12 @@ public:
BankPaths paths_;
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
// 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
@@ -311,6 +322,128 @@ private:
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,
// 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
@@ -347,6 +480,19 @@ CaptureResult finalizeRecording(RealtimeCaptureState& st) {
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;
cap.relativePath = st.paths_.relativePath;
cap.uniqueTag = st.uniqueTag_;
@@ -365,9 +511,25 @@ CaptureResult finalizeRecording(RealtimeCaptureState& st) {
result.status = CaptureStatus::Ok;
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 [" +
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;
return result;
}
@@ -448,6 +610,14 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
st->uniqueTag_ = makeUniqueTag();
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
// wrapped in an Undo_BeginBlock/Undo_EndBlock — divergence from the insert/view
// 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_RECMON", 0.0); // no input monitoring
// Record range: time selection over [start,end], play cursor at start. Both were
// snapshotted and will be restored by restore().
double rs = request.startSeconds, re = request.endSeconds;
// Record range: time selection over [start, recordWindowEnd], play cursor at start.
// recordWindowEnd extends past the request's range end for a tail mode so the
// 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);
SetEditCurPos(request.startSeconds, false, false);
@@ -567,9 +740,13 @@ RealtimeTickResult RealtimeRecordBackend::tick(RealtimeCaptureState& state) {
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.request_.startSeconds,
state.request_.endSeconds);
state.recordWindowEnd_);
// On the Recording -> Finalizing edge, stop OUR project's transport ONCE so REAPER
// begins closing/flushing the recorded take. Project-scoped (OnStopButtonEx(proj_))
+44
View File
@@ -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
+62
View File
@@ -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
+33
View File
@@ -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
+34
View File
@@ -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
+51
View File
@@ -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
+85
View File
@@ -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
View File
@@ -218,8 +218,22 @@ static void OnTimer()
// project saved in Design mode parks the Arrange tracks automatically, no manual
// toggle. Fires exactly once per load (consumeLoadSignal clears it); idle ticks
// skip it. proj = nullptr -> REAPER's active project (the one poll just loaded).
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);
}
// Reflect a live bank change (capture / project load) in the docked grid.
// Cheap when the bank is unchanged (a fingerprint compare); repaints only on
@@ -600,13 +614,20 @@ static void RunCaptureRealtimeTrack()
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;
req.sourceMode = reasampler::SourceMode::SelectedTracks; // realtime track scope
req.startSeconds = src.startSeconds; // exact bounds — no rounding
req.endSeconds = src.endSeconds;
req.wetDry = 1.0; // fully wet (post-fader tap)
req.tailMode = reasampler::TailMode::None; // realtime tail is T2; exact bounds here
req.tailMs = 0.0;
req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle
req.tailMs = tail.manualMs; // Manual-only (pre-clamped); ignored for None/Auto
req.sampleRate = 0; // follow project rate
req.channelCount = 2;
req.bitDepth = reasampler::WavBitDepth::Float32;
@@ -627,8 +648,15 @@ static void RunCaptureRealtimeTrack()
// completion across ticks (UI stays responsive).
g_rtCaptureProject = EnumProjects(-1, nullptr, 0);
g_rtCapture = std::move(st);
ShowConsoleMsg("ReaSampler: realtime capture started — recording in the "
"background; the bank updates when it reaches the range end.\n");
// With a tail mode the recorded window runs PAST the range end (Auto: +8 s then
// 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
+28
View File
@@ -2,6 +2,7 @@
#include <algorithm>
#include <climits>
#include <cmath>
// peaks implementation.
//
@@ -63,4 +64,31 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
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
+38
View File
@@ -66,4 +66,42 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
std::size_t frameCount,
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
+29
View File
@@ -198,6 +198,13 @@ void ReaSamplerSession::saveToActiveProject() {
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
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));
}
@@ -222,6 +229,23 @@ ViewModeModel loadViewModel(ReaProject* proj) {
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
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).
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) {
book_ = BankBook{};
return;
+26 -4
View File
@@ -21,6 +21,7 @@
#include "bank_book.h"
#include "bank_model.h"
#include "tail_control.h"
#include "view_mode_model.h"
namespace reasampler {
@@ -50,6 +51,13 @@ inline constexpr const char* kProjExtBanksKey = "banks";
// FOREVER-STABLE: changing it orphans every already-saved project's 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
// 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
@@ -105,10 +113,19 @@ public:
ViewModeModel& view() { return view_; }
const ViewModeModel& view() const { return view_; }
// Serialize the current book (under the `banks` key) and view model 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).
// The docked panel's tail setting (mode + manualMs), authoritative here — NOT in
// panel state — so it travels inside the .rpp: persist serializes it on save and
// replaces it on project load exactly as it treats the bank and view model. The
// 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();
// 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.
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 GUID is the PRIMARY signal (a different stored GUID = a different project
// of record = Load, immune to pointer recycling). The pointer disambiguates the
+18
View File
@@ -56,6 +56,24 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
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*/) {
// `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
+14
View File
@@ -114,6 +114,20 @@ struct TailRenderSettings {
// the Auto default or an explicit request (spec §Manual override). Pure + tested.
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
// for SourceMode::Realtime (that is the M8 backend, not offline render).
struct RenderSettingsChoice {
+100 -1
View File
@@ -3,6 +3,10 @@
#include "tail_control.h"
#include <algorithm>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cstring>
namespace reasampler {
@@ -21,13 +25,108 @@ double clampManualMs(double manualMs) {
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) {
switch (setting.mode) {
case TailMode::None: return "Tail: Off";
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
}
// ---------------------------------------------------------------------------
// 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
+26 -5
View File
@@ -9,6 +9,7 @@
// only (plus render_settings for the pure TailMode enum). Builds and unit-tests
// without REAPER.
#include <optional>
#include <string>
#include "render_settings.h" // TailMode (pure enum) — the three-state tail contract
@@ -16,10 +17,15 @@
namespace reasampler {
// 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
// (+/- click zones or scroll) is a noted follow-on; this pass ships a fixed default.
// reverb throw at a moderate tempo) that is well under the 8 s cap. Also the value a
// project with no stored tail setting (older / never-adjusted) falls back to on load.
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
// 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
@@ -41,9 +47,24 @@ TailMode cycleTailMode(TailMode current);
// tailMs into the CaptureRequest. Meaningful only for TailMode::Manual.
double clampManualMs(double manualMs);
// The toggle's label for a setting, e.g. "Tail: Off", "Tail: Auto", "Tail: Manual".
// (Manual omits the length here — the panel is unobtrusive; a length readout can be
// added with the fine-adjust follow-on.) Pure so the exact strings are test-pinned.
// Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped to
// [0, kMaxTailMs]. Positive notches lengthen, negative shorten. Pure so the fine-adjust
// arithmetic (and its clamp at both bounds) is unit-tested; the panel wheel handler
// owns no arithmetic of its own. Meaningful only for TailMode::Manual.
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);
// 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
+442
View File
@@ -10,10 +10,16 @@
#include "view.h"
#include <cstdio>
#include <map>
#include <optional>
#include <set>
#include <string>
#include <utility>
#include <vector>
#include "item_read.h"
#include "lane_keys.h"
#include "track_guid.h"
#include "view_tree.h"
@@ -22,6 +28,7 @@
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
#define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_GetOffline
#define REAPERAPI_WANT_TrackFX_SetOffline
@@ -29,12 +36,56 @@
#define REAPERAPI_WANT_Undo_EndBlock2
#define REAPERAPI_WANT_TrackList_AdjustWindows
#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"
namespace reasampler {
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
// info params driven through the double-valued Get/SetMediaTrackInfo_Value API.
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
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);
}
// 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
// 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
@@ -228,8 +561,117 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
TrackList_AdjustWindows(false);
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);
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
+40
View File
@@ -52,4 +52,44 @@ namespace reasampler {
// registered mode. `proj` may be nullptr to mean REAPER's current project.
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
+142 -4
View File
@@ -1,10 +1,15 @@
#include "view_mode_model.h"
#include <algorithm>
#include <cassert>
#include <cerrno>
#include <climits>
#include <cstdio>
#include <cstdlib>
#include <set>
#include <utility>
#include "lane_keys.h" // laneNameForMode — the ONE durable managed-lane-key convention
// 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
// 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.
//
// 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;
}
@@ -146,6 +158,116 @@ std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackG
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
// ---------------------------------------------------------------------------
@@ -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
// 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.
#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()) {
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);
plan.lanes.push_back(LanePlayOp{ref.trackGuid, ref.laneKey, lanePlays});
}
@@ -448,7 +582,8 @@ std::string ViewModeModel::serialize() const {
{
bool first = true;
for (const auto& [guid, mem] : membership_.all()) {
if (!first) out += ','; first = false;
if (!first) out += ',';
first = false;
ObjWriter e(out);
e.keyStr("guid", guid);
e.keyBegin("modes");
@@ -456,7 +591,8 @@ std::string ViewModeModel::serialize() const {
{
bool mf = true;
for (const auto& id : mem.modeIds) {
if (!mf) out += ','; mf = false;
if (!mf) out += ',';
mf = false;
writeEscaped(out, id);
}
}
@@ -472,7 +608,8 @@ std::string ViewModeModel::serialize() const {
{
bool first = true;
for (const auto& [guid, snap] : snapshots_) {
if (!first) out += ','; first = false;
if (!first) out += ',';
first = false;
ObjWriter e(out);
e.keyStr("guid", guid);
e.keyRaw("showInTcp", intToStr(snap.showInTcp));
@@ -494,7 +631,8 @@ std::string ViewModeModel::serialize() const {
{
bool first = true;
for (const auto& [ref, ownership] : lanes_.all()) {
if (!first) out += ','; first = false;
if (!first) out += ',';
first = false;
ObjWriter e(out);
e.keyStr("trackGuid", ref.trackGuid);
e.keyStr("laneKey", ref.laneKey);
+190
View File
@@ -519,6 +519,196 @@ std::vector<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackG
const std::vector<NewItem>& newItems,
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`
// 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).
+160
View File
@@ -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
View File
@@ -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
+53
View File
@@ -15,6 +15,7 @@
#include "../src/bank_grid.h"
#include <cmath>
#include <cstdio>
#include <string>
#include <vector>
@@ -322,6 +323,52 @@ static void testNavDegenerate() {
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() {
testColumnsForWidth();
testTooNarrowClampsToOneColumn();
@@ -355,6 +402,12 @@ int main() {
testNavFromEmptyFocusesFirst();
testNavDegenerate();
testCompressFullScale();
testCompressZeroIsMidline();
testCompressMidValuesVisible();
testCompressAtAndBelowFloor();
testCompressSignPreserved();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}
+194
View File
@@ -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;
}
+113
View File
@@ -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;
}
+75
View File
@@ -278,6 +278,76 @@ static void testLargeBinCountOverflowGuard() {
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() {
testSineEnvelope();
testRampMonotonic();
@@ -289,6 +359,11 @@ int main() {
testSingleBinWholeBuffer();
testDegenerateInputs();
testLargeBinCountOverflowGuard();
testLastFrameDecayingRamp();
testLastFrameSilence();
testLastFrameAllAbove();
testLastFramePerChannelMaxAbs();
testLastFrameDegenerate();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
+28
View File
@@ -126,6 +126,31 @@ static void testTailManualClampsToCap() {
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 --------------------------
static void testParseSingleTrackAudioArea() {
@@ -267,6 +292,9 @@ int main() {
testAutoTrimRatioDerivesFromDb();
testTailManualFixedNoTrim();
testTailManualClampsToCap();
testRealtimeWindowNoneIsExact();
testRealtimeWindowAutoAddsCap();
testRealtimeWindowManualAddsClampedLength();
testParseSingleTrackAudioArea();
testParseMultipleAreas();
testParseSkipsEnvelopeLaneAreas();
+92 -2
View File
@@ -49,15 +49,53 @@ static void testManualClampCapsAtEightSeconds() {
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 -----------------------
static void testLabelStringsPerMode() {
TailSetting off; off.mode = TailMode::None;
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(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() {
@@ -69,13 +107,65 @@ static void testDefaultSettingIsOff() {
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() {
testCycleOrderIsNoneAutoManualNone();
testCycleThreeStepsReturnsToStart();
testManualClampInRangeIsUnchanged();
testManualClampCapsAtEightSeconds();
testAdjustUpAndDownBySteps();
testAdjustClampsAtUpperBound();
testAdjustClampsAtLowerBound();
testLabelStringsPerMode();
testManualLabelRendersLengthInSeconds();
testDefaultSettingIsOff();
testRoundTripNoneDefault();
testRoundTripManualArbitraryMs();
testRoundTripAuto();
testDeserializeEmptyIsDefault();
testDeserializeMalformedIsDefault();
if (g_fail == 0) std::printf("tail_control: all tests passed\n");
else std::printf("tail_control: %d CHECK(s) FAILED\n", g_fail);
+553
View File
@@ -16,6 +16,7 @@
// guards the in-DAW "all leaves hidden after toggling twice" regression.
#include "../src/view_mode_model.h"
#include "../src/lane_keys.h" // laneNameForMode — assert the minting plan's durable keys
#include <algorithm>
#include <cstdio>
@@ -940,6 +941,40 @@ static void testLaneOwnershipIndex() {
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 --------
//
// 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 -----------------------
static void testLaneJsonRoundTrip() {
@@ -1124,8 +1663,22 @@ int main() {
// D2 two-canvas lane extension
testLaneModeStateAndPlayValues();
testLaneOwnershipIndex();
testLaneOwnershipLastWriterWins();
testManagedOnlyPlannerAndQuery();
testAutoTagDecision();
testPlanItemRetag();
testReconcileUnregisteredModeGuardDecision();
testLaneMintingSingleModeNoSplit();
testLaneMintingMultiModeMintsAndAssignsAll();
testLaneMintingManualLaneExempt();
testLaneMintingThreeModesAndOwnershipKeys();
testLaneMintingFolderDerivedVisibleSplitsOwnMedia();
testLaneMintingLazySingleLaneStillConfines();
testLaneMintingFolderOwnItemsSpanBothModes();
testLaneMintingNewItemOnAlreadySplitFolderYieldsPlan();
testLaneMintingShowBothNotForceSplit();
testLaneMintingSingleModeLeafVisibleOnceNoSplit();
testLaneMintingEmptyFolderNotSplit();
testLaneJsonRoundTrip();
testLaneMalformedJson();
+372
View File
@@ -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;
}