Merge Phase Ψ — the extension trust pass: exact bounds, disjoint solo surfaces, reachable actions, honest drops, real names, true mono
This commit is contained in:
@@ -19,8 +19,9 @@ Per-module detail — what each file owns, its invariants — lives in the twent
|
||||
paths anywhere in the index.
|
||||
- **Material:** must handle full-mix/stem bounces, chops/one-shots, and
|
||||
single-cycle/wavetable grabs equally. That means exact sample-accurate bounds,
|
||||
explicit tail control, channel-count preservation, and loop/zero-crossing
|
||||
handling all matter from day one.
|
||||
explicit tail control, correct channel handling (the exact-bounds channel rule
|
||||
under Precision invariants), and loop/zero-crossing handling all matter from day
|
||||
one.
|
||||
|
||||
## One-time submodule setup
|
||||
|
||||
@@ -181,6 +182,7 @@ Comments carry *why*, and context where non-obvious — never *what* the code al
|
||||
2. `rec->Register("gaccel", &accel)` — puts the action in the Actions list.
|
||||
3. `rec->Register("hookcommand", ...)` — receives every action fired; claim only your own id, return `false` otherwise.
|
||||
4. On unload (`rec == nullptr`), mirror-unregister everything with the same strings prefixed by `'-'`.
|
||||
- **Non-main sections use a different mechanism.** `gaccel_register_t` carries no section field — `command_id` + `gaccel` can only ever produce a Main-section action. To publish into another section (Media Explorer = 32063, MIDI editor = 32060, MIDI event list = 32061, MIDI inline = 32062), register a `custom_action_register_t{uniqueSectionId, idStr, name, extra}` under `"custom_action"`; it returns the command id, or **0 on failure** (e.g. a duplicate `idStr`) — which the caller must tolerate rather than half-register. `idStr` must be unique **across all sections**, so an action published into both Main and a non-main section needs a SECOND id string; the FOREVER-STABLE contract binds it identically from the moment it ships. `custom_action_register_t` has no `ACCEL`, so a non-main entry ships no default keybinding. Dispatch for these ids arrives through `"hookcommand2"` (`bool(KbdSectionInfo*, int command, int val, int val2, int relmode, HWND)`) — `"hookcommand"` runs for the main section only. The two hooks must partition the ids between them; what happens when a command is claimed by both is unspecified by the SDK (`hookcommand2`'s doc says a `true` return prevents further hooks/actions from running, which is in tension with a clean double-fire either way), so nothing may rely on either outcome. On unload, mirror with `"-custom_action"` `[verify — DAW]` (the header confirms the `-` prefix for "most" registration types and spells out only `-pcmsrc` by name; `custom_action` itself is unconfirmed) and `"-hookcommand2"`.
|
||||
|
||||
## Product design docs
|
||||
|
||||
@@ -205,7 +207,7 @@ Plan-style docs live under `docs/`:
|
||||
- **Null test:** a dry offline capture of a range, re-inserted at its source position, nulls to silence against the source — the tool's trust anchor. Ship as a verification action. (Verification action cut per `docs/product/provenance.md` — manual verification only.)
|
||||
- **Bit-identical repeats:** identical offline capture requests produce identical files.
|
||||
- **Non-destructive:** capture never mutates source items or tracks; the realtime backend's temp track is created and removed cleanly, and source routing is restored.
|
||||
- **Exact bounds:** no rounding of the requested range; no added silence unless a tail is explicitly requested; channel count preserved (no silent stereo fold).
|
||||
- **Exact bounds:** no rounding of the requested range; no added silence unless a tail is explicitly requested; **no lossy channel fold** — summing or averaging differing channels is forbidden. The one permitted collapse is lossless: a new capture whose channels are bit-identical per frame (float bit patterns, never an epsilon) lands as a 1-channel file, with `Sample::channelCount` and the file's `fmt` written together so the two can never disagree. Frame count, sample rate and bit depth are untouched by it. Never retroactive — existing entries and files are never rewritten — and ingest is excluded, because an imported file is the user's bytes, not our capture. The superseded wording ("channel count preserved") was already untrue in the other direction: a mono source renders at `RENDER_CHANNELS = 2`. `[verify — DAW]` "lossless" here is a file-bytes property; whether REAPER sums a 1-channel item on a stereo track at the same unity gain as a dual-mono 2-channel item (pan law, mono spread) — the null test's actual playback-chain property — is unconfirmed.
|
||||
- **Relative paths only** in the persisted `BankIndex`.
|
||||
- **Capture FX scope:** two scopes only — item = item/take FX only; track = item FX + the selected track's own track FX. There is no master scope (to capture the master, render a track instead). For both scopes, the out-of-scope chain (ancestors + master track, plus the item's own track for item scope) has its FX, gain, and pan/width/pan-law/mode neutralized to unity — the master track is bypassed as out-of-scope chain, not captured as a scope. Range (time selection or razor) is orthogonal.
|
||||
|
||||
|
||||
@@ -813,3 +813,76 @@ control's appearance/disappearance on the 500 ms sync tick.
|
||||
disagree whenever a bank blob's loop for a capture differs from the copy in the instance's
|
||||
own refs table. Pre-existing — `bakeWindowNeedsHold` is only a new *consumer* of
|
||||
`pickedMarkers`, not the origin of the divergence.
|
||||
|
||||
### Phase Ψ — The extension trust pass: exact bounds, disjoint solo surfaces, reachable actions, honest drops, real names, true mono
|
||||
|
||||
Seven tracks across three waves, code-complete, reviewed, remediated, and integrated on
|
||||
this branch: 89/89 tests passing, a clean build. Phase Ψ came from a direct list of
|
||||
seven defects and refinements (Daniel, 2026-08-01) rather than a backing product doc —
|
||||
see `docs/PLAN.md`'s Phase Ψ section for the Ψ.1–Ψ.7 provenance list this phase traces
|
||||
back to.
|
||||
|
||||
**Ψ-W1-T1 — `capture-range-exactness`.** A ranged item capture now renders the
|
||||
requested window instead of the whole item, by re-sourcing through the selected-tracks
|
||||
render when the item extent does not already print the window. **Deviation worth
|
||||
recording:** the spec named two candidate architectures; the engineer shipped a
|
||||
*conditional* form of candidate (a) — the full-extent case runs literally unchanged
|
||||
code, which makes the byte-identity regression floor structural rather than hoped-for,
|
||||
and makes the fix cheap to revert if the underlying inference proves wrong. Also added:
|
||||
a transient isolation guard cutting `B_MAINSEND` on direct folder children and muting
|
||||
receives so an item capture stays true to item scope, and a post-render frame-count
|
||||
gate (±1 tolerance, tail-None only) that refuses and self-cleans a widened render. New
|
||||
modules `core/capture/render_window`, `core/capture/track_topology`,
|
||||
`shell/capture/render_selection`, `shell/capture/render_isolation`.
|
||||
|
||||
**Ψ-W1-T2 — `mode-switch-discipline`.** Per-mode SOLO surfaces: solo cached, cleared,
|
||||
and replayed across a Design/Arrange switch, with the switch itself visibly refused
|
||||
while the transport runs. New `core/view/solo_cache`, `shell/view/view_solo`. Also
|
||||
closed a pre-existing bug where a footer mode-segment click never persisted view state.
|
||||
Required amending a thrice-stated never-touch-solo invariant (`src/shell/view/CLAUDE.md`,
|
||||
`src/core/view/CLAUDE.md`, `docs/product/design-view.md`) to the snapshot sense of
|
||||
non-destructive: solo is cached per mode on a real switch and restored verbatim, not
|
||||
left untouched absolutely the way `B_MUTE` and the master track are.
|
||||
|
||||
**Ψ-W1-T3 — `media-explorer-section`.** The Media Explorer import action is published
|
||||
into REAPER's Media Explorer action section (32063) via `custom_action` +
|
||||
`hookcommand2`, while remaining in Main so existing keybindings survive. A second
|
||||
FOREVER-STABLE id was minted — `INGEST_IMPORT_MEDIA_EXPLORER_MX` — permanent, per
|
||||
channel. The root `CLAUDE.md` REAPER extension contract gained the second,
|
||||
non-main registration mechanism alongside the original four-step main-section pattern.
|
||||
|
||||
**Ψ-W1-T4 — `drop-target-resolution`.** The drag-out gesture became a per-move,
|
||||
stateless law: target class resolves from what is under the cursor on every move,
|
||||
transitions reversible, OS hand-off reserved for leaving REAPER. The whole TCP/MCP is
|
||||
now the instrument-drop hotspot; a single-card arrange drop lands a timeline item at
|
||||
the pointer's track and time; every surface has a defined outcome and cue, no silent
|
||||
no-op release anywhere. New `shell/actions/arrange_drop_win`.
|
||||
|
||||
**Ψ-W2-T1 — `capture-naming`.** Captures are named after their source track plus a
|
||||
discriminator (`<Track> [+N] [#ordinal] MM-DD HHMM`) at every interactive mint site,
|
||||
with the name shown on the panel card over a scrim clearing the 4.5:1 contrast floor.
|
||||
New `core/capture/capture_name`. Recapture, ingest, and the bake deliberately keep
|
||||
their own naming.
|
||||
|
||||
**Ψ-W2-T2 — `mono-collapse`.** A capture whose channels are bit-identical collapses to
|
||||
one lossless mono channel, written via temp file plus atomic rename, with the index's
|
||||
channel count now *measured* off the landed file rather than echoed from the request.
|
||||
Required amending root `CLAUDE.md`'s channel-count-preserved precision invariant — the
|
||||
current wording ("no lossy channel fold... one permitted collapse is lossless") is the
|
||||
landed form.
|
||||
|
||||
**Ψ-W3-T1 — `track-scope-range` — a wave that did not exist when the phase was
|
||||
scoped, and consolidates none of the original seven.** Opened after Ψ-W2's review
|
||||
surfaced that the track scope carried the same multi-track stem-collapse hole
|
||||
Ψ-W1-T1 had just closed for item scope. Now any multi-track selected-tracks render
|
||||
refuses, both scopes, keyed on the render *source* rather than the capture scope.
|
||||
Realtime deliberately diverges — it sums correctly and was left untouched.
|
||||
|
||||
**None of the seven is DAW-verified.** All are code-complete and unit-tested; none
|
||||
has been confirmed in a running REAPER. Several rest on a **shared unverified
|
||||
inference** about how REAPER's selected-tracks render source interacts with custom
|
||||
time bounds — and Ψ-W3's refusal now rests on it too, meaning if the inference is
|
||||
wrong that refusal costs a working capture. Each track's DAW-verification obligation
|
||||
is recorded in `docs/PLAN.md`'s Phase Ψ section; `docs/verify-track-scope-multitrack.md`
|
||||
is a new standalone verification script on this branch, for Ψ-W3-T1's multi-track
|
||||
refusal specifically. No human has observed any of these seven behaviors in a DAW.
|
||||
|
||||
+114
-598
@@ -2063,6 +2063,19 @@ provenance, cited throughout as Ψ.1–Ψ.7:
|
||||
| Ψ.1 | Ψ-W2-T1 | `ppsi-w2-t1-capture-naming` |
|
||||
| Ψ.6 | Ψ-W2-T2 | `ppsi-w2-t2-mono-collapse` |
|
||||
|
||||
**Ψ-W3 is not one of the seven** — opened mid-phase, after Ψ-W2's review surfaced that
|
||||
the track scope carried the same multi-track stem-collapse hole Ψ-W1-T1 had just closed
|
||||
for item scope. It consolidates none of the original seven and carries no `Ψ-item` row
|
||||
above; see Ψ-W3 below.
|
||||
|
||||
**All three waves have landed — Phase Ψ is complete.** W1 and W2 each carry their own
|
||||
landed notes below; W3 carries its own too. See `docs/COMPLETED.md` for every track's
|
||||
full narrative. **None of the seven tracks is DAW-verified** — all are code-complete and
|
||||
unit-tested, several resting on a shared unverified inference about how REAPER's
|
||||
selected-tracks render source interacts with custom time bounds, which Ψ-W3-T1's
|
||||
refusal now also rests on; each track's DAW-verification obligation is restated inline
|
||||
below.
|
||||
|
||||
Ψ.2 and Ψ.3 share one track deliberately: they share one chokepoint — `applyMode`, the
|
||||
sole mode mutator (`shell/view/view.cpp:380-469`) — and one discriminator
|
||||
(`targetModeId != model.activeModeId()`, the test that distinguishes a real switch from a
|
||||
@@ -2074,9 +2087,8 @@ exception — Ψ-W2-T2 touches `shell/instrument/processor_reload.cpp` for a sta
|
||||
and an index/file consistency check, and that touch is bounded to the minimum in its
|
||||
surface boundary precisely because Γ is live in that directory.
|
||||
|
||||
**Three invariant amendments are DELIVERABLES of this phase, not asides.** Each is
|
||||
scheduled in — and an acceptance criterion of — its owning track. This spec schedules
|
||||
them; the implementing track performs them:
|
||||
**Three invariant amendments were DELIVERABLES of this phase, not asides.** Each landed
|
||||
in its owning track, as an acceptance criterion of that track:
|
||||
|
||||
1. **The never-touch-solo rule** (Ψ-W1-T2): `src/shell/view/CLAUDE.md:14-17`,
|
||||
`src/core/view/CLAUDE.md:10`, and `docs/product/design-view.md:160-165` + `:588-592`.
|
||||
@@ -2103,413 +2115,68 @@ internal-drag path").
|
||||
|
||||
### Ψ-W1 — Exact bounds, disciplined switches, reachable actions, resolved drops
|
||||
|
||||
**Depends on:** nothing in this phase. **Four tracks, disjoint by surface:**
|
||||
|
||||
| Track | Owns |
|
||||
|---|---|
|
||||
| **T1** `capture-range-exactness` | `core/capture/render_settings`, `shell/capture/capture.cpp`'s render-configuration block, `shell/capture/scope_resolve` (source-mode selection only), `tests/test_render_settings.cpp` |
|
||||
| **T2** `mode-switch-discipline` | `core/view/view_mode_model` (solo cache), `shell/view/view.cpp` (`applyMode` seams + WANT block), `shell/actions/design_view_actions.cpp` (refusal feedback), `shell/panel/panel_input.cpp` **footer mode-segment block only** (`:300-309`), the mode segment's disabled state in `core/ui/footer_bar` + `shell/panel/panel_render.cpp` |
|
||||
| **T3** `media-explorer-section` | `shell/actions/ingest.cpp` (register/dispatch/unregister), **the registration block in `src/app/main.cpp`** (the `hookcommand2` hook + unload mirror), the root-`CLAUDE.md` contract amendment |
|
||||
| **T4** `drop-target-resolution` | `core/ui/drag_out`, `core/wire/instrument_drop`, `shell/panel/panel_drag.cpp`, `shell/actions/instrument_drop_win.cpp`, `shell/actions/drag_out_win.cpp` (hand-off timing), `tests/test_drag_out.cpp` |
|
||||
|
||||
**Why these four are parallel.** T1 is capture core/shell; T2 is view core/shell plus one
|
||||
fenced block of `panel_input.cpp`; T3 is action registration; T4 is the drag chain. No
|
||||
two tracks own the same function anywhere.
|
||||
|
||||
**Two shared-file adjacencies, named rather than discovered at merge.**
|
||||
(a) `shell/panel/panel_input.cpp`: T2 owns the footer mode-segment block (`:300-309`)
|
||||
only; T4 may touch the drag-arm block (`:382-402`) only. Separate blocks — textual
|
||||
adjacency, not semantic contention; whichever lands second rebases.
|
||||
(b) **`src/app/main.cpp` is T3's exclusively.** T4 must not touch `main.cpp` — its whole
|
||||
redesign lives in the drag chain and registers nothing. T2 also does not touch it: the
|
||||
playback gate lives inside `applyMode` conditioned on the real-switch discriminator, so
|
||||
`main.cpp:173`'s project-load reapply passes through unchanged.
|
||||
**Depends on:** nothing in this phase.
|
||||
|
||||
#### Ψ-W1-T1 — `capture-range-exactness`
|
||||
|
||||
**Goal.** A capture over a time selection or razor area on a source item substantially
|
||||
longer than the selection produces exactly the requested range — both scopes, no
|
||||
whole-item widening. (Ψ.7)
|
||||
|
||||
**What is already correct, so the fix does not wander.** Range resolution is correct end
|
||||
to end: `scope_resolve.cpp:100-112` (`resolveRange`) → razor union
|
||||
(`render_settings.cpp:135-170`) or `GetSet_LoopTimeRange` (`scope_resolve.cpp:35-40`),
|
||||
passed verbatim (`capture_orchestrator.cpp:221-222`), landed as
|
||||
`RENDER_BOUNDSFLAG=0` + exact `RENDER_STARTPOS`/`RENDER_ENDPOS`
|
||||
(`capture.cpp:320-322`). There is no item-bounds fallback in `RunCapture`. **The widening
|
||||
decision point is the source-mode bit**: item scope maps to
|
||||
`kRenderSelItems (&32) | kRenderSingleFile` (`render_settings.cpp:82-86`), and the repo's
|
||||
own comments treat `&32` as item-extent-driven (`capture_batch.cpp:64-66`, `:98-99` —
|
||||
the entire reason batch transiently selects one item per render). The working inference —
|
||||
**unverified without a DAW** — is that REAPER's "selected media items" render source
|
||||
overrides the custom time bounds. Track scope uses `&128` (selected tracks via master), a
|
||||
normal time-bounded render, and **no code path was found that widens it**.
|
||||
|
||||
**Behavior.**
|
||||
- **The track opens with a DAW repro matrix, before any code change** `[verify]`:
|
||||
item scope × {time selection, razor} and track scope × {time selection, razor}, over a
|
||||
source item substantially longer than the selection, **tail mode None**. This confirms
|
||||
the `&32`-overrides-bounds inference, and disambiguates Ψ.7's "item/track both" claim —
|
||||
the candidates are: the reporter saw only the item scope; or a non-`None` panel tail
|
||||
mode was active (`capture_orchestrator.cpp:217`; Auto adds an 8 s window,
|
||||
`render_settings.cpp:28-37`). If track scope reproduces with tail None, the analysis
|
||||
above is wrong and the track says so before proceeding.
|
||||
- **The fix architecture is a design call at implementation review** `[propose]`, with
|
||||
the two candidates named now:
|
||||
- **(a) Re-source the ranged item capture.** When item scope carries an explicit range,
|
||||
render time-bounded (e.g. selected-track source `&128` over the item's own track,
|
||||
with the item-scope FX-bypass plan unchanged — it already neutralizes the track's own
|
||||
track-FX chain and everything above). Semantic edge to resolve: another item on the
|
||||
same track overlapping the range would now be audible in the capture, where `&32`
|
||||
excluded it.
|
||||
- **(b) Render-then-trim.** Keep `&32` (only the selected item's audio, REAPER's own
|
||||
semantics) and trim the rendered file to the requested range afterward with the pure
|
||||
`wav_codec` tools, sample-exactly (offset = requested start − rendered start at the
|
||||
file's sample rate). Precedent: `trimAutoTailInPlace`
|
||||
(`capture_realtime_finalize.cpp:55-125`). Cost: renders more than needed; requires
|
||||
knowing the rendered file's start time exactly.
|
||||
Whichever candidate lands, the choice is judged against the acceptance criteria below,
|
||||
not against convenience.
|
||||
- **Ripple surfaces if the item-scope source mode changes**, enumerated so none is
|
||||
discovered late: `capture_batch.cpp:98-99`'s select-one mechanism, item-scope FX
|
||||
fingerprinting reading `CountSelectedMediaItems` (`scope_resolve.cpp:199-207`), and
|
||||
`core/model/provenance` recipes storing `sourceMode` as an int
|
||||
(`scope_resolve.cpp:186`) — a recorded recipe must replay to the same audio.
|
||||
- **Batch semantics are deliberately different and stay so:** `RunBatchCaptureItems` is
|
||||
one sample per item at item extent (`capture_batch.cpp:177-179`) — that is its meaning,
|
||||
not this defect. Stated so nobody "fixes" it.
|
||||
|
||||
**Acceptance criteria.**
|
||||
- **The bounds equality, stated as a number:** the captured file's frame count equals the
|
||||
requested range's duration at the project sample rate — exactly, no rounding — for
|
||||
BOTH scopes, with both a time selection and a razor area, over a source item
|
||||
substantially longer than the selection, tail None. `[verify — DAW]`
|
||||
- The null test holds for a ranged item capture: re-inserted at the range start, it nulls
|
||||
against the source over the range. `[verify — DAW]`
|
||||
- Bit-identical repeats hold for the ranged capture.
|
||||
- Full-item captures (no time selection/razor, or bounds equal to the item) are
|
||||
byte-identical to today's output.
|
||||
- Recapture-from-source of a ranged capture reproduces it (the provenance recipe stays
|
||||
truthful under the chosen fix).
|
||||
- `tests/test_render_settings.cpp` pins the chosen mapping (today `:44-47` asserts only
|
||||
that the `&32` bit is chosen — never its bounds interaction); any new pure trim/window
|
||||
arithmetic lands in `core/capture` with unit tests.
|
||||
- **DAW-verification obligation:** Daniel observes the four-cell matrix above, plus one
|
||||
razor-union case (two disjoint razor areas — the union rule at
|
||||
`render_settings.cpp:135-170` is bounds-driven and must not regress).
|
||||
- Root `CLAUDE.md:208`'s **"Exact bounds"** invariant needs no amendment — this track
|
||||
makes the code honor it; the invariant was always the spec.
|
||||
|
||||
**Open questions.** `[verify]` the override inference and the track-scope repro (the
|
||||
matrix above). `[propose]` fix candidate (a) vs (b), including (a)'s overlapping-item
|
||||
semantic edge and (b)'s rendered-start-time derivation.
|
||||
**Landed** — see `docs/COMPLETED.md` for the full narrative. A ranged item capture now
|
||||
renders exactly the requested window instead of the whole item, by re-sourcing through
|
||||
the selected-tracks render when the item extent does not already print the window.
|
||||
**Deviation:** the spec named two candidate architectures (re-source vs. render-then-
|
||||
trim); the engineer shipped a conditional form of the re-source candidate — the
|
||||
full-extent case runs literally unchanged code, keeping the byte-identity regression
|
||||
floor structural and the fix cheap to revert if the override inference proves wrong.
|
||||
Also landed: a transient isolation guard (cutting `B_MAINSEND` on direct folder
|
||||
children, muting receives) so an item capture stays true to item scope, and a
|
||||
post-render frame-count gate (±1 tolerance, tail-None only) that refuses and self-cleans
|
||||
a widened render. New modules `core/capture/render_window`, `core/capture/track_topology`,
|
||||
`shell/capture/render_selection`, `shell/capture/render_isolation`. The whole fix rests
|
||||
on the unverified inference that REAPER's selected-tracks render source overrides
|
||||
custom time bounds — Ψ-W3-T1 (below) now also depends on it. **DAW-verification
|
||||
obligation, unmet:** the four-cell scope × selection-type matrix over a source item
|
||||
substantially longer than the selection (tail None), plus one razor-union case — none of
|
||||
it run in a live REAPER session yet.
|
||||
|
||||
#### Ψ-W1-T2 — `mode-switch-discipline`
|
||||
|
||||
**Goal.** Switching the active mode caches and clears the outgoing mode's solo state and
|
||||
restores the incoming mode's — disjoint solo surfaces per mode — and the switch itself is
|
||||
refused, visibly, while the transport is playing or recording. (Ψ.2 + Ψ.3)
|
||||
|
||||
**The invariant amendment comes first, because without it this track is a breach.** The
|
||||
never-touch-solo rule is stated three times: `src/shell/view/CLAUDE.md:14-17` ("Never
|
||||
touches master or `B_MUTE`/`I_SOLO`… User mute/solo survives every toggle untouched"),
|
||||
`src/core/view/CLAUDE.md:10`, and `docs/product/design-view.md:160-165` (framed as the
|
||||
analog of capture's non-destructive invariant) + `:588-592`. **The amended form:** the
|
||||
tool never *loses* the user's solo state — solo is cached per mode on a real switch and
|
||||
restored verbatim on return, the snapshot sense of non-destructive, exactly as the
|
||||
park/restore snapshots already treat visibility and FX state. `B_MUTE` stays untouched
|
||||
absolutely; the master track stays untouched absolutely (and survives for free —
|
||||
`handleByGuid` is built from `GetTrack`'s index space, which excludes master,
|
||||
`view.cpp:90-91`). All three files are amended by this track.
|
||||
|
||||
**Surface boundary — owns:** `core/view/view_mode_model` (the solo cache: storage,
|
||||
serialize/deserialize, reconcile participation — mirroring the existing `snapshots_` map,
|
||||
`view_mode_model.h:284-358`), `shell/view/view.cpp` (the `applyMode` seams and its
|
||||
`REAPERAPI_WANT` block, `:21-41`), `shell/actions/design_view_actions.cpp` (refusal
|
||||
feedback on the action path), `shell/panel/panel_input.cpp`'s footer mode-segment block
|
||||
ONLY (`:300-309`), the segment's disabled state in `core/ui/footer_bar` (pure predicate,
|
||||
transport bool passed IN from the shell) and its paint in `shell/panel/panel_render.cpp`
|
||||
(`:110-125` region), the three invariant-amendment files, `tests/test_view_mode_model.cpp`.
|
||||
**Must not touch:** `panel_drag.cpp` (T4's), `src/app/main.cpp` (the gate's discriminator
|
||||
makes the project-load reapply pass through unchanged), `B_MUTE`, master, and the
|
||||
park/restore planner's existing semantics.
|
||||
|
||||
**Behavior.**
|
||||
- **The discriminator is the hinge for both halves.** `applyMode` is also the reapply
|
||||
path — called with `targetModeId == activeModeId()` from `reapplyActiveMode`
|
||||
(`design_view_actions.cpp:106-108`, reached from tag/untag/show-both at
|
||||
`:187,197,206`) and from project load (`main.cpp:173`). A real switch is
|
||||
`targetModeId != model.activeModeId()`. **Only a real switch caches/clears/restores
|
||||
solo, and only a real switch is playback-gated** — reapply during playback (tagging,
|
||||
project load) keeps working, and there is no cache/clear/restore flicker on reapply.
|
||||
- **Solo cache semantics.** On a real switch: read `I_SOLO` for every track in the live
|
||||
enumeration (`handleByGuid`, keyed by GUID — all non-master tracks, not just managed
|
||||
leaves: a mode's solo surface is the whole project as seen in that mode); store the
|
||||
non-zero values into the model keyed by the OUTGOING mode id (`model.activeModeId()`
|
||||
is still the outgoing mode at the capture seam); write `I_SOLO = 0` on every track that
|
||||
had it set; after `setActiveMode` (`view.cpp:455`), restore the INCOMING mode's cached
|
||||
entries verbatim and consume them (clear-on-restore, mirroring
|
||||
`storeSnapshot`/`clearSnapshot`). **Values are cached and restored verbatim as the raw
|
||||
`I_SOLO` int — never collapsed to a boolean** (solo-in-place and safe-solo variants
|
||||
survive the round trip).
|
||||
- **Seams in `applyMode`:** capture after `handleByGuid` is populated (`:386`) and before
|
||||
`Undo_BeginBlock2` (`:399`); the clear and restore writes both ride the existing single
|
||||
undo block (clear at its head, restore after `setActiveMode` `:455` and before
|
||||
`TrackList_AdjustWindows` `:460`) — one mode toggle stays one Ctrl-Z.
|
||||
- **Storage is pure, shell touches only the API pair.** The cache lives in
|
||||
`ViewModeModel` beside `snapshots_` with the same `deserialize(serialize(x)) == x`
|
||||
contract (`view_mode_model.h:347-350`); only the
|
||||
`GetMediaTrackInfo_Value`/`SetMediaTrackInfo_Value` pair lives in `view.cpp`, exactly
|
||||
as `snapshotTrack` (`:121-134`) does. `core/` includes no REAPER headers.
|
||||
- **Reconcile participation — decided: the solo cache prunes on dead GUIDs**, like
|
||||
snapshots and unlike membership (`view.cpp:389-395`). The hazard it removes: a stale
|
||||
entry restoring onto a reused GUID. A pruned entry is simply lost solo state for a
|
||||
track that no longer exists — correct.
|
||||
- **Persistence rides the one blob.** `ViewModeModel::serialize()` →
|
||||
`SetProjExtState(proj, "reasampler", "view_state")` (`ext_state_io.cpp:156-158`, key
|
||||
`ext_keys.h:31`). `parseModel` skips unknown keys (`view_mode_model.cpp:603`), so the
|
||||
new key is backward-safe: an older build reading newer state drops the solo cache
|
||||
silently and nothing else breaks. Stated, accepted.
|
||||
- **The playback gate.** Inside `applyMode`, when the discriminator says real switch and
|
||||
`GetPlayStateEx(proj) & (1|4)` (bit 1 playing, bit 4 recording — the precedent is
|
||||
`capture_realtime_shell.cpp:284`, project-scoped `*Ex` variants deliberately,
|
||||
comment `:192`), refuse before touching anything: return `false`, no partial apply
|
||||
(the same fail-closed shape as the mode-exists guard at `:381`). Add
|
||||
`REAPERAPI_WANT_GetPlayStateEx` to `view.cpp`'s WANT block.
|
||||
- **The refusal is visible, not a silent no-op.** The footer `[Arrange|Design]` segment
|
||||
renders a disabled state while the transport runs, following the one existing disabled
|
||||
precedent end to end: pure predicate (`mode_enable`-style, transport bool passed IN so
|
||||
`core/ui` stays REAPER-free) → layout/paint state → no-op click. The action path's
|
||||
refusal (`doToggleMode`/`doActivateMode`) reads `applyMode`'s `false` and skips
|
||||
persist/invalidate.
|
||||
- **The panel's direct-call divergence is closed in this track — decided.** The footer
|
||||
segment click calls `applyMode` directly (`panel_input.cpp:300-309`, passing `nullptr`
|
||||
project), skipping the `persistViewState()` + `bankPanelInvalidate()` the action path
|
||||
does (`design_view_actions.cpp:165-180`) — a pre-existing defect: a panel-initiated
|
||||
switch does not persist. The segment click now routes through `Main_OnCommand` of the
|
||||
activate actions, exactly as the bottom-bar tag buttons already do
|
||||
(`panel_input.cpp:74-75`). One path, one gate, one persist.
|
||||
|
||||
**Acceptance criteria.**
|
||||
- **The disjoint-surface matrix, DAW-observed:** solo two tracks in Arrange; switch to
|
||||
Design → no track is soloed; solo a third track in Design; switch back → exactly the
|
||||
two Arrange solos are restored (raw `I_SOLO` values verbatim, including solo-in-place)
|
||||
and the Design solo is gone; switch forward again → the Design solo is restored.
|
||||
- Reapply paths (tag/untag/show-both, project load) neither clear nor flash solo state —
|
||||
before or after this track, during playback or stopped.
|
||||
- With the transport playing or recording: the switch is refused on EVERY path (action,
|
||||
panel segment), the segment visibly renders disabled, and project state is untouched by
|
||||
the refused attempt. Stopped → the switch works. Recording gates identically to
|
||||
playing.
|
||||
- A panel-initiated switch now persists (the divergence closure): switch via the footer
|
||||
segment, save, reload → the project reopens in the switched mode.
|
||||
- Solo cache round-trips save/reload: save mid-disjunction, reload → the inactive mode's
|
||||
cached solos restore on the next switch.
|
||||
- One mode toggle remains one Ctrl-Z; `B_MUTE` is never written; master is never touched.
|
||||
- The three invariant-amendment files land amended, in this track.
|
||||
- Pure model changes covered in `tests/test_view_mode_model.cpp` (store/consume,
|
||||
serialize round-trip, reconcile pruning); the enable predicate is pure and tested.
|
||||
- **DAW-verification obligation:** Daniel runs the solo matrix, the playback-gated
|
||||
refusal (playing AND recording), and the panel-persist case.
|
||||
|
||||
**Open questions.** `[verify]` `I_SOLO` is settable via `SetMediaTrackInfo_Value` and its
|
||||
value domain — against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h`, per root
|
||||
`CLAUDE.md`'s API-verification rule (settability is inference today). `[verify]` adding
|
||||
`REAPERAPI_WANT_GetPlayStateEx` to `view.cpp` alone compiles (`main.cpp:15` does not
|
||||
define `REAPERAPI_MINIMAL`, so it should — inference, one compile answers it).
|
||||
`[propose]` the repaint trigger for the disabled segment (the panel must notice transport
|
||||
transitions; lean: poll play state in the panel's existing timer tick and invalidate on
|
||||
change). `[propose]` restore-onto-parked-track edge: a track soloed in mode B, then
|
||||
re-tagged so it is parked when B next activates, would on verbatim restore solo a
|
||||
silent track and mute the mix — lean: restore skips tracks parked in the incoming mode
|
||||
and drops those entries, with the alternative (retain until the track re-enters) named
|
||||
and rejected as zombie state.
|
||||
**Landed** — see `docs/COMPLETED.md` for the full narrative. Per-mode SOLO surfaces now
|
||||
cache, clear, and restore across a Design/Arrange switch, with the switch itself
|
||||
refused, visibly, while the transport is playing or recording. The never-touch-solo
|
||||
invariant — stated three times (`src/shell/view/CLAUDE.md`, `src/core/view/CLAUDE.md`,
|
||||
`docs/product/design-view.md`) — is amended, in this track, to the snapshot sense of
|
||||
non-destructive: solo is cached per mode on a real switch and restored verbatim, not
|
||||
left untouched absolutely the way `B_MUTE` and the master track are. Also closed: a
|
||||
pre-existing bug where a footer mode-segment click never persisted view state. New
|
||||
`core/view/solo_cache`, `shell/view/view_solo`. **DAW-verification obligation, unmet:**
|
||||
the disjoint-surface solo matrix, the playback-gated refusal (playing and recording),
|
||||
and the panel-persist case — none run in a live REAPER session yet.
|
||||
|
||||
#### Ψ-W1-T3 — `media-explorer-section`
|
||||
|
||||
**Goal.** The Media-Explorer import action appears in the Media Explorer action section,
|
||||
so it can be bound and placed on the Media Explorer toolbar. (Ψ.4)
|
||||
|
||||
**Why it lands in Main today (SDK-verified):** the action registers via `"gaccel"`
|
||||
(`ingest.cpp:457-468`), and `gaccel_register_t` registers into the main keyboard section
|
||||
only — the struct has no section field (`vendor/reaper-sdk/sdk/reaper_plugin.h:1106-1117`).
|
||||
**The supported mechanism (SDK-verified):** `custom_action_register_t`
|
||||
(`reaper_plugin.h:1090-1103`) — `{uniqueSectionId, idStr, name, extra}`, with **Media
|
||||
Explorer = section 32063** (`:1099`, corroborated `reaper_plugin_functions.h:3467`);
|
||||
`Register("custom_action", &ca)` returns the command id or 0; `idStr` must be unique
|
||||
**across all sections** (`:1100`). Dispatch for non-main sections MUST come from
|
||||
`"hookcommand2"` (`reaper_plugin.h:212-221`) — `"hookcommand"` runs only for the main
|
||||
section (`:200-205`), and `main.cpp:309` registers only `"hookcommand"` today.
|
||||
`custom_action_register_t` carries no `ACCEL`, so the non-Main entry ships no default
|
||||
keybinding — acceptable; toolbar placement is the ask.
|
||||
|
||||
**Settled, not a fork: the action registers in BOTH Main and Media Explorer.** Ψ.4 asks
|
||||
for reach, not relocation, and a bare move would orphan any existing Main-section
|
||||
keybinding — a half-fix. Because `idStr` is unique across all sections, the Media
|
||||
Explorer registration **mints a second FOREVER-STABLE command-id suffix:**
|
||||
**`INGEST_IMPORT_MEDIA_EXPLORER_MX`**, composed per channel via `channelCommandId` like
|
||||
every other id (`core/version/app_version.cpp:43-47,84-86`) — a new permanent entry in
|
||||
each channel's id family, never to change after shipping. The existing
|
||||
`INGEST_IMPORT_MEDIA_EXPLORER` string is preserved unchanged for the Main entry, so root
|
||||
`CLAUDE.md:180-183`'s never-change contract is honored literally. Both registrations
|
||||
share the one handler (`doImportFromMediaExplorer`, `ingest.cpp:317-395`).
|
||||
|
||||
**Surface boundary — owns:** `shell/actions/ingest.cpp` (the second registration, the
|
||||
`hookcommand2` claim, the `-custom_action` unload mirror beside the existing mirrors at
|
||||
`:476-481`), **the registration block in `src/app/main.cpp`** (registering the
|
||||
`"hookcommand2"` hook beside `"hookcommand"` at `:309`, and its unload mirror at the
|
||||
`rec == nullptr` path), and the root-`CLAUDE.md` §"REAPER extension contract" amendment
|
||||
(the second registration mechanism, documented beside the 4-step main-section pattern).
|
||||
**Must not touch:** any other action family's registration, `panel_*`, `capture*`. T4
|
||||
must not touch `main.cpp`; this track is why.
|
||||
|
||||
**Behavior.**
|
||||
- Main-section registration byte-identical to today: same id string, same gaccel, same
|
||||
keybindings surviving.
|
||||
- Second registration: `custom_action_register_t{32063, channelCommandId(new suffix),
|
||||
channelActionName(same display phrase — decided: identical phrase in both sections,
|
||||
for findability), nullptr}` via `Register("custom_action", ...)`. A 0 return is
|
||||
tolerated gracefully (the Main entry still works; a console note, not a failure).
|
||||
- `hookcommand2` claims ONLY the new MX command id and returns `false` otherwise —
|
||||
`hookcommand2` fires for every section including Main, and the Main id stays claimed by
|
||||
the existing `hookcommand` path (`ingest.cpp:470-474` via `main.cpp:222-230`); the two
|
||||
ids differ, so no double dispatch is possible. Stated as a criterion, not an accident.
|
||||
- Unload mirrors everything: `-custom_action` with the same struct/pointer, alongside the
|
||||
existing `-gaccel`/`-command_id`.
|
||||
- Beta channel forks both ids automatically through `channelCommandId` — each channel
|
||||
gains exactly one new permanent id.
|
||||
|
||||
**Acceptance criteria.**
|
||||
- The Actions list, section "Media Explorer", shows the channel-prefixed action; it can
|
||||
be added to the Media Explorer toolbar; firing it from that toolbar imports the last
|
||||
played Media Explorer file into a ReaSampler on the selected track — identical behavior
|
||||
to the Main-section entry. `[verify — DAW]`
|
||||
- The Main-section entry is unchanged: same command id, existing keybindings intact.
|
||||
- Extension unload unregisters both entries cleanly; reload re-registers both.
|
||||
- The new suffix `INGEST_IMPORT_MEDIA_EXPLORER_MX` is recorded as FOREVER-STABLE, per
|
||||
channel, in the same breath as the registration lands.
|
||||
- Root `CLAUDE.md`'s "REAPER extension contract" section gains the non-main mechanism
|
||||
(`custom_action` / `hookcommand2` / `-custom_action` mirror) — a deliverable of this
|
||||
track.
|
||||
- **DAW-verification obligation:** Daniel adds the action to the Media Explorer toolbar
|
||||
and imports from it; also confirms the Main-section binding still fires.
|
||||
|
||||
**Open questions.** `[verify]` re-verify `custom_action_register_t` and `hookcommand2`
|
||||
argument order/types against the SDK headers at implementation time (root `CLAUDE.md`
|
||||
rule; the section ids and struct shapes above are already header-verified, the rule
|
||||
applies regardless). None classified `[propose]` — the ruling above is settled.
|
||||
**Landed** — see `docs/COMPLETED.md` for the full narrative. The Media Explorer import
|
||||
action now registers into REAPER's Media Explorer action section (32063) via
|
||||
`custom_action` + `hookcommand2`, alongside its existing Main-section entry so existing
|
||||
keybindings survive — a second FOREVER-STABLE id, `INGEST_IMPORT_MEDIA_EXPLORER_MX`,
|
||||
minted per channel. Root `CLAUDE.md`'s "REAPER extension contract" is amended, in this
|
||||
track, with the second, non-main registration mechanism beside the original four-step
|
||||
main-section pattern. **DAW-verification obligation, unmet:** adding the action to the
|
||||
Media Explorer toolbar and firing it from there; confirming the Main-section binding
|
||||
still fires; and confirming unload/reload does not leak a duplicate Media Explorer entry
|
||||
(the `-custom_action` unload mirror is unconfirmed against the SDK header) — none run in
|
||||
a live REAPER session yet.
|
||||
|
||||
#### Ψ-W1-T4 — `drop-target-resolution`
|
||||
|
||||
**Goal.** Dragging cards out of the panel resolves its target from what is actually under
|
||||
the cursor — continuously, reversibly, with a defined outcome and a visible cue for every
|
||||
surface, and no silent no-op release anywhere. (Ψ.5)
|
||||
|
||||
**The root causes, so the redesign is judged against them.** The current chain locks a
|
||||
drag's fate at its first processed move outside the client rect
|
||||
(`core/ui/drag_out.cpp:18-26`): a single-card drag over ANY REAPER surface —
|
||||
`GetThingFromPoint` returns non-empty info for the arrange too
|
||||
(`instrument_drop_win.cpp:84-86`) — locks to `InstrumentDrop` and returns before the OS
|
||||
hand-off is ever evaluated (`panel_drag.cpp:285-294`); over the arrange the info string
|
||||
fails the FX-hotspot rule, so release falls to a **silent no-op** (root cause A — the
|
||||
verified headline: single-card arrange drops do nothing, while multi-card drags skip the
|
||||
FX resolve (`:276`) and work via OS drag, which is exactly Ψ.5's "sometimes"). The
|
||||
`OsDrag` branch is irreversible — `ReleaseCapture` + `resetDragState` + modal
|
||||
`DoDragDrop` (`:299-335`) — and `WM_MOUSEMOVE` coalescing makes the deciding exit pixel
|
||||
vary with drag speed (root cause B). The FX "hotspot" is only the FX-button glyph family
|
||||
(`fx_*`/`tcp.fx*`/`mcp.fx*`, `core/wire/instrument_drop.cpp:90-95`) — TCP body/name, or
|
||||
a TCP too narrow to draw the button, yields `"tcp"` and a cue-less no-op (root cause C).
|
||||
Multi-card drags can never instrument-drop (root cause D). One unresolvable hand-off
|
||||
evaluation sets `dragOsHandoffBlocked` for the remainder of the drag (`:320`, cleared
|
||||
only in `resetDragState` — root cause E). Coordinate spaces and DPI are NOT causes
|
||||
(verified); bank-to-bank never leaves the client and is CTest-covered
|
||||
(`tests/test_drag_out.cpp:33-112`) — consistent with "dragging between banks seems
|
||||
fine", and it must stay byte-identical.
|
||||
|
||||
**The redesign is a gesture law, not a patch.** The law: **the target class is resolved
|
||||
from what is under the cursor on every move, every class transition is reversible until
|
||||
release or until the pointer leaves REAPER entirely, and the OS hand-off is reserved for
|
||||
leaving REAPER** — every REAPER-internal target is executed natively on release. The
|
||||
minimal patch (re-evaluating the hotspot per move but keeping the early irreversible
|
||||
`OsDrag` fork) is named and rejected: it re-creates root cause B — a drag that crosses
|
||||
the arrange en route to an FX button would still lose the instrument drop forever.
|
||||
|
||||
**The class enumeration and its defined outcomes (single-card / multi-card):**
|
||||
|
||||
| Under the cursor | Single card | Multi card |
|
||||
|---|---|---|
|
||||
| Inside the panel client | Internal drag (unchanged, byte-identical) | Internal drag (unchanged) |
|
||||
| FX hotspot (`fx_*` window, or track panel — see below) | **Instrument drop** (existing `.vstpreset` path) | **Refuse-with-cue** (no instrument drop for a multi payload — root cause D becomes a defined, cued outcome) |
|
||||
| TCP/MCP, whole panel (not just the FX glyph; `*.fxembed` still excluded) | **Instrument drop — decided:** the whole track panel is the hotspot. Root cause C IS the FX-glyph-only rule; a capture dropped on a track's panel means "sampler on this track." The rejected alternative — TCP drop = item at edit cursor — is silent-placement-adjacent and has no time coordinate; the arrange owns placement. | Refuse-with-cue |
|
||||
| Arrange | **Timeline item at the pointer's track/time** — native insertion on release | Native insertion, shape at review (see open questions) |
|
||||
| Elsewhere in REAPER (ruler, transport, docker chrome) | Refuse-with-cue | Refuse-with-cue |
|
||||
| Outside REAPER entirely | OS drag-out (`DoDragDrop`, CF_HDROP, copy-only — unchanged mechanics, new trigger condition) | OS drag-out |
|
||||
|
||||
- **Every release either performs the resolved action or visibly refuses** — the cue
|
||||
(cursor via the existing `applyDragCursor` precedent, plus the panel's drag paint
|
||||
state) tracks the resolved class continuously, so "will this work" is visible before
|
||||
release, and no cell in the matrix is a silent nothing.
|
||||
- **The blocked flag is retired as a drag-lifetime latch** (root cause E): an
|
||||
unresolvable evaluation refuses that evaluation, not the rest of the drag.
|
||||
- **The arrange outcome does not violate the load-bearing principle, and the spec says so
|
||||
where a reviewer will look:** the item is placed because the USER dragged it to that
|
||||
spot — user-initiated placement, the same class of act as `RunInsertSelected`, not a
|
||||
capture auto-insert. Root `CLAUDE.md`'s "capture and placement are separate acts" is
|
||||
about capture never placing; a deliberate drop is placement on demand.
|
||||
- **The pure decision stays pure.** The class enumeration, its precedence, and the
|
||||
single/multi split land in `core/ui/drag_out` (the law) +
|
||||
`core/wire/instrument_drop` (the info-string classification), CTest-covered over the
|
||||
full matrix; the shell reads live pointer/window state and executes outcomes, exactly
|
||||
the discipline `card_drag` already follows.
|
||||
- **Hot-path discipline:** the inside-client path stays free of SDK hit-tests
|
||||
(`GetThingFromPoint` is evaluated only outside the client rect, as today —
|
||||
`panel_drag.cpp:273-274`); per-move resolution outside the client now runs for multi
|
||||
payloads too, which is new but still per-mouse-move cold.
|
||||
|
||||
**Surface boundary — owns:** `core/ui/drag_out` (the gesture law, rewritten),
|
||||
`core/wire/instrument_drop` (classification), `shell/panel/panel_drag.cpp` (routing +
|
||||
release dispatch), `shell/actions/instrument_drop_win.cpp` (hit-test shell),
|
||||
`shell/actions/drag_out_win.cpp` (hand-off trigger timing), `tests/test_drag_out.cpp`.
|
||||
May touch `panel_input.cpp`'s drag-arm block (`:382-402`) only — T2's footer block is
|
||||
fenced. **Must not touch:** `src/app/main.cpp` (T3's — this track registers nothing),
|
||||
`core/ui/card_drag` and the in-grid reorder/replace semantics (bank-to-bank stays
|
||||
byte-identical), the capture pillar, the insert action's own semantics.
|
||||
|
||||
**Acceptance criteria.**
|
||||
- The full matrix above, DAW-observed, single AND multi: every cell produces its defined
|
||||
outcome with its cue, and in particular — a single-card drop onto the arrange lands an
|
||||
item at the pointer's track and time (the headline defect); a single-card drop onto a
|
||||
track panel (not just the FX glyph) loads the instrument; a multi-card drop onto the
|
||||
arrange still lands items; every refuse cell visibly refuses.
|
||||
- **Reversibility:** leave the client, cross the arrange, reach an FX window →
|
||||
instrument drop still available; return into the panel client → internal drag resumes.
|
||||
One unresolvable evaluation does not change any later evaluation's outcome.
|
||||
- **Drag speed does not change outcomes:** fast flicks and slow drags to the same release
|
||||
point resolve identically (the coalescing hazard is designed out with the early lock,
|
||||
not mitigated).
|
||||
- Bank-to-bank drags are byte-identical to today; `tests/test_drag_out.cpp:33-112` (or
|
||||
their successors) stay green with unchanged semantics.
|
||||
- The pure law's CTest matrix covers every class × single/multi combination, including
|
||||
the `*.fxembed` exclusions and the null-track/non-empty-info cases the SDK documents
|
||||
for `GetThingFromPoint` (`reaper_plugin_functions.h:3440-3446`).
|
||||
- No new SDK hit-test inside the client rect.
|
||||
- **DAW-verification obligation:** Daniel runs the matrix, explicitly including the
|
||||
narrow-TCP case (root cause C's trigger), fast-flick drags, and a drag that crosses the
|
||||
arrange before reaching an FX window.
|
||||
|
||||
**Open questions.** `[verify]` pointer→(track, time) for the native arrange insertion —
|
||||
candidate API `GetSet_ArrangeView2` (pixel-column → time mapping) plus
|
||||
`GetThingFromPoint`'s track; verify signatures and behavior against the SDK header
|
||||
before building on them; if no exact pixel→time mapping exists, the arrange outcome's
|
||||
mechanism (not its existence) is re-proposed. `[verify]` what REAPER does with a CF_HDROP
|
||||
drop re-entering its windows during our `DoDragDrop` (the accepted residual once the
|
||||
pointer has left REAPER and returns mid-modal-loop — confirm it is REAPER's own file
|
||||
import, then state it). `[propose]` the multi-card arrange insertion shape (lean: one
|
||||
item per file mirroring REAPER's own multi-file drop convention; alternative: retain the
|
||||
OS hand-off for the multi-arrange cell only — costs a second delivery mechanism and a
|
||||
modal fork, named to be rejected on uniformity unless the native shape fails
|
||||
verification). `[propose]` the exact refuse-with-cue rendering (cursor-only vs cursor +
|
||||
panel status text).
|
||||
**Landed** — see `docs/COMPLETED.md` for the full narrative. The drag-out gesture is now
|
||||
a per-move, stateless law: target class resolves from what is under the cursor on every
|
||||
move, every class transition is reversible until release or until the pointer leaves
|
||||
REAPER, and the OS hand-off is reserved for leaving REAPER entirely — the whole TCP/MCP
|
||||
is now an instrument-drop hotspot, and a single-card arrange drop lands a timeline item
|
||||
at the pointer's track and time. New `shell/actions/arrange_drop_win`.
|
||||
**DAW-verification obligation, unmet:** the full target-class matrix (single- and
|
||||
multi-card), reversibility across a drag that crosses the arrange en route to an FX
|
||||
window, drag-speed independence, and the narrow-TCP case — none run in a live REAPER
|
||||
session yet.
|
||||
|
||||
---
|
||||
|
||||
@@ -2521,211 +2188,58 @@ render-configuration block in `shell/capture/capture.cpp` and
|
||||
TUs, so W2 dispatches only after W1-T1 is on `dev`. (T2–T4 of W1 gate nothing here; the
|
||||
wave boundary is the file collision, not a semantic dependency.)
|
||||
|
||||
**Two tracks, parallel, disjoint by field family:** T1 owns the naming fields
|
||||
(`baseName` / `uniqueTag` / `displayName` / `capture_paths`), T2 owns the channel fields
|
||||
(`channelCount` / PCM / `wav_codec`). **The known collision seam, named now:** both
|
||||
tracks touch `shell/capture/capture.cpp` and
|
||||
`shell/capture/capture_realtime_finalize.cpp` at adjacent lines. In `capture.cpp`, T1
|
||||
owns the tag/paths mint (`:310-314`) and the `Sample` id/label lines (`:398-402`); T2
|
||||
owns `RENDER_CHANNELS` (`:353-354`), the new post-render collapse step inserted between
|
||||
the exists-check (`:390-395`) and the `Sample` population (`:398`), and
|
||||
`stampCaptureSample`'s channel echo (`:207`). In `capture_realtime_finalize.cpp`, T1 owns
|
||||
the label site (`:195`); T2 owns the layout-parse/channel lines (`:188-198`) and the
|
||||
collapse insertion. Adjacent lines, disjoint fields — textual merge adjacency, not
|
||||
semantic contention; whichever lands second rebases.
|
||||
|
||||
#### Ψ-W2-T1 — `capture-naming`
|
||||
|
||||
**Goal.** Captures are labeled after their source track's name plus a discriminator, on
|
||||
every mint site — and the name is visible where the user looks. (Ψ.1)
|
||||
|
||||
**Today, and why it reads as broken:** every offline capture's `displayName` is the
|
||||
literal `"item"` or `"track"` (`CaptureActionDef` → `render_settings.cpp:175-183` →
|
||||
`capture.cpp:402`), the filename is `"item_<epoch>-<counter>.wav"`
|
||||
(`makeUniqueTag`, `capture.cpp:187-199`; `deriveBankPaths`,
|
||||
`core/capture/capture_paths.cpp:48-72`) — and **the source track name is read nowhere in
|
||||
the tree** (repo-wide: zero hits for `P_NAME`/`GetTrackName`; the capture path holds
|
||||
`MediaTrack*` and GUIDs but never a name). Worse, **the label barely surfaces:** the
|
||||
panel card draws no name at all (`panel_render.cpp:23-63` — waveform, bars.beats,
|
||||
seconds.ms only); the sample name appears only inside the VST3 editor. A good name
|
||||
nobody can see is a half-fix, so this track also surfaces it.
|
||||
|
||||
**Surface boundary — owns:** `shell/capture/scope_resolve` (the ONE place already
|
||||
walking source tracks before the FX-bypass guard — gains the name read, plumbed through
|
||||
`ResolvedSource`, `scope_resolve.h:33`), `shell/capture/capture.h` (`CaptureRequest`),
|
||||
`capture_orchestrator.cpp`'s naming lines (`:229`, `:405`), `capture.cpp`'s mint/label
|
||||
lines (`:310-314`, `:398-402`), `capture_batch.cpp`'s mint sites (`:220`, `:284`,
|
||||
`:395`), the realtime label chain (`capture_realtime_shell.cpp:309-310`,
|
||||
`capture_realtime_finalize.cpp:195`, `core/capture/capture_realtime.cpp:31-32`),
|
||||
`core/capture/render_settings` only for `CaptureActionDef::baseName` semantics
|
||||
(`render_settings.h:170-175` — the literals become fallbacks), and the card name
|
||||
surface: pure geometry in `core/ui` (`bank_grid` / `card_meta`) + its draw in
|
||||
`shell/panel/panel_render.cpp`. **Must not touch:** `channelCount`/PCM/`wav_codec`
|
||||
(T2's), the render-configuration block W1-T1 landed (read-only here),
|
||||
`core/capture/capture_paths`'s purity — it stays REAPER-free path arithmetic with **no
|
||||
filesystem access** (`core/capture/CLAUDE.md`); any collision handling stays
|
||||
`makeUniqueTag`'s, shell-side.
|
||||
|
||||
**Behavior.**
|
||||
- **The name is resolved shell-side in `scope_resolve`,** before the FX-bypass guard,
|
||||
alongside the GUID walk it already does (`scope_resolve.cpp:53-72`, `:114-127`):
|
||||
track scope → the selected track's name; item scope → the selected item's owning
|
||||
track's name. `[verify]` the read API (`GetSetMediaTrackInfo_String` with `"P_NAME"`
|
||||
vs `GetTrackName`) against the SDK header before use.
|
||||
- **Label composition:** `displayName = "<TrackName> <discriminator>"`. The
|
||||
discriminator's exact format is `[propose]` (lean: a compact date-time derived from the
|
||||
capture's own `createdTimestamp`, e.g. `Bass 08-01 1432` — Daniel's "date, etc."
|
||||
names it); `displayName` remains explicitly NOT unique, per the existing rule
|
||||
(`core/model/CLAUDE.md` §`resample_name`) — the discriminator serves legibility, not
|
||||
uniqueness.
|
||||
- **The filename becomes meaningful too:** `baseName` = the sanitized track name, so the
|
||||
stem is `"Bass_<tag>.wav"` via the existing `sanitizeStem` + `makeUniqueTag` pipeline —
|
||||
stem uniqueness remains entirely `makeUniqueTag`'s job, unchanged. A name the sanitizer
|
||||
reduces to nothing (non-ASCII) falls back to `"capture"` for the stem
|
||||
(`capture_paths.cpp:24-46`'s existing rule) while `displayName` keeps the real name.
|
||||
- **Fallback for an unnamed track:** deterministic, matching REAPER's own display
|
||||
convention (`"Track N"` by index at capture time) — never the old literals on the
|
||||
interactive paths.
|
||||
- **All five mint sites covered, each stated:** offline (above); **realtime** — same
|
||||
resolution at `begin` time (the action has a selected track); **batch** — per-unit
|
||||
owning-track name, the batch ordinal retained as an additional discriminator
|
||||
(`"Bass 1"`, `"Bass 2"`… exact composition settled with the discriminator format);
|
||||
**recapture** — decided: preserves the existing entry's `displayName` (it regenerates
|
||||
an entry, it does not mint a new identity; only the file stem re-mints);
|
||||
**ingest** — unchanged (already meaningful: the source file's stem,
|
||||
`ingest.cpp:249-266`); **bake** — unchanged (`model::nextIterationName`, the
|
||||
`Kick r2` precedent). **The bake's naming-and-lineage open question is Ξ's, jointly
|
||||
held with Ξ-W1-T1, and this track does not close or touch it.**
|
||||
- **The card shows the name — decided in-scope:** one truncated name line on the panel
|
||||
card, geometry pure (`bank_grid`/`card_meta`, CTest-covered), drawn by palette role
|
||||
through the kit. Truncation is display-only; the stored value is never shortened. If
|
||||
review finds the card too crowded at the smallest cell size, the fallback is
|
||||
name-in-tooltip — `[propose]` at review, with on-card as the lean.
|
||||
- **Existing bank entries are untouched** — new captures only; no retroactive rename.
|
||||
- `displayName` already flows to the instrument (component-state copy + one-way refresh,
|
||||
`component_state_io.cpp:74-88`, `sample_map.cpp:96`) — no instrument-side work.
|
||||
|
||||
**Acceptance criteria.**
|
||||
- An item capture from a track named `Bass` yields `displayName` `Bass <discriminator>`
|
||||
(format as settled at review) and a file `Bass_<tag>.wav`; a track capture likewise;
|
||||
the literals `"item"`/`"track"` never appear as labels on any interactive capture path
|
||||
again. `[verify — DAW]`
|
||||
- Realtime and batch captures follow the same scheme (batch retains per-unit ordinals);
|
||||
recapture preserves the entry's existing name; ingest and bake naming are unchanged.
|
||||
- An unnamed source track produces the deterministic `Track N` fallback.
|
||||
- The panel card shows the name, truncated to its cell, pure-geometry tested; the
|
||||
instrument's browse list shows the same name with no mechanism change.
|
||||
- File-stem uniqueness is still solely `makeUniqueTag`'s; `capture_paths` gains no
|
||||
filesystem access; existing entries' labels are byte-identical after the track lands.
|
||||
- **DAW-verification obligation:** Daniel captures from a named track, an unnamed track,
|
||||
and a multi-item selection; sees the labels on the card and in the instrument.
|
||||
|
||||
**Open questions.** `[verify]` the track-name read API against the SDK header.
|
||||
`[propose]` the discriminator format (lean above). `[propose]` multi-track item
|
||||
selections (items spanning several tracks in one capture — lean: first source track's
|
||||
name plus a `+N` marker; alternatives: joined names, or the scope literal as fallback).
|
||||
`[propose]` card-name fallback to tooltip only if the card proves too crowded.
|
||||
**Landed** — see `docs/COMPLETED.md` for the full narrative. Captures are now named
|
||||
after their source track's name plus a discriminator
|
||||
(`<Track> [+N] [#ordinal] MM-DD HHMM`) at every interactive mint site, with the name
|
||||
shown on the panel card over a scrim clearing the 4.5:1 contrast floor. New
|
||||
`core/capture/capture_name`. Recapture, ingest, and the bake deliberately keep their own
|
||||
naming — the bake's naming-and-lineage open question stays Ξ-W1-T1's/Ξ-W2-T1's to close,
|
||||
untouched here. **DAW-verification obligation, unmet:** capturing from a named track, an
|
||||
unnamed track, and a multi-item selection, and confirming the labels show on the card
|
||||
and in the instrument — none run in a live REAPER session yet.
|
||||
|
||||
#### Ψ-W2-T2 — `mono-collapse`
|
||||
|
||||
**Goal.** A new capture whose channels are bit-identical is collapsed losslessly to one
|
||||
channel — one channel of data on disk, a mono arrange item on insert, the instrument
|
||||
loading in Mono mode. (Ψ.6)
|
||||
**Landed** — see `docs/COMPLETED.md` for the full narrative. A capture whose channels
|
||||
are bit-identical now collapses losslessly to one mono channel, written via temp file
|
||||
plus atomic rename, with `Sample::channelCount` measured off the landed file rather than
|
||||
echoed from the request on every capture path — including realtime, which previously
|
||||
parsed the layout and echoed the request anyway. Root `CLAUDE.md:208`'s
|
||||
channel-count-preserved invariant is amended, in this track, to the landed wording:
|
||||
channel count preserved, except that bit-identical channels may collapse losslessly to
|
||||
mono; a lossy fold remains forbidden. The `shell/instrument/processor_reload.cpp` touch
|
||||
stayed to the minimum named in its surface boundary (Phase Γ was live in that
|
||||
directory). **Open, not closed here:** whether bake landings collapse too — `[propose]`,
|
||||
leaning yes, still deferred to be confirmed against what Ξ-W2-T1 actually shipped.
|
||||
**DAW-verification obligation, unmet:** capturing a dead-center mono source and a
|
||||
true-stereo source, inserting both, loading both into the instrument, and running the
|
||||
null test on the collapsed one (REAPER's mono-item-on-stereo-track summing at unity is
|
||||
the specific thing to confirm) — none run in a live REAPER session yet.
|
||||
|
||||
**The invariant amendment is part of this track.** Root `CLAUDE.md:208` — "channel count
|
||||
preserved (no silent stereo fold)" — is honored in spirit (the parenthetical forbids a
|
||||
LOSSY fold; this collapse is lossless by predicate) but contradicted in text. The
|
||||
amendment: channel count is preserved except that bit-identical channels may collapse
|
||||
losslessly to mono; a lossy fold remains forbidden. Noted in the amendment: the old text
|
||||
was already untrue in the other direction — a mono source renders at `RENDER_CHANNELS=2`
|
||||
today (`capture_orchestrator.cpp:227`, hardcoded and never measured).
|
||||
---
|
||||
|
||||
**Surface boundary — owns:** `core/capture/wav_codec` (the pure bit-identity predicate +
|
||||
collapse plan, with `wav_codec` unit tests), `shell/capture/capture.cpp` (the post-render
|
||||
collapse step between the exists-check `:390-395` and the `Sample` population `:398`,
|
||||
plus `stampCaptureSample`'s channel echo `:207` — derive from the FILE, the
|
||||
`bake_land.cpp:105,131,177` / `ingest.cpp:199,268` precedent),
|
||||
`shell/capture/capture_realtime_finalize.cpp` (the collapse on the realtime path, and
|
||||
the same echo fix — it parses the layout at `:188-198` and STILL echoes the request
|
||||
value), the root-`CLAUDE.md:208` amendment, and — **minimally, because Phase Γ is live in
|
||||
`shell/instrument/`** — `shell/instrument/processor_reload.cpp` only for the stale
|
||||
"always 2 for extension captures" comment (`:64-77` vs `:150-157`) and any index/file
|
||||
consistency fix that falls out; nothing else instrument-side. **Must not touch:** the
|
||||
naming fields (T1's), `capture_paths`, the panel, the instrument's channel-mode logic
|
||||
(`sample_map.cpp:58-62` already maps `channelCount == 1` → Mono, explicit user toggle
|
||||
winning — no change needed).
|
||||
### Ψ-W3 — Closing the track-scope stem-collapse hole
|
||||
|
||||
**Behavior.**
|
||||
- **The predicate is pure and bit-exact:** all channels bit-identical per frame (float
|
||||
bit patterns, never epsilon) → collapse to 1 channel. Generalized to N channels
|
||||
(all-identical → mono; no partial collapse, e.g. never 4→2) — N-channel captures are
|
||||
not reachable today (`channelCount` hardcoded 2), so the generalization is
|
||||
future-proofing the predicate, stated as such.
|
||||
- **The collapse mirrors the one existing rewrite precedent** — `trimAutoTailInPlace`
|
||||
(`capture_realtime_finalize.cpp:55-125`): `parseWavLayout` → `extractFloatFrames` →
|
||||
`buildFloat32Wav(1, …)` → truncating rewrite, all with the existing `wav_codec` tools
|
||||
(`wav_codec.h:48-89` — `buildFloat32Wav` already takes arbitrary `nch`). There is no
|
||||
PCM assembly on the offline path today (REAPER writes the file; the extension only
|
||||
stats and hashes it) — this step is the first, and it lives shell-side at the named
|
||||
insertion point with its plan pure.
|
||||
- **Scope — decided: every extension capture path** — offline, realtime, batch,
|
||||
recapture (batch and recapture route through the same backends). **Ingest is
|
||||
excluded** — imported files are the user's bytes, not our capture; rewriting them is a
|
||||
mutation this tool has no license for. **Unconditional, no user opt-out** — Ψ.6 asks
|
||||
for the behavior, not a preference; dual-mono stereo carries zero information the mono
|
||||
file lacks. **New captures only, never retroactive** — existing bank entries and files
|
||||
are untouched.
|
||||
- **The file and the index value are written together — the stated hazard.** A collapsed
|
||||
file with `Sample::channelCount` left at 2 still plays (the instrument's
|
||||
`extractChannel` clamps out-of-range to the last channel, `sample_map.cpp:174-186`,
|
||||
yielding dual-mono) but the mono/stereo toggle and the waveform lane count would read
|
||||
Stereo — so the criterion is equality with the file's `fmt` on every path, not absence
|
||||
of crashes.
|
||||
- **Insert needs no change, stated so nobody invents work:** `insert.cpp:129-152` passes
|
||||
only a path to `InsertMedia`; REAPER derives the item's channel count from the file —
|
||||
a 1-channel WAV yields a mono item for free.
|
||||
- **Behavior change, stated and accepted:** `hashWavContent` covers the `fmt ` body +
|
||||
`data` payload, so a collapsed capture will NOT hash-dedup against a pre-existing
|
||||
stereo twin of the same audio (`bank_model.h:143-148`). Accepted — the predicate is
|
||||
deterministic, so repeats of the same request still dedup against each other.
|
||||
- **Bit-identical repeats survive:** a deterministic predicate over deterministic bytes;
|
||||
identical requests still produce identical files (now identically-collapsed ones).
|
||||
**Depends on Ψ-W2 for:** existing at all — this wave did not exist when the phase was
|
||||
scoped. Daniel opened it after Ψ-W2's review surfaced that the track scope carried the
|
||||
same multi-track stem-collapse hole Ψ-W1-T1 had just closed for item scope.
|
||||
|
||||
**Acceptance criteria.**
|
||||
- A capture of dead-center mono content yields a 1-channel float32 WAV whose PCM is
|
||||
bit-identical to either source channel and whose frame count is unchanged — losslessness
|
||||
asserted by `extractFloatFrames` equality in the pure tests, observed in the DAW on the
|
||||
real path. `[verify — DAW]`
|
||||
- A capture with ANY differing sample pair is byte-identical to today's 2-channel output
|
||||
— the not-collapsed path is unchanged, the same discipline as "bypassed means
|
||||
byte-identical."
|
||||
- `Sample::channelCount` equals the produced file's `fmt` channel count on EVERY capture
|
||||
path — including realtime, whose finalize currently parses the layout and echoes the
|
||||
request anyway; that defect is fixed here.
|
||||
- Inserting a collapsed capture yields a mono arrange item; the instrument loads it in
|
||||
Mono channel mode with a single waveform lane, explicit user toggle still winning.
|
||||
`[verify — DAW]`
|
||||
- The null test holds for a collapsed capture re-inserted at its source position.
|
||||
`[verify — DAW: REAPER's mono-item-on-stereo-track summing at unity is the thing to
|
||||
confirm]`
|
||||
- Bit-identical repeats hold across the collapse; realtime and batch behave identically
|
||||
to offline; ingest is demonstrably untouched (an imported dual-mono file stays stereo).
|
||||
- The predicate + collapse plan are pure with `wav_codec` test coverage, including the
|
||||
N-channel all-identical case and the one-sample-differs case.
|
||||
- Root `CLAUDE.md:208` lands amended, in this track; the `processor_reload.cpp` touch is
|
||||
the comment + consistency fix only.
|
||||
- **DAW-verification obligation:** Daniel captures a dead-center source and a true-stereo
|
||||
source, inserts both, loads both into the instrument, and runs the null test on the
|
||||
collapsed one.
|
||||
**One track. Consolidates none of the seven** — it came from a review finding, not from
|
||||
Ψ.1–Ψ.7.
|
||||
|
||||
**Open questions.** `[propose]` whether bake landings collapse too (lean YES — the bake
|
||||
"writes a file plus an index entry, like every other capture", `bake_land` already
|
||||
derives channel count from the file, and a dead-center instrument render is exactly the
|
||||
dual-mono case; it is `[propose]` rather than decided only because `bake_land` is Ξ-W2's
|
||||
freshly-landed surface and the collapse there should be confirmed against what actually
|
||||
shipped). `[verify]` REAPER's mono-item summing for the null test (above). Decided, not
|
||||
open: unconditional; new-captures-only; ingest excluded; N-generalized predicate; no
|
||||
partial collapse.
|
||||
#### Ψ-W3-T1 — `track-scope-range`
|
||||
|
||||
**Landed** — see `docs/COMPLETED.md` for the full narrative. Any multi-track
|
||||
selected-tracks render now refuses, in both scopes, keyed on the render *source* rather
|
||||
than the capture scope — closing the hole Ψ-W1-T1 left open for track scope. Realtime
|
||||
deliberately diverges and was left untouched, because it sums correctly. The refusal
|
||||
rests on the same unverified inference Ψ-W1-T1 rests on — that REAPER's selected-tracks
|
||||
render source overrides custom time bounds — so if that inference is wrong, this refusal
|
||||
costs a working capture. `docs/verify-track-scope-multitrack.md` is a new standalone
|
||||
verification script on this branch, for this track's multi-track refusal specifically.
|
||||
**DAW-verification obligation, unmet:** confirmed nowhere in a live REAPER session yet.
|
||||
|
||||
---
|
||||
|
||||
@@ -2777,11 +2291,13 @@ proof it exists to give.
|
||||
deck-rework entry (whose original "one row of taller decks with within-deck stacking" shape
|
||||
Daniel explicitly superseded), and **Γ-W1-T1 discharges "Raise the stage-time ceiling above
|
||||
2 s"** (Γ-F3 reversed).
|
||||
- **All of Phase Ψ** (`ppsi-*`). **Six tracks across two waves**, from a direct list of
|
||||
seven defects and refinements (Daniel, 2026-08-01), not from `TODO-1.0.md`. Listed here
|
||||
as a block, like Γ; unlike Γ it has no backing product doc — the seven are recorded
|
||||
verbatim in the phase header as its provenance (Ψ.1–Ψ.7), and the design content lives
|
||||
inline in its tracks.
|
||||
- **All of Phase Ψ** (`ppsi-*`). **Seven tracks across three waves**, from a direct list
|
||||
of seven defects and refinements (Daniel, 2026-08-01), not from `TODO-1.0.md`. Listed
|
||||
here as a block, like Γ; unlike Γ it has no backing product doc — the seven are
|
||||
recorded verbatim in the phase header as its provenance (Ψ.1–Ψ.7), and the design
|
||||
content lives inline in its tracks. The seventh track, Ψ-W3-T1, is not one of the
|
||||
seven defects/refinements itself — it came from a review finding mid-phase; see the
|
||||
Phase Ψ section for detail.
|
||||
- **Γ-W3-T2 `bake-reset-amendment` is a CORRECTION, not a feature**, and belongs on this list
|
||||
for a different reason from the others: it exists only because Ξ-W2-T1 shipped ahead of this
|
||||
plan's sequencing claim. If more corrections of this shape appear, they belong here rather
|
||||
|
||||
+222
@@ -194,6 +194,34 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r
|
||||
|
||||
**Done looks like.** Not stated in the source beyond choosing one of the three placement options.
|
||||
|
||||
## Confirm the card name strip reads legibly at the shipping cell size (Ψ-W2-T1 DAW verification)
|
||||
|
||||
**Context.** Ψ-W2-T1 (`capture-naming`) put the capture's label on the docked panel card,
|
||||
across the top of the cell, drawn OVER the waveform thumbnail. Review found the strip's
|
||||
text/primary was measured at ~1:1 contrast against the accent-lime waveform fill at the
|
||||
shipping 140×84 cell size — a loud capture's peak reaches into the strip on 12 of its 13
|
||||
rows — and remediated it with a bg/base scrim behind the name (`kCardNameScrimAlpha`,
|
||||
`core/ui/theme.h`) sized so the composite clears the WCAG 4.5:1 body floor against both the
|
||||
bare fill and bare bg/cell (pinned in `test_theme.cpp`).
|
||||
|
||||
**The wart.** The floor math is verified; the actual on-screen read is not. No `[verify —
|
||||
DAW]` deferral was filed for this track's acceptance criterion ("the panel card shows the
|
||||
name") when it landed, unlike the sibling Ψ tracks.
|
||||
|
||||
**Intended fix.** N/A — no code change. Daniel views the docked panel with real captures
|
||||
(quiet and loud material, long and short names) and confirms the name reads over the
|
||||
waveform at the shipping cell size.
|
||||
|
||||
**The constraint the fix MUST handle.** N/A — verification only.
|
||||
|
||||
**Priority / risk.** Not stated. The math clears its floor with real margin (see
|
||||
`testCardNameScrimClearsBodyFloorOnItsWorstBackground`), so this is a confirmation step,
|
||||
not a suspected defect.
|
||||
|
||||
**Done looks like.** Daniel confirms the card name reads legibly over both quiet and
|
||||
loud waveform material at the shipping 140×84 cell size, or a follow-up adjusts the scrim
|
||||
alpha and this entry is re-filed against the new value.
|
||||
|
||||
## A realtime capture interrupted by a project switch leaves an untracked file behind
|
||||
|
||||
**Context (found by the tracking-consolidation review, 2026-07-30).** `DriveRealtimeCapture` detects that the active project is no longer the one the in-flight capture belongs to, aborts the backend, and drops the handle. On a `Done` abort the backend has *already* moved the recorded WAV into the **original** project's bank folder (`capture_realtime_finalize`), so a file the tool created exists with no bank entry and no ledger record.
|
||||
@@ -483,3 +511,197 @@ failure is a mis-drawn marker or a spuriously shown/hidden Hold knob, not bad au
|
||||
**Done looks like.** One fold answers the intrinsic for both the editor's markers and the
|
||||
engine's reload, with a test that moves the bank's loop out from under a loaded instance
|
||||
and shows the two agreeing.
|
||||
|
||||
## `ingestHandleSectionCommand` has no unit test
|
||||
|
||||
**Context (what shipped — Ψ-W1-T3, media-explorer-section).** The Media-Explorer
|
||||
import now dispatches through two hooks — `ingestHandleCommand` (Main,
|
||||
`"hookcommand"`) and `ingestHandleSectionCommand` (Media Explorer,
|
||||
`"hookcommand2"`). Both live in `ingest.cpp`, which compiles straight into the
|
||||
`reaper_reasampler` MODULE target.
|
||||
|
||||
**The wart.** No `shell/` translation unit in this repo has a test target — every
|
||||
`<module>_tests` executable is a `core/` pure-module target. `ingestHandleSectionCommand`
|
||||
is a two-line command-id comparison; correctness here rests on code review, not CTest.
|
||||
Review verified this constraint is real and the deferral correct.
|
||||
|
||||
**Intended fix.** Make `action_registry` a linkable library and give it the repo's
|
||||
first `shell/` test target, driven by a fake `reaper_plugin_info_t`. Its own header
|
||||
(`reaper_plugin.h:153-172`) shows `Register` is a plain member-function pointer on the
|
||||
struct, not a REAPER API pointer resolved through `REAPERAPI_LoadAPI` — a fake instance
|
||||
needs no live REAPER process to exercise `rec->Register(...)` calls. Once
|
||||
`action_registry` is test-covered, move the Media-Explorer section registration into it.
|
||||
|
||||
**The constraint the fix MUST handle.** The extraction alone buys nothing:
|
||||
`action_registry` has no test target today either, so lifting `ingestHandleSectionCommand`
|
||||
into it without also standing up the test target just relocates the untested code. The
|
||||
same follow-up could collapse `ingest.cpp:466-472`'s hand-rolled `command_id`+`gaccel`
|
||||
pair onto `action_registry::registerAction`, which already does exactly that dance for
|
||||
the Q-W6 table.
|
||||
|
||||
**Priority / risk.** Low / deferred. `ingestHandleSectionCommand` is a two-branch
|
||||
comparison, reviewed and correct at this scope; the gap is the missing test seam, not a
|
||||
known defect.
|
||||
|
||||
**Done looks like.** `action_registry` is a linkable library with its own `shell/`-first
|
||||
CTest target driven by a fake `reaper_plugin_info_t`; the Media-Explorer section
|
||||
registration and `ingestHandleSectionCommand` move into it and gain unit coverage; and
|
||||
`ingest.cpp`'s own `command_id`+`gaccel` registration collapses onto
|
||||
`action_registry::registerAction` where the shapes match.
|
||||
|
||||
## The `&128` multi-track output shape is still DAW-unobserved, and a refusal now rests on it
|
||||
|
||||
**Context.** The multi-track TRACK capture no longer lands one track's audio under an
|
||||
`Ok`: `renderOffline` refuses every selected-tracks render covering more than one track,
|
||||
both scopes, naming the way out (`render_settings::isMultiTrackStemRender` /
|
||||
`multiTrackRefusalMessage`). What did NOT change is the evidence: the per-track-output
|
||||
reading of `&128` is still INFERRED from the SDK header documenting the single-file bit
|
||||
`&(4<<16)` for item/razor sources only. It has never been observed in a DAW.
|
||||
|
||||
**The wart.** The refusal is therefore as unverified as the defect it closes. If REAPER
|
||||
in fact sums a multi-track `&128` render into the single literal `RENDER_PATTERN`, the
|
||||
refusal costs a working capture — a user who selects two tracks and captures gets a
|
||||
message where a correct summed file used to land.
|
||||
|
||||
**Intended fix.** Run the observation in `docs/verify-track-scope-multitrack.md` §3 (a
|
||||
hand-driven Render dialog, source "selected tracks via master", one literal filename, two
|
||||
tracks selected — then count the files REAPER writes). If it comes back "one file per
|
||||
track", nothing to do and the inference is retired into fact. If it comes back "one
|
||||
summed file", the refusal is over-strict for the TRACK scope and should be narrowed back
|
||||
— and the ITEM-scope half is then an OPEN question, not settled: a full-extent item
|
||||
capture already sums a multi-track item selection via `&32|single-file`
|
||||
(`tests/test_render_settings.cpp:262`), so if `&128` also sums, a ranged item capture
|
||||
routed through it sums too, and keeping the item refusal in that branch would make item
|
||||
scope inconsistent with itself across the range boundary (full-extent sums, ranged
|
||||
refuses, same scope). Whether that inconsistency is acceptable or the item refusal should
|
||||
narrow too needs its own look at that point — not decided here.
|
||||
|
||||
**The constraint the fix MUST handle.** Narrowing the refusal must keep the ITEM scope
|
||||
refusing, must keep `renderOffline` the single seam (so a recipe replay cannot diverge
|
||||
from a fresh capture), and must not re-open the collapse for any caller that reaches
|
||||
`&128` later — the predicate is keyed on the render source precisely so new callers
|
||||
inherit it.
|
||||
|
||||
**Priority / risk.** Low and bounded either way: the current behavior refuses rather than
|
||||
lands wrong audio, so the cost of being wrong here is a refused capture, not a bad one.
|
||||
|
||||
**Done looks like.** The `&128` multi-track output shape is DAW-observed and written into
|
||||
`src/shell/capture/CLAUDE.md` as fact rather than inference, and the refusal is either
|
||||
kept as-is or narrowed to the item scope with that observation cited.
|
||||
|
||||
## A `SelectedItems` recipe replays against whatever items are selected then
|
||||
|
||||
**Context (surfaced by Ψ-W1-T1, capture-range-exactness).** `RunRecaptureFromSource`
|
||||
rebuilds a `CaptureRequest` from the recorded `CaptureRecipe` and resolves its source
|
||||
tracks by GUID. `renderOffline` engages `RenderTrackSelection` only when the recipe's
|
||||
source mode is `SelectedTracks`, which is what makes a ranged item capture and a
|
||||
track capture replay against their recorded tracks rather than the live selection.
|
||||
|
||||
**The wart.** A recipe whose source mode is `SelectedItems` — every pre-fix item-scope
|
||||
capture, and every post-fix full-extent one — renders `&32`, which prints whatever
|
||||
items happen to be selected when the replay fires. The recorded recipe therefore does
|
||||
not fully determine the audio it reproduces, which is what "recapture from source"
|
||||
promises.
|
||||
|
||||
**Intended fix.** Not proposed. The recipe stores tracks and a range; it carries no
|
||||
item GUIDs, so no guard on the shell side can reconstruct the item selection from
|
||||
what is recorded. Closing it means widening `CaptureRecipe` (a wire-format change with
|
||||
a version rung) or re-sourcing full-extent item captures through the tracks render too,
|
||||
which would drag them onto the isolation path for no gain.
|
||||
|
||||
**The constraint the fix MUST handle.** Widening the recipe must keep every already-
|
||||
persisted recipe readable, and must not make a replay depend on items that no longer
|
||||
exist — a deleted source item has to degrade to a stated refusal, not a silent
|
||||
substitution.
|
||||
|
||||
**Priority / risk.** Pre-existing; not introduced or worsened by the range-exactness
|
||||
work. Harmless when the user re-runs a recapture with the same items still selected,
|
||||
wrong when they do not.
|
||||
|
||||
**Done looks like.** A `SelectedItems` recapture either reproduces its recorded audio
|
||||
from the recipe alone, or refuses with a message naming what the recipe cannot pin
|
||||
down.
|
||||
|
||||
## An overlapping item on the source track itself is not isolated from a ranged item capture — DECIDED, not deferred
|
||||
|
||||
**Context (surfaced by Ψ-W1-T1, capture-range-exactness).** The re-source to the
|
||||
selected-tracks render (`&128`) needed transient upstream silencing so an item capture
|
||||
did not also print folder children and receives; `render_isolation` (`UpstreamIsolation`)
|
||||
covers both. A third widening exists in the same shape: a non-selected item on the
|
||||
SAME track that overlaps the requested range is now audible in the render, where the
|
||||
pre-fix `&32` selected-items source excluded it by construction (that source only ever
|
||||
prints the selected items).
|
||||
|
||||
**This is a decision, not a gap.** `src/shell/capture/CLAUDE.md` states the reasoning in
|
||||
full and it is not repeated here: `UpstreamIsolation`/`render_selection` silence and
|
||||
select TRACKS because the recipe that replays a capture stores tracks and a range, never
|
||||
item GUIDs — a mute plan keyed to today's overlapping item could not be recomputed at
|
||||
replay time, so muting items would make the capture stop reproducing itself. The named
|
||||
candidate (a) in `docs/PLAN.md` §Ψ-W1-T1 carried exactly this semantic edge; it was
|
||||
weighed against candidate (b) (an item-bounds render with a derived start time) and (a)
|
||||
shipped with the edge accepted rather than closed.
|
||||
|
||||
**Priority / risk.** Low in the common case (one item per track over the captured range is
|
||||
the normal shape); a project with deliberately overlapping items on one track is the one
|
||||
that surfaces it, and the practical mitigation is unchanged from before this track:
|
||||
select/move the neighbour, or capture at track scope instead.
|
||||
|
||||
**Done looks like.** Nothing to do — recorded so a future reviewer does not read the
|
||||
non-isolation as an oversight and re-propose closing it against the recipe's stated
|
||||
tracks-and-range-only shape.
|
||||
|
||||
## Resample-bake landings don't apply the lossless mono collapse to a dual-mono render
|
||||
|
||||
**Context (surfaced by Ψ-W2-T2, mono-collapse).** The collapse (`collapseCapturedFileToMono`
|
||||
/ `core/capture/wav_codec::collapseToMono`) ships for every extension capture path —
|
||||
offline, realtime, batch, recapture — but not for `bake_land.cpp`'s `landOne`, the
|
||||
resample bake's landing function. A dead-center instrument render (the common case
|
||||
that motivated Ψ.6 in the first place) is exactly the dual-mono shape the predicate
|
||||
collapses, so an un-collapsed bake keeps paying for the second channel it doesn't need.
|
||||
|
||||
**Not deferred for the reason once given.** `landOne` reads the staged file into `bytes`
|
||||
once (`bake_land.cpp:101`), parses its layout (`:105`), hashes it (`:126`), derives the
|
||||
channel count twice (`:131`, `:178`), and writes it (`:165`) — all from that same one
|
||||
buffer, so collapsing `bytes` right after the layout parse would keep the hash, the
|
||||
channel count, and the written file consistent by construction; there is no ordering
|
||||
hazard here to defer around.
|
||||
|
||||
**The real reason.** `bake_land.cpp` is Phase Ξ's freshly-landed surface
|
||||
(Ξ-W2-T1, the resample bake chain) and another team is actively remediating it. Landing
|
||||
a mutation there now would cross tracks mid-remediation for no urgent gain — the mono
|
||||
propagation this item would add is a size win, not a correctness one.
|
||||
|
||||
**A mono capture already propagates through the bake for free**, so this item is scoped
|
||||
to the dual-mono-*render* case only: `runBake` / `instrument_bake.cpp` already renders
|
||||
however many channels the dialed sound has, and `bake_render.cpp:38` reads
|
||||
`sample.channelCount()` off that render rather than hardcoding 2 — a mono-programmed
|
||||
sound already bakes to a mono file today, with no change needed.
|
||||
|
||||
**Intended fix.** Once `bake_land.cpp` is quiet, call `collapseToMono` on the staged
|
||||
`bytes` in `landOne` right after the layout parse (`:105`) and before the hash (`:126`),
|
||||
matching the offline/realtime insertion point (post-parse, pre-identity-read).
|
||||
|
||||
**Priority / risk.** Low — a size optimization on an already-correct path, not a
|
||||
precision-invariant gap; the bake's dual-mono case still lands as a valid (if larger)
|
||||
stereo file today.
|
||||
|
||||
**Done looks like.** A dead-center instrument bake lands as a 1-channel file with
|
||||
`Sample::channelCount` matching, the same way an offline dead-center capture does; a
|
||||
true-stereo bake is byte-identical to today's output.
|
||||
|
||||
## A 0-byte render can pass every gate and land as `Ok` (pre-existing, not a Ψ-W3 regression)
|
||||
|
||||
**Context (surfaced by Ψ-W3 review).** `OfflineRenderBackend::capture`'s exists-check
|
||||
(`capture.cpp:489`) passes for a 0-byte file, and the bounds gate (`:507-546`) only fires
|
||||
when `expectedFrames > 0` — an invalid/empty layout reads `expectedFrames == 0` and skips
|
||||
the gate rather than refusing. A 0-byte render can therefore reach `stampCaptureSample`
|
||||
and land as `CaptureStatus::Ok` with an empty `contentHash` and `channelCount == 0`.
|
||||
|
||||
**Not introduced by Ψ-W3.** The exists-check and the `expectedFrames > 0` guard both
|
||||
predate this track; Ψ-W3 only added the mono-collapse failure report that sits downstream
|
||||
of this hole and was careful not to assert bytes it never verified (see
|
||||
`reportCollapseFailure` in `capture.cpp`).
|
||||
|
||||
**Intended fix.** After the exists-check, also reject a 0-byte file explicitly (its own
|
||||
status, not folded into `BoundsMismatch`, since a 0-byte file was never bounds-checked at
|
||||
all) before anything downstream reads it.
|
||||
|
||||
+23
-13
@@ -157,12 +157,19 @@ load hitch and any un-persisted internal state is lost. This is an accepted cost
|
||||
of the CPU reclaim, not a bug. It must be documented at the toggle affordance so
|
||||
the user isn't surprised.
|
||||
|
||||
**Never touched:** `B_MUTE` and `I_SOLO`. The tool owns visibility, `B_MAINSEND`,
|
||||
`I_FXEN`, and FX-offline — nothing else — across every managed leaf, tagged or
|
||||
untagged. The user's mute/solo survives every toggle, untouched. This is the exact
|
||||
analog of the
|
||||
capture pillar's non-destructive invariant: **the tool never destroys the user's
|
||||
real state to do its job.**
|
||||
**Never touched:** `B_MUTE`. The tool owns visibility, `B_MAINSEND`, `I_FXEN`,
|
||||
FX-offline, and `I_SOLO` — nothing else — across every managed leaf, tagged or
|
||||
untagged.
|
||||
|
||||
**Solo is owned but never lost.** Solo is a per-mode surface: switching modes banks
|
||||
the outgoing mode's solo state, clears it, and replays the incoming mode's on
|
||||
return, verbatim. Two modes therefore never share a solo — you can solo the drum
|
||||
bus in Arrange and the sound-design chain in Design without either leaking into the
|
||||
other — and neither is destroyed. That is the same exact analog of the capture
|
||||
pillar's non-destructive invariant the flags above satisfy: **the tool never
|
||||
destroys the user's real state to do its job.** It is snapshot-and-restore, one
|
||||
level out from a single toggle to the pair of stances. Reapplying the current mode
|
||||
(tagging, project load) is not a switch and does not touch solo at all.
|
||||
|
||||
---
|
||||
|
||||
@@ -316,8 +323,10 @@ Mirrors the capture pillar's split exactly.
|
||||
- Snapshots prior flag values before parking (reads the same flags it will drive).
|
||||
- Resolves track GUIDs via `GetTrackGUID` / `guidToString` / `stringToGuid` for the
|
||||
index; never uses track index (unstable across reorders).
|
||||
- Never touches the master track's visibility flags; never touches `B_MUTE` /
|
||||
`I_SOLO` on anything.
|
||||
- On a real switch only, banks/clears/replays `I_SOLO` per the per-mode solo surface
|
||||
above.
|
||||
- Never touches the master track's visibility flags; never touches `B_MUTE` on
|
||||
anything.
|
||||
|
||||
**`persist` slice:**
|
||||
- Serialize/deserialize the view section (modes + membership + show-both + snapshots
|
||||
@@ -585,11 +594,12 @@ The settled distinction:
|
||||
touch them**: a mode toggle never shows, hides, silences, re-lanes, or re-plays a
|
||||
manual lane. Its `C_LANEPLAYS` state is the user's, left exactly as they set it.
|
||||
|
||||
This is the fixed-lane analog of the two invariants already load-bearing in D1 —
|
||||
*never touch `B_MUTE`/`I_SOLO`* and *never touch the master* — extended to a third
|
||||
surface: **never drive a lane the tool did not mint.** It is the same non-destructive
|
||||
promise (the tool owns only what it created) reaching one level deeper, into the lane
|
||||
dimension.
|
||||
This is the fixed-lane analog of the invariants already load-bearing in D1 —
|
||||
*never touch `B_MUTE`*, *never touch the master*, and *never lose the user's solo*
|
||||
(see "Never touched" above) — extended to a further surface: **never drive a lane
|
||||
the tool did not mint.** It is the same non-destructive promise (the tool owns only
|
||||
what it created, and restores what it parks) reaching one level deeper, into the
|
||||
lane dimension.
|
||||
|
||||
### Lane-ownership index (the new data)
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# DAW verification — track-scope capture over a multi-track selection
|
||||
|
||||
What a DAW pass must establish for the multi-track track capture, and the exact numbers
|
||||
or strings to read off. Nothing below can be closed by a unit test: every item depends on
|
||||
what REAPER actually does with a render request.
|
||||
|
||||
**Build to use.** Release, installed into `UserPlugins/`, REAPER restarted — extensions
|
||||
load at startup only. Set the docked panel's tail toggle to **None** before every cell;
|
||||
Auto adds an 8 s window and Manual a fixed one, and both would invalidate the frame-count
|
||||
readings.
|
||||
|
||||
**Project to use.** One saved project, project sample rate pinned to 48000. Two audio
|
||||
tracks, `A` and `B`, each holding one item at least 30 s long, with *audibly different*
|
||||
content (a tone on `A`, a drum loop on `B`). One folder track `F` with `A` and `B` as its
|
||||
children, used only in §5.
|
||||
|
||||
---
|
||||
|
||||
## 1. The regression floor — single-track track capture is unchanged
|
||||
|
||||
Select **track `A` only**. Make a time selection from **10.000 s to 12.000 s**. Run
|
||||
*ReaSampler: capture selected track(s)*.
|
||||
|
||||
Read off:
|
||||
|
||||
- A file appears in the project's bank folder, and one new card appears on the panel.
|
||||
- The card's length reads **2.000 s**; its frame count is **96000** (`round(12.0 × 48000)
|
||||
− round(10.0 × 48000)`). The backend refuses the capture with `BoundsMismatch` if the
|
||||
render is more than one frame off that, so a landed capture already proves the number
|
||||
to ±1 — what you are confirming here is that it landed at all.
|
||||
- The REAPER console shows **no** `ReaSampler capture failed:` line.
|
||||
- Track `A` is still the only selected track afterwards.
|
||||
- **Content, not just length.** Listen to the landed file. `A` and `B` carry *audibly
|
||||
different* content by the project setup above (tone vs. drum loop), so this is a by-ear
|
||||
check, not a null test: the capture must be the tone alone, with **no** drum-loop bleed.
|
||||
Expected: pure tone, matching `A` soloed. Failing: any trace of `B`'s drum loop audible
|
||||
in the file. This is not a tautology check — the SDK header's own `RENDER_SETTINGS` line
|
||||
admits a second reading, `(&(1|2)==0)=master mix`, under which a single-track track
|
||||
capture could render the **whole master mix** (both `A` and `B`) rather than `A` alone;
|
||||
drum-loop bleed here is exactly what that misreading would produce, and this is the
|
||||
cheapest place in the whole doc to catch it.
|
||||
|
||||
**This is the byte-identical floor.** If either cell now refuses, the change is wrong —
|
||||
the refusal must fire only above one track.
|
||||
|
||||
## 2. The defect cell — two selected tracks now refuse
|
||||
|
||||
Select **`A` and `B` together**. Time selection 10.000–12.000 s. Run *capture selected
|
||||
track(s)*.
|
||||
|
||||
Read off:
|
||||
|
||||
- The console prints exactly:
|
||||
`ReaSampler capture failed: A track capture renders the selected tracks through the
|
||||
master, and more than one track cannot land as a single file. Capture one track at a
|
||||
time, or route them into a folder/bus track and capture that (a folder's own output is
|
||||
its children summed).`
|
||||
- **No** new card on the panel, and **no** new `.wav` in the bank folder (check the folder
|
||||
directly — a stray file with nothing indexing it would mean the refusal fired too late).
|
||||
- `A` and `B` are both still selected, both still unmuted, and neither track's fader, pan,
|
||||
or FX-bypass state changed. The refusal returns before any guard is constructed, so
|
||||
there should be nothing to restore — this reading is what confirms that.
|
||||
|
||||
Repeat with a **razor area spanning both tracks** and no time selection: identical
|
||||
readings. Note that the track *selection* is what the refusal counts — a razor over two
|
||||
tracks with only `A` selected is a one-track capture and must still succeed (§1).
|
||||
|
||||
## 3. The decisive observation — what `&128` actually writes
|
||||
|
||||
**This is the one that retires an inference, and it is the reason `docs/TODO.md` still
|
||||
carries an entry.** The refusal in §2 rests on reading the SDK header's single-file bit
|
||||
`&(4<<16)` as applying to item/razor sources only, never to `&128` — so N selected tracks
|
||||
are believed to produce N files. That has never been observed.
|
||||
|
||||
Drive REAPER's own Render dialog by hand, with the extension out of the loop:
|
||||
|
||||
1. Select `A` and `B`.
|
||||
2. File → Render. **Source:** *Selected tracks via master* — the dialog wording for `&128`
|
||||
(SDK header ~3041). Do **not** pick *Stems (selected tracks)* — that is `&2`, a
|
||||
different source bit that unambiguously writes one file per track and would confirm
|
||||
nothing about `&128`.
|
||||
**Bounds:** *Custom time range*, 10.000 to 12.000 s.
|
||||
3. **File name:** a literal stem with **no wildcards at all** — e.g. `stemprobe`. Clear
|
||||
`$track` / `$item` / anything else from the pattern; the extension writes exactly one
|
||||
literal stem, so the probe must too.
|
||||
4. Render to an empty scratch folder.
|
||||
|
||||
Read off — **the file count in that folder**:
|
||||
|
||||
- **Two files** (however REAPER disambiguated them, or one file that visibly got
|
||||
overwritten): the inference holds, the §2 refusal is correct, and the `docs/TODO.md`
|
||||
entry can be closed by writing this observation into `src/shell/capture/CLAUDE.md` as
|
||||
fact.
|
||||
- **One file containing `A` and `B` summed** (confirm by ear, or by nulling it against a
|
||||
master render of the same range with only `A` and `B` unmuted): the inference is wrong,
|
||||
the §2 refusal costs a working capture, and the track-scope half should be narrowed back
|
||||
per the `docs/TODO.md` entry. The item-scope half stays either way.
|
||||
|
||||
Also record **what REAPER named the files** — that decides whether a future correct
|
||||
multi-track capture could ever be built on this source at all.
|
||||
|
||||
## 4. Recapture replays the same answer
|
||||
|
||||
Take a **single-track** track capture that carries provenance (capture a range on `A`
|
||||
whose source item is itself a bank sample, so `detectParent` fires), select its card, and
|
||||
run *re-capture from source*. It must regenerate — same audio, same 96000 frames.
|
||||
|
||||
Then construct the multi-track case: a recorded recipe whose `trackGuids` names two
|
||||
tracks. The reachable way to get one is to have captured it before this change; if no such
|
||||
entry exists in any project, record that this cell was **not exercised** rather than
|
||||
inventing one. When it is exercised, read off:
|
||||
|
||||
- `ReaSampler re-capture failed:` followed by the **same** message text as §2.
|
||||
- The bank entry is untouched — same file, same hash, same card.
|
||||
|
||||
## 5. The way out actually works
|
||||
|
||||
Route `A` and `B` into folder `F`. Select **`F` only**, time selection 10.000–12.000 s,
|
||||
capture track scope.
|
||||
|
||||
Read off: one card, 2.000 s, and the audio contains **both** `A` and `B`. This is what the
|
||||
refusal message tells the user to do, so it has to be true.
|
||||
|
||||
## 6. Realtime still accepts a multi-track selection
|
||||
|
||||
Select `A` and `B`. Run *ReaSampler: capture selected track(s) in realtime* over the same
|
||||
range. Read off: **one** card, and its audio contains both tracks. Realtime taps each
|
||||
source track with a send into one temp track, so it sums where the offline render cannot —
|
||||
the divergence from §2 is deliberate and this cell is what confirms it is real.
|
||||
|
||||
## 7. Mono collapse — what is and is not reachable
|
||||
|
||||
Capture a range on a track whose content is dead-center (a mono source panned center, or
|
||||
a duplicated-channel file), using time selection **10.000 s to 12.000 s** (2.000 s, 96000
|
||||
frames at 48000 Hz — the §1 convention, so the resulting file size is exact). The panel has
|
||||
no channel-count readout anywhere (`Sample::channelCount` is not drawn by
|
||||
`src/shell/panel/panel_render.cpp`), so read the proxy instead:
|
||||
|
||||
- Check the landed `.wav`'s size on disk (Explorer → Properties, or a directory listing). A
|
||||
successful collapse is the extension's own rebuild — canonical 44-byte header + 96000 ×
|
||||
4 bytes = **384,044 bytes**. A file near double that (~768,044 bytes, plus whatever
|
||||
REAPER's own render adds for `bext`/metadata chunks) means the collapse did not fire —
|
||||
recheck the source is genuinely dead-center before treating this as a defect.
|
||||
- The console shows **no** `the lossless mono collapse ... already reached the bank; only
|
||||
the size win from the collapse was lost.` line.
|
||||
|
||||
**Not DAW-reachable:** the collapse's *failure* branch. It fires only if the captured file
|
||||
cannot be read, or its temporary rewrite cannot be written or renamed, inside the same
|
||||
call that just rendered the file — there is no manual way to inject that fault between the
|
||||
render and the rename. The branch is covered only at its reporting seam
|
||||
(`tests/test_wav_codec.cpp`, `testCollapseOutcomeSuffixesAreDistinctStrings`), and its console line
|
||||
has never been seen in a running REAPER. If you ever do see it, the render already reached
|
||||
the bank — the report only tells you the collapse's size win was lost, not that the bytes
|
||||
were verified (see `docs/TODO.md`'s 0-byte-render entry).
|
||||
@@ -12,6 +12,8 @@ add_library(reaper_reasampler MODULE
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_batch.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/bake_land.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/scope_resolve.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/render_selection.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/render_isolation.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/realtime_lifecycle.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_shell.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_finalize.cpp
|
||||
@@ -33,6 +35,7 @@ add_library(reaper_reasampler MODULE
|
||||
${LICE_SRC}
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/view/view.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/view/view_solo.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/track_guid.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/provenance_shell.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/capture/item_read.cpp
|
||||
@@ -41,11 +44,12 @@ add_library(reaper_reasampler MODULE
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/bank_actions.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/prune_action.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/ingest.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/arrange_drop_win.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/drag_out_win.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp
|
||||
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp
|
||||
)
|
||||
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name)
|
||||
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name)
|
||||
# NOT linked here, deliberately: sampler_core / pitch_shift / the filter. The instrument
|
||||
# renders its own bake in its own process, which is what keeps the extension's link graph
|
||||
# free of the voice engine — a link edge to it here means the design drifted.
|
||||
|
||||
+14
-2
@@ -216,8 +216,8 @@ static project_config_extension_t g_projectConfig{
|
||||
nullptr, // userData
|
||||
};
|
||||
|
||||
// REAPER calls this for EVERY action fired anywhere; claim only our own id, return
|
||||
// false otherwise so REAPER keeps looking. This TU's own family dispatches through
|
||||
// REAPER calls this for every action fired in the MAIN section; claim only our own id,
|
||||
// return false otherwise so REAPER keeps looking. This TU's own family dispatches through
|
||||
// the registration table; the other families claim their own ids after it.
|
||||
static bool OnHookCommand(int command, int /*flag*/)
|
||||
{
|
||||
@@ -229,6 +229,16 @@ static bool OnHookCommand(int command, int /*flag*/)
|
||||
return false;
|
||||
}
|
||||
|
||||
// "hookcommand" covers the main section only, so actions we published into another
|
||||
// section arrive here instead. Partitioning contract: root `CLAUDE.md` §"REAPER
|
||||
// extension contract".
|
||||
static bool OnHookCommand2(KbdSectionInfo* /*sec*/, int command, int /*val*/, int /*val2*/,
|
||||
int /*relmode*/, HWND /*hwnd*/)
|
||||
{
|
||||
if (command == 0) return false;
|
||||
return reasampler::ingestHandleSectionCommand(command);
|
||||
}
|
||||
|
||||
// REAPER polls this to render each of OUR actions' checked state in menus/toolbars.
|
||||
// Return 1 (on) / 0 (off) for ids we own, -1 for everything else (per the contract).
|
||||
static int OnToggleAction(int command)
|
||||
@@ -255,6 +265,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
g_rec->Register("-projectconfig", (void*)&g_projectConfig);
|
||||
g_rec->Register("-toggleaction", (void*)&OnToggleAction);
|
||||
g_rec->Register("-hookcommand", (void*)&OnHookCommand);
|
||||
g_rec->Register("-hookcommand2", (void*)&OnHookCommand2);
|
||||
reasampler::designViewUnregisterActions(g_rec);
|
||||
reasampler::bankUnregisterActions(g_rec);
|
||||
reasampler::ingestUnregisterActions(g_rec);
|
||||
@@ -307,6 +318,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||
reasampler::ingestRegisterActions(rec, &g_session);
|
||||
|
||||
rec->Register("hookcommand", (void*)&OnHookCommand);
|
||||
rec->Register("hookcommand2", (void*)&OnHookCommand2);
|
||||
|
||||
// Drives project-load / Save-As detection: the timer polls the active project
|
||||
// each tick; on a project load it reloads the bank from ext state, on a Save-As
|
||||
|
||||
@@ -45,12 +45,15 @@ Detail specific to these pure modules:
|
||||
|
||||
## Modules
|
||||
|
||||
- `wav_codec` — chunk walker + layout parse + float32 build + size-field patch + content hashes; the single pure RIFF/WAV owner (`wav_trim` is retired; `wav_codec` is the sole owner).
|
||||
- `wav_codec` — chunk walker + layout parse + float32 build + size-field patch + the lossless mono collapse + content hashes; the single pure RIFF/WAV owner (`wav_trim` is retired; `wav_codec` is the sole owner).
|
||||
- `capture_realtime` (`core/capture`, **renamed from `realtime_record` in Q-W3** — the Q-9 naming rider: pure module takes the stem, the shell takes the suffix, matching `drag_out`/`drag_out_win`) — the M8 realtime-record pure logic: capture scope + FX-tap point → `I_RECMODE`/`I_RECMODE_FLAGS` values, wet/dry → tap point, the recorded-file → `Sample` mapping, and the async record-phase state machine. Depends on `bank_model` for the plain `Sample`/`SourceMode` types. The transport/temp-track/send recipe lives in the shell (`shell/capture/capture_realtime_shell.cpp` + `capture_realtime_finalize.cpp`).
|
||||
- `batch_capture` — pure batch-capture planner: maps source ranges to capture units and aggregates results.
|
||||
- `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition` → `NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives.
|
||||
- `capture_name` — the REAPER-free composition of one capture's label + file-stem base from its source-track name(s), a local-calendar discriminator (`MM-DD HHMM`, from the shell's clock read), and an optional batch ordinal. The label and the stem deliberately diverge: the stem still passes through `capture_paths::sanitizeStem` (so a name that sanitizes to nothing files as `capture`), while the label keeps the source name verbatim. Stem uniqueness stays entirely `makeUniqueTag`'s — this module never disambiguates.
|
||||
- `insert_plan` — the REAPER-free logic behind the `insert` shell (M6): computes the `InsertMedia` `mode` bitmask from an `InsertOptions` struct (placement target, tempo-conform ratio, preserve-pitch flag), guaranteeing the &4 stretch-to-time-selection bit is never set and that no tempo bits are set when `conform == None`.
|
||||
- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode` → `RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), and the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family.
|
||||
- `render_window` — the REAPER-free frame arithmetic behind exact capture bounds: `frameCountFor` (the frame count a project-time window occupies at the project rate — the number the offline backend checks the rendered file against before landing it, so a widened render is refused rather than banked) and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all.
|
||||
- `track_topology` — the REAPER-free folder arithmetic over a project's flat `I_FOLDERDEPTH` delta list: `directChildIndices` names a folder parent's DIRECT children, the set `shell/capture/render_isolation` silences so a ranged item capture does not print its track's children. Grandchildren are excluded by construction — they reach the parent only through the child that owns them.
|
||||
- `tail_control` — the REAPER-free logic behind the docked `bank_panel`'s tail-mode toggle: the cycle order (None → Auto → Manual → None), the Manual-length clamp/scroll-wheel fine-adjust (`clampManualMs`/`adjustManualMs`, 250 ms/notch, 2000 ms default), the toggle's label text (e.g. "Tail: Manual 2.0s"), and the `TailSetting` JSON round-trip persist stores per-project.
|
||||
|
||||
## Gotchas
|
||||
@@ -60,9 +63,41 @@ Detail specific to these pure modules:
|
||||
(`reaper_plugin_functions.h` lines ~3041/~3047/~3051/~3062) — re-verify
|
||||
against the header before changing any bit value, per the root `CLAUDE.md`
|
||||
API-verification rule.
|
||||
- **The selected-items render source (`&32`) cannot narrow a window** — REAPER
|
||||
derives that render's bounds from the selected items' own extents, so
|
||||
`RENDER_BOUNDSFLAG=0` + `RENDER_STARTPOS`/`RENDER_ENDPOS` do not constrain it.
|
||||
This is an inference from the observed defect (a time selection inside a long
|
||||
item captured the whole item), NOT a header-confirmed fact. It is why
|
||||
`sourceModeForScope` routes item scope to `&32` only when the item extent
|
||||
already IS the requested window — do not re-point item scope unconditionally at
|
||||
`&32`, and do not widen the `&32` branch to windows it cannot express. This is the
|
||||
one home for that inference; the sites that act on it point here rather than
|
||||
restating it.
|
||||
- **The re-source changes the CONTENT, not the FX scope.** `fxBypassPlanFor` is keyed
|
||||
on `CaptureScope`, so a ranged item capture still hears take/item FX only — but the
|
||||
selected-tracks source prints everything upstream of the track. The shell answers
|
||||
that with a transient silencing (`shell/capture/render_isolation`) whose child-set
|
||||
walk lives here in `track_topology`; the item-vs-track asymmetry behind it is in
|
||||
`src/shell/capture/CLAUDE.md`.
|
||||
- `kRenderPreFaderStems` (&8192) is deliberately **not** used — REAPER offline
|
||||
render has no true pre-FX "dry" bit; FX scoping is done entirely by the
|
||||
FX-bypass-around-render mechanism, never by a render bit.
|
||||
- **The mono collapse changes a capture's content identity, by design.**
|
||||
`hashWavContent` covers the `fmt ` body plus the `data` payload, and the collapse
|
||||
rewrites both — so a collapsed capture does NOT hash-dedup against a stereo twin of
|
||||
the same audio already in the bank. Accepted: the predicate is deterministic over
|
||||
deterministic bytes, so repeats of the same request still dedup against each other,
|
||||
which is what the bit-identical-repeats invariant actually asks for. Do not "fix"
|
||||
this by hashing pre-collapse — that would make two entries with different audio
|
||||
layouts share one identity.
|
||||
- **The collapse's minimal rebuild also drops `bext`/iXML/LIST — a source-position
|
||||
consequence, not only a hashing one.** REAPER's renderer writes a `bext` time
|
||||
reference, and REAPER's own import paths can position an item at that BWF timestamp,
|
||||
so a collapsed capture loses it while a declined (non-collapsed) capture from the same
|
||||
action keeps it — two captures from one action behave differently on re-import.
|
||||
`shell/capture/insert.cpp` is unaffected (it drives `SetEditCurPos` + `InsertMedia`
|
||||
rather than reading BWF), so this is not a defect in the shipped insert path.
|
||||
Accepted, not verified against a DAW re-import: `[verify — DAW]`.
|
||||
- `tail_control`'s `kDefaultManualTailMs`/`kManualStepMs` and
|
||||
`render_settings`'s `kMaxTailMs`/`kAutoTrimThresholdDb` are separate constants
|
||||
in separate files by design (panel-facing default/step vs. runaway-guard cap)
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
reasampler_pure_library(capture_paths SOURCES capture_paths.cpp)
|
||||
reasampler_test(capture_paths LINK capture_paths)
|
||||
|
||||
reasampler_pure_library(capture_name SOURCES capture_name.cpp)
|
||||
# capture_paths: the stem base's real contract is that sanitizeStem keeps it legal, so the
|
||||
# name tests assert the composed stem THROUGH the sanitizer rather than in isolation.
|
||||
reasampler_test(capture_name LINK capture_name capture_paths)
|
||||
|
||||
reasampler_pure_library(insert_plan SOURCES insert_plan.cpp)
|
||||
reasampler_test(insert_plan LINK insert_plan)
|
||||
|
||||
reasampler_pure_library(render_settings SOURCES render_settings.cpp LINK PUBLIC bank_model)
|
||||
reasampler_test(render_settings LINK render_settings)
|
||||
|
||||
reasampler_pure_library(render_window SOURCES render_window.cpp)
|
||||
reasampler_test(render_window LINK render_window)
|
||||
|
||||
reasampler_pure_library(track_topology SOURCES track_topology.cpp)
|
||||
reasampler_test(track_topology LINK track_topology)
|
||||
|
||||
reasampler_pure_library(batch_capture SOURCES batch_capture.cpp)
|
||||
reasampler_test(batch_capture LINK batch_capture)
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// capture_name — pure implementation. See the header.
|
||||
|
||||
#include "core/capture/capture_name.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
// A track name padded with spaces would render ragged in the label and as underscores in
|
||||
// the stem, so both ends are trimmed before anything else looks at it.
|
||||
std::string trimmed(const std::string& s) {
|
||||
std::size_t b = 0;
|
||||
std::size_t e = s.size();
|
||||
auto isSpace = [](unsigned char c) {
|
||||
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
|
||||
};
|
||||
while (b < e && isSpace(static_cast<unsigned char>(s[b]))) ++b;
|
||||
while (e > b && isSpace(static_cast<unsigned char>(s[e - 1]))) --e;
|
||||
return s.substr(b, e - b);
|
||||
}
|
||||
|
||||
// Truncating mid-sequence would put invalid UTF-8 into the persisted label, so the cut
|
||||
// backs off over continuation bytes (10xxxxxx). The stem does not care — sanitizeStem
|
||||
// replaces every non-ASCII byte anyway — but one rule for both keeps them the same name.
|
||||
std::string truncateUtf8(const std::string& s, std::size_t maxBytes) {
|
||||
if (s.size() <= maxBytes) return s;
|
||||
std::size_t cut = maxBytes;
|
||||
while (cut > 0 && (static_cast<unsigned char>(s[cut]) & 0xC0) == 0x80) --cut;
|
||||
return s.substr(0, cut);
|
||||
}
|
||||
|
||||
int clampTo(int v, int lo, int hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string formatCaptureStamp(const CaptureStamp& stamp) {
|
||||
if (stamp.month < 1 || stamp.day < 1) return {};
|
||||
char buf[24];
|
||||
std::snprintf(buf, sizeof(buf), "%02d-%02d %02d%02d",
|
||||
clampTo(stamp.month, 1, 12), clampTo(stamp.day, 1, 31),
|
||||
clampTo(stamp.hour, 0, 23), clampTo(stamp.minute, 0, 59));
|
||||
return buf;
|
||||
}
|
||||
|
||||
CaptureName composeCaptureName(const CaptureNameInputs& in) {
|
||||
std::string base;
|
||||
int named = 0;
|
||||
for (const std::string& raw : in.sourceNames) {
|
||||
const std::string n = trimmed(raw);
|
||||
if (n.empty()) continue;
|
||||
if (base.empty()) base = n;
|
||||
++named;
|
||||
}
|
||||
if (base.empty()) base = trimmed(in.fallback);
|
||||
if (base.empty()) base = "capture";
|
||||
base = truncateUtf8(base, kMaxSourceNameBytes);
|
||||
// truncateUtf8 backs off over continuation bytes, so a name whose first kMaxSourceNameBytes
|
||||
// bytes are ALL continuation bytes (0x80-0xBF) backs off to nothing — re-apply the "never an
|
||||
// empty label" fallback after truncation, not just before it.
|
||||
if (base.empty()) base = "capture";
|
||||
|
||||
CaptureName out;
|
||||
out.label = base;
|
||||
out.stemBase = base;
|
||||
|
||||
// Several sources collapse onto the first one's name plus a count of the rest — the
|
||||
// alternative (joining every name) produces a stem no one can read and a label that
|
||||
// no longer fits a card.
|
||||
if (named > 1) {
|
||||
const std::string extra = std::to_string(named - 1);
|
||||
out.label += " +" + extra;
|
||||
out.stemBase += "+" + extra;
|
||||
}
|
||||
|
||||
if (in.ordinal > 0) {
|
||||
const std::string ord = std::to_string(in.ordinal);
|
||||
out.label += " #" + ord;
|
||||
out.stemBase += "-" + ord;
|
||||
}
|
||||
|
||||
const std::string stamp = formatCaptureStamp(in.stamp);
|
||||
if (!stamp.empty()) out.label += " " + stamp;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
// capture_name — the REAPER-free composition of one capture's label and file-stem base
|
||||
// from its source-track name(s), a local-calendar discriminator, and an optional batch
|
||||
// ordinal. The shell reads the names and the clock; the SHAPE of a capture's name is
|
||||
// decided here so it is testable without a DAW.
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// The capture's own moment, already broken down into LOCAL calendar fields by the shell.
|
||||
// Passing fields rather than an epoch is what keeps the format deterministic under test:
|
||||
// an epoch would render differently per machine timezone. month < 1 or day < 1 means
|
||||
// "no stamp" and suppresses the discriminator entirely.
|
||||
struct CaptureStamp {
|
||||
int month = 0; // 1-12
|
||||
int day = 0; // 1-31
|
||||
int hour = 0; // 0-23
|
||||
int minute = 0; // 0-59
|
||||
};
|
||||
|
||||
// Longest source-name prefix kept in either the label or the stem. Real track names sit
|
||||
// far under it; the bound exists so a pathological name cannot push the rendered file
|
||||
// path toward the platform's limit, and so a label and its file still read as the same
|
||||
// name.
|
||||
inline constexpr std::size_t kMaxSourceNameBytes = 64;
|
||||
|
||||
struct CaptureNameInputs {
|
||||
// Source-track names in source order — the first non-empty one names the capture,
|
||||
// the rest only contribute the "+N" multi-source marker.
|
||||
std::vector<std::string> sourceNames;
|
||||
|
||||
CaptureStamp stamp;
|
||||
|
||||
// Batch unit ordinal; <= 0 for a single capture.
|
||||
int ordinal = 0;
|
||||
|
||||
// The scope literal ("item"/"track"/"realtime"), used ONLY when no source name
|
||||
// resolved at all — otherwise the source name wins.
|
||||
std::string fallback = "capture";
|
||||
};
|
||||
|
||||
struct CaptureName {
|
||||
// Sample::displayName. Legible, carries the source name verbatim, and is explicitly
|
||||
// NOT unique (core/model/CLAUDE.md §resample_name) — the stamp serves the eye.
|
||||
std::string label;
|
||||
|
||||
// deriveBankPaths' baseName. Still passes through sanitizeStem, and stem uniqueness
|
||||
// is still entirely makeUniqueTag's job.
|
||||
std::string stemBase;
|
||||
};
|
||||
|
||||
// "MM-DD HHMM" (e.g. "08-01 1432"); empty when the stamp carries no calendar date.
|
||||
// Year is deliberately omitted: the card and the browse list are narrow, and Sample
|
||||
// carries the full createdTimestamp for anything needing the exact moment.
|
||||
std::string formatCaptureStamp(const CaptureStamp& stamp);
|
||||
|
||||
CaptureName composeCaptureName(const CaptureNameInputs& in);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -100,12 +100,44 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
||||
return c;
|
||||
}
|
||||
|
||||
SourceMode sourceModeForScope(CaptureScope scope) {
|
||||
SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow) {
|
||||
switch (scope) {
|
||||
case CaptureScope::Item: return SourceMode::SelectedItems;
|
||||
case CaptureScope::Item:
|
||||
return itemExtentIsWindow ? SourceMode::SelectedItems
|
||||
: SourceMode::SelectedTracks;
|
||||
case CaptureScope::Track: return SourceMode::SelectedTracks;
|
||||
}
|
||||
return SourceMode::SelectedItems; // unreachable for a valid enum; fail closed
|
||||
// Unreachable for a valid enum; fail closed to the time-bounded render, which
|
||||
// honors the requested bounds whatever the selection is.
|
||||
return SourceMode::SelectedTracks;
|
||||
}
|
||||
|
||||
bool isMultiTrackStemRender(SourceMode mode, int sourceTrackCount) {
|
||||
return mode == SourceMode::SelectedTracks && sourceTrackCount > 1;
|
||||
}
|
||||
|
||||
std::string multiTrackRefusalMessage(CaptureScope scope) {
|
||||
// Deliberately does not name realtime capture as a way out, though it is the one
|
||||
// action that sums correctly here: realtime is non-deterministic (hardware/performed
|
||||
// FX, no bit-identical-repeats guarantee), so pointing an offline refusal at it would
|
||||
// trade one invariant for another rather than just naming a substitute. A stated
|
||||
// choice, not an oversight.
|
||||
switch (scope) {
|
||||
case CaptureScope::Item:
|
||||
return "This range is narrower than the selected items, so it renders "
|
||||
"through their tracks -- and those items span more than one track, "
|
||||
"which this shape cannot land as a single file. Capture one track's "
|
||||
"items at a time, or make the range match the items' extent.";
|
||||
case CaptureScope::Track:
|
||||
return "A track capture renders the selected tracks through the master, "
|
||||
"and more than one track cannot land as a single file. Capture one "
|
||||
"track at a time, or route them into a folder/bus track and capture "
|
||||
"that (a folder's own output is its children summed).";
|
||||
}
|
||||
// Unreachable for a valid enum; a refusal with no way out is still better than a
|
||||
// silent one, so fail closed to the scope-agnostic half of the message.
|
||||
return "This selection spans more than one track, which cannot land as a single "
|
||||
"file. Capture one track at a time.";
|
||||
}
|
||||
|
||||
RangeSource inferRangeSource(bool hasRazorArea) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#pragma once
|
||||
// render_settings — the REAPER-free logic behind the capture action family:
|
||||
// sourceMode -> RENDER_SETTINGS bits, P_RAZOREDITS parsing + range union,
|
||||
// razor-else-time inference, the FX-scope bypass plan, and the capture-action
|
||||
// table main.cpp iterates. Bit MEANINGS below are transcribed verbatim from
|
||||
// razor-else-time inference, the FX-scope bypass plan, the capture-action
|
||||
// table main.cpp iterates, and the multi-track-stem refusal + its user-facing
|
||||
// message text. Bit MEANINGS below are transcribed verbatim from
|
||||
// reaper_plugin_functions.h; the CHOICE of which bits each mode sets is tested.
|
||||
|
||||
#include <string>
|
||||
@@ -105,9 +106,42 @@ enum class CaptureScope {
|
||||
Track,
|
||||
};
|
||||
|
||||
// The render source mode each scope drives. Item captures selected items, Track
|
||||
// captures selected tracks (via master).
|
||||
SourceMode sourceModeForScope(CaptureScope scope);
|
||||
// The render source mode each scope drives. Track scope always captures its
|
||||
// selected tracks (via master), time-bounded by RENDER_STARTPOS/ENDPOS.
|
||||
//
|
||||
// Item scope captures the selected items ONLY when `itemExtentIsWindow` — i.e.
|
||||
// when those items' own extent already prints the requested window (see
|
||||
// render_window::itemExtentPrintsWindow). REAPER's selected-items render source is
|
||||
// INFERRED to derive its bounds from the item extents, so a window strictly inside
|
||||
// (or wider than) a selected item cannot be expressed through it; that case renders
|
||||
// time-bounded through the items' own tracks. The inference is unverified — see
|
||||
// src/core/capture/CLAUDE.md §Gotchas for what it rests on.
|
||||
//
|
||||
// The FX SCOPE is unaffected by the swap (fxBypassPlanFor is keyed on CaptureScope,
|
||||
// not on the source mode, so an item capture still hears take/item FX only), but the
|
||||
// CONTENT reaching the render is not: the selected-tracks source prints everything
|
||||
// upstream of the track — its folder children and its receives — which the shell
|
||||
// transiently silences (shell/capture/render_isolation). An overlapping item on the
|
||||
// track ITSELF is deliberately not isolated; see src/shell/capture/CLAUDE.md.
|
||||
SourceMode sourceModeForScope(CaptureScope scope, bool itemExtentIsWindow);
|
||||
|
||||
// True for the one render shape that cannot land as a single capture: a selected-tracks
|
||||
// render covering more than one track — a ranged item capture whose items span several
|
||||
// tracks, or any multi-track track capture. That source is read as rendering one file
|
||||
// per selected track — the single-file bit is documented for item/razor sources only
|
||||
// (SDK header ~3041), which is the whole basis for the reading and is DAW-unverified.
|
||||
// If it holds, N tracks collapse N stems onto one literal render pattern and whichever
|
||||
// file survived would land as a successful capture carrying one track's audio. The
|
||||
// caller refuses instead.
|
||||
//
|
||||
// Scope is deliberately NOT a parameter: the exposure comes from the render SOURCE,
|
||||
// which both scopes reach.
|
||||
bool isMultiTrackStemRender(SourceMode mode, int sourceTrackCount);
|
||||
|
||||
// The refusal text for the shape above. Keyed on scope because only the way OUT differs:
|
||||
// an item capture can also widen its range to the items' own extent, which a track
|
||||
// capture has no analog for. Kept beside the predicate so the two read as siblings.
|
||||
std::string multiTrackRefusalMessage(CaptureScope scope);
|
||||
|
||||
// --- Range inference: razor-else-time (orthogonal to scope) -------------------
|
||||
//
|
||||
@@ -170,7 +204,7 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges);
|
||||
struct CaptureActionDef {
|
||||
const char* commandSuffix; // e.g. "CAPTURE_TRACK" — FOREVER-STABLE (composed w/ prefix)
|
||||
const char* descriptionPhrase; // e.g. "capture selected track(s)" — Actions-list phrase
|
||||
const char* baseName; // file-stem base for this capture
|
||||
const char* baseName; // file-stem FALLBACK; the source track normally names the capture
|
||||
CaptureScope scope; // FX scope (item / track)
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// render_window.cpp — see the header.
|
||||
|
||||
#include "core/capture/render_window.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
// Round-to-nearest, so a position that sits mid-frame maps to the frame a render
|
||||
// of it prints rather than to the frame below it.
|
||||
long long frameIndexAt(double seconds, int sampleRate) {
|
||||
return std::llround(seconds * static_cast<double>(sampleRate));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
long long frameCountFor(double startSeconds, double endSeconds, int sampleRate) {
|
||||
if (sampleRate <= 0) return 0;
|
||||
if (!(endSeconds > startSeconds)) return 0;
|
||||
const long long frames =
|
||||
frameIndexAt(endSeconds, sampleRate) - frameIndexAt(startSeconds, sampleRate);
|
||||
return frames > 0 ? frames : 0;
|
||||
}
|
||||
|
||||
bool itemExtentPrintsWindow(double reqStart, double reqEnd,
|
||||
double itemStart, double itemEnd,
|
||||
int sampleRate) {
|
||||
if (sampleRate <= 0)
|
||||
return reqStart == itemStart && reqEnd == itemEnd;
|
||||
return frameIndexAt(reqStart, sampleRate) == frameIndexAt(itemStart, sampleRate)
|
||||
&& frameIndexAt(reqEnd, sampleRate) == frameIndexAt(itemEnd, sampleRate);
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
// render_window — pure frame arithmetic for a capture's requested window: the
|
||||
// frame count a project-time range occupies, and whether a render whose bounds
|
||||
// come from the selected items' own extent already prints that window.
|
||||
// NO REAPER types; unit-tested by tests/test_render_window.cpp.
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// Frames the [startSeconds, endSeconds) window occupies at `sampleRate`. Both
|
||||
// edges are resolved to the NEAREST frame boundary and subtracted, so the answer
|
||||
// is a difference of frame indices rather than a rounded duration — two windows
|
||||
// of equal length at different offsets can legitimately differ by one frame.
|
||||
// Returns 0 for a non-positive rate or an empty/inverted window.
|
||||
//
|
||||
// The offline backend compares this against the rendered file's own frame count, so
|
||||
// exact-bounds failures surface as a refused capture rather than a wrong file. That
|
||||
// REAPER resolves the two edges the same way is UNVERIFIED — a DAW pass decides
|
||||
// whether the equality is exact or off by a frame.
|
||||
long long frameCountFor(double startSeconds, double endSeconds, int sampleRate);
|
||||
|
||||
// True when a render bounded by the selected items' own extent
|
||||
// [itemStart, itemEnd) already prints exactly the requested
|
||||
// [reqStart, reqEnd) window — the one case where REAPER's selected-items render
|
||||
// source is believed to need no correction (the bounds-override inference behind
|
||||
// that is unverified; src/core/capture/CLAUDE.md §Gotchas states what it rests on).
|
||||
// Compared at frame resolution, because a sub-frame difference prints the same
|
||||
// frames. An unknown rate (<= 0) falls back to exact equality, which can only send
|
||||
// a window to the time-bounded render, never widen one.
|
||||
bool itemExtentPrintsWindow(double reqStart, double reqEnd,
|
||||
double itemStart, double itemEnd,
|
||||
int sampleRate);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,28 @@
|
||||
// track_topology.cpp — see the header.
|
||||
|
||||
#include "core/capture/track_topology.h"
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
std::vector<int> directChildIndices(const std::vector<int>& folderDepths,
|
||||
int parentIndex) {
|
||||
std::vector<int> children;
|
||||
const int count = static_cast<int>(folderDepths.size());
|
||||
if (parentIndex < 0 || parentIndex >= count) return children;
|
||||
if (folderDepths[static_cast<std::size_t>(parentIndex)] != 1) return children;
|
||||
|
||||
// Depth relative to the parent: 1 immediately after it (inside its folder), and
|
||||
// 0 once the folder closes. Only tracks sitting at relative depth 1 are direct
|
||||
// children; a child that opens its own folder pushes the level to 2, which is
|
||||
// what excludes its descendants.
|
||||
int level = 1;
|
||||
for (int i = parentIndex + 1; i < count && level > 0; ++i) {
|
||||
if (level == 1) children.push_back(i);
|
||||
level += folderDepths[static_cast<std::size_t>(i)];
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
// track_topology — pure folder arithmetic over a project's track list: which tracks
|
||||
// are the DIRECT children of a folder parent, derived from the I_FOLDERDEPTH deltas
|
||||
// alone. NO REAPER types (the shell reads the deltas); unit-tested by
|
||||
// tests/test_track_topology.cpp.
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// Indices of `parentIndex`'s DIRECT children, given every track's I_FOLDERDEPTH in
|
||||
// track order. I_FOLDERDEPTH is a DELTA applied AFTER its own track (SDK header
|
||||
// ~2215: 0 = normal, 1 = opens a folder, -n = closes n folders), so the depth walk
|
||||
// below is the only way to recover the tree from the flat list.
|
||||
//
|
||||
// Empty when `parentIndex` is out of range or its track does not open a folder.
|
||||
// Grandchildren are deliberately excluded: their audio reaches the parent only
|
||||
// through the direct child that owns them, so a caller silencing each direct child's
|
||||
// send-to-parent silences the whole subtree. An unterminated folder (no closing
|
||||
// negative delta) treats every remaining track as inside it, matching REAPER.
|
||||
std::vector<int> directChildIndices(const std::vector<int>& folderDepths,
|
||||
int parentIndex);
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -257,6 +257,59 @@ std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
|
||||
return out;
|
||||
}
|
||||
|
||||
MonoCollapse collapseToMono(const std::vector<std::uint8_t>& bytes) {
|
||||
MonoCollapse out;
|
||||
|
||||
const WavLayout layout = parseWavLayout(bytes);
|
||||
if (!layout.valid || layout.channelCount < 2) return out;
|
||||
|
||||
const std::size_t frames = layout.frameCount();
|
||||
if (frames == 0) return out;
|
||||
|
||||
const std::size_t stride = layout.channelCount;
|
||||
const std::vector<AudioSample> pcm = extractFloatFrames(bytes, layout, 0, frames);
|
||||
if (pcm.size() != frames * stride) return out; // short read -> decline, never guess
|
||||
|
||||
// Bit patterns, not values: see the header. memcpy is the only defined float->bits
|
||||
// read, and it compiles to a register move.
|
||||
auto bitsOf = [](AudioSample s) {
|
||||
std::uint32_t bits = 0;
|
||||
std::memcpy(&bits, &s, 4u);
|
||||
return bits;
|
||||
};
|
||||
for (std::size_t f = 0; f < frames; ++f) {
|
||||
const std::uint32_t first = bitsOf(pcm[f * stride]);
|
||||
for (std::size_t c = 1; c < stride; ++c) {
|
||||
if (bitsOf(pcm[f * stride + c]) != first) return out;
|
||||
}
|
||||
}
|
||||
|
||||
// float -> double -> float round-trips exactly for every finite value and for
|
||||
// +-0/+-infinity (double represents every float bit pattern in those classes), so
|
||||
// channel 0 reaches the rebuilt file unaltered. The one hole: a signaling NaN is
|
||||
// quieted by the float->double promotion, so an identical-bit sNaN pair could
|
||||
// collapse to a different bit pattern than it started with. Not reachable from
|
||||
// REAPER-rendered audio, but the bit-identical predicate above admits NaN inputs,
|
||||
// so this rebuild is not exempt from the claim it makes.
|
||||
std::vector<double> mono(frames);
|
||||
for (std::size_t f = 0; f < frames; ++f)
|
||||
mono[f] = static_cast<double>(pcm[f * stride]);
|
||||
|
||||
out.collapsed = true;
|
||||
out.bytes = buildFloat32Wav(1, layout.sampleRate, frames, mono);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string monoCollapseSuffix(MonoCollapseOutcome outcome) {
|
||||
switch (outcome) {
|
||||
case MonoCollapseOutcome::Declined: return {};
|
||||
case MonoCollapseOutcome::Collapsed: return " (collapsed to mono)";
|
||||
case MonoCollapseOutcome::Failed:
|
||||
return " (mono collapse failed -- left as captured)";
|
||||
}
|
||||
return {}; // unreachable for a valid enum; claim nothing rather than a wrong outcome
|
||||
}
|
||||
|
||||
std::string hashBytes(const std::uint8_t* data, std::size_t len) {
|
||||
// FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity.
|
||||
std::uint64_t h = kFnvOffsetBasis;
|
||||
|
||||
@@ -90,6 +90,52 @@ std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
|
||||
std::size_t frameCount,
|
||||
const std::vector<double>& interleaved);
|
||||
|
||||
// --- Lossless mono collapse ---------------------------------------------------
|
||||
|
||||
// The outcome of the bit-identical mono collapse. `collapsed == false` means the
|
||||
// caller must leave the source file exactly as it is — it writes nothing.
|
||||
struct MonoCollapse {
|
||||
bool collapsed = false;
|
||||
std::vector<std::uint8_t> bytes; // the rebuilt 1-channel WAV; empty unless collapsed
|
||||
};
|
||||
|
||||
// Collapses a multi-channel float32 WAV to one channel when EVERY channel of EVERY
|
||||
// frame carries the identical float BIT PATTERN. Bit equality, never an epsilon and
|
||||
// never `==` on floats: +0.0/-0.0 and two NaNs with differing payloads are NOT
|
||||
// identical and are never folded. Frame count, sample rate and bit depth are
|
||||
// preserved — only the interleave stride changes — so the collapse cannot lose
|
||||
// information, and a lossy downmix (summing differing channels) is not something
|
||||
// this can express.
|
||||
//
|
||||
// Declines for: bytes that do not parse; a file already at one channel; a zero-frame
|
||||
// file (no frame of evidence to act on); any differing channel pair.
|
||||
//
|
||||
// The rebuild is a canonical minimal WAV, so non-audio chunks (a renderer's `bext`
|
||||
// timestamp, iXML, LIST) do not survive it. That much hashWavContent already skips —
|
||||
// but the collapse rewrites the `fmt ` body and the `data` payload too, which moves
|
||||
// the file's content identity; see this directory's CLAUDE.md for what that costs,
|
||||
// including the bext/source-position consequence beyond hashing.
|
||||
MonoCollapse collapseToMono(const std::vector<std::uint8_t>& bytes);
|
||||
|
||||
// How applying the collapse to a captured FILE ended. `Declined` is collapseToMono's own
|
||||
// "nothing to do"; `Failed` is a read that never happened or a warranted rewrite that did
|
||||
// not land. The capture is intact and correctly measured in every case — only the report
|
||||
// tells them apart, which is why the two must not share one value.
|
||||
enum class MonoCollapseOutcome {
|
||||
Declined,
|
||||
Collapsed,
|
||||
Failed,
|
||||
};
|
||||
|
||||
// The capture message's collapse suffix — empty for Declined, so a capture that had
|
||||
// nothing to collapse reads exactly as it did before the collapse existed. Shared by
|
||||
// both backends so one outcome cannot be reported two ways. NOT user-observable on its
|
||||
// own: CaptureResult::message on a successful capture is never printed by any caller, so
|
||||
// the Collapsed/Failed text this returns reaches no one today — the one observable
|
||||
// channel for a genuine Failed outcome is the backends' own reportCollapseFailure
|
||||
// console line.
|
||||
std::string monoCollapseSuffix(MonoCollapseOutcome outcome);
|
||||
|
||||
// --- Content identity (dedup hashes) -----------------------------------------
|
||||
|
||||
// Deterministic FNV-1a 64-bit content hash over `len` bytes, as 16-char lowercase
|
||||
|
||||
@@ -88,6 +88,11 @@ struct Sample {
|
||||
|
||||
double wetDry = 1.0; // 1.0 = fully wet, 0.0 = fully dry
|
||||
|
||||
// Channels in the file this entry names — equal to its `fmt ` count by
|
||||
// construction on every path that measures it, which is what makes the
|
||||
// instrument's mono/stereo-toggle default agree with the audio (the waveform
|
||||
// lane count reads the decoded file directly, not this field). 0 = unknown —
|
||||
// a pre-field entry, or a capture whose file could not be parsed to measure it.
|
||||
int channelCount = 0;
|
||||
int sampleRate = 0;
|
||||
|
||||
|
||||
+21
-12
@@ -76,14 +76,20 @@ L7 sub-pass, 2026-07-27):
|
||||
geometry modules; the kit's *draw* half is shell, its *geometry* half is pure,
|
||||
even where a WDL piece is reused. Look-and-feel work never touches capture,
|
||||
placement, or bank data ownership.
|
||||
- **L7 drag-gesture precedence is a pure decision helper.** The rule — leave
|
||||
client rect → OS drag-out; else drop on a tab/other bank → move/copy; else
|
||||
same-bank grid → reorder-to-slot (empty slot = place, occupied + no modifier =
|
||||
insert-before-and-shift, occupied + Alt = replace) — is "encoded in a pure
|
||||
decision helper (mirror `drag_out::decideGesture`)"; the shell only reads live
|
||||
pointer/focus/client-rect/modifier state and calls it, then maps the resolved
|
||||
gesture to a cursor via `SetCursor`. No cue or precedence logic belongs in the
|
||||
shell.
|
||||
- **Drag-gesture precedence is a pure decision helper, on both sides of the client
|
||||
rect.** Inside: drop on a tab/other bank → move/copy; else same-bank grid →
|
||||
reorder-to-slot (empty slot = place, occupied + no modifier =
|
||||
insert-before-and-shift, occupied + Alt = replace). Outside: `decideDropClass`
|
||||
resolves the surface under the cursor. The shell only reads live
|
||||
pointer/focus/client-rect/modifier state and calls these, then maps the resolved
|
||||
cue to a cursor via `SetCursor`. No cue or precedence logic belongs in the shell.
|
||||
- **The drag-out law is per-move and stateless.** The class is resolved from the
|
||||
current pointer on every move and again at the release point; nothing is latched
|
||||
between evaluations. That is what makes every transition reversible and what
|
||||
makes drag speed (WM_MOUSEMOVE coalescing) unable to change an outcome. Leaving
|
||||
REAPER entirely is the one irreversible transition, because the OS hand-off goes
|
||||
modal. A first-move class lock and a drag-lifetime "cannot hand off" latch both
|
||||
existed here and were removed — do not reintroduce either.
|
||||
|
||||
## Modules
|
||||
|
||||
@@ -91,16 +97,16 @@ L7 sub-pass, 2026-07-27):
|
||||
- `bank_grid` — REAPER-free grid layout, selection, keyboard-nav, and thumbnail-cache-key logic for the docked bank panel.
|
||||
- `tab_strip` — REAPER-free scrollable tab-strip layout + hit-test for the named-banks strip.
|
||||
- `prune_button` — pure layout/hit-test for the `bank_panel` footer Prune button.
|
||||
- `drag_out` — pure OS drag-out module: gesture-boundary decision and path-list assembly. The `InstrumentDrop` gesture signals that the shell should execute an instrument-drop rather than a file-copy drag.
|
||||
- `drag_out` — the pure drag-out gesture law plus path-list assembly. Owns the `ReaperSurface` vocabulary (OffReaper / TrackPanel / FxSurface / FxEmbed / Arrange / Other — `core/wire/instrument_drop` classifies REAPER's info token INTO it), `decideDropClass` (surface × single-vs-multi payload → Internal / InstrumentDrop / ArrangeInsert / Refuse / OsHandoff / None), and `cueForDropClass`. **No `DropClass` means "nothing happens"**: a surface with no defined outcome for the payload resolves to `Refuse`, which the shell shows as a cursor, so "no silent no-op release" is a property of the enumeration rather than of any call site.
|
||||
- `theme` — pure palette module: role→color mapping, REAPER-grey neutral ladder + the pastel accent system, the keyboard strip's spectral ramp, WCAG contrast-floor helpers + `compositeOver` (the effective color of a translucent fill, so alpha overlays are testable). Only the ramp's MID stop is its own constant; lo/hi are still aliases of `accent/primary`/`accent/tertiary`, so a categorical accent move CAN still reorder the ramp — `testSpectralRampLuminanceIsMonotonic` is the build-time catch, not the structure.
|
||||
- `component_geometry` — pure button/slider/list-row geometry + hover hit-test helpers.
|
||||
- `action_bar` — pure task-grouped action-bar layout/hit-test: clusters (Capture / Placement / Maintenance / Tagging / Switching).
|
||||
- `footer_bar` — pure footer layout/hit-test: `[Arrange|Design]` mode-toggle geometry, Tail button, and Prune placement.
|
||||
- `footer_bar` — pure footer layout/hit-test: `[Arrange|Design]` mode-toggle geometry, Tail button, and Prune placement, plus `modeSegmentEnabled` — the mode segment's live/dead predicate under the playback gate AND under whether the shell resolved a routable command id for it (both bools passed IN, so this stays REAPER-free).
|
||||
- `overflow_menu` — pure overflow-menu-button geometry/reserve/hit-test for the top-toolbar More (⋯) button.
|
||||
- `mode_enable` — pure opposite-mode enablement predicate: given the active mode, computes per-button live/disabled state for the four Item/Track × Arrange/Design tag buttons.
|
||||
- `tooltip` — pure tooltip placement + prefix-strip: strips the `ReaSampler:` display prefix from the registered action phrase; width clamped to the client rect.
|
||||
- `card_drag` — pure drag-gesture precedence + slot hit-test: leave-client → OS drag-out; other-bank → move/copy; same-bank → reorder / Alt-over-occupied → replace.
|
||||
- `card_meta` — pure card-metadata formatters: bars.beats.subdivisions and seconds.milliseconds; blank when the sample is unstamped.
|
||||
- `card_meta` — pure card-metadata formatters: bars.beats.subdivisions and seconds.milliseconds; blank when the sample is unstamped. Also `cardNameStrip` + the two strip constants — the card's name line sits across the TOP of the cell, drawn over the waveform exactly as the length read-out is over it at the bottom, and is suppressed entirely on a cell with no room for both strips plus a waveform band.
|
||||
- `stroke_aa` — analytic antialiased thick-stroke COVERAGE (the shell blends it): `StrokeCanvas`, a reusable mask holding distance-to-polyline coverage MAX-accumulated across segments, plus `strokePolyline` / `strokeBounds` / `appendArc` / `rasterRowOffset` (the row-major offset
|
||||
math for a possibly bottom-up raster, pulled out of the shell's LICE blend so its flipped
|
||||
branch is pinned by a host-free test). An arc is just a flattened polyline, so ONE path serves the knob arcs, the inner dial, the envelope polyline and both spline traces. Coverage is `clamp(halfWidth + 0.5 - distance, 0, 1)`, which makes perpendicular weight exactly `2·halfWidth` at every angle. **The guaranteed-opaque-core threshold is width ≥ 2 px, not any width above 1 px**: opacity needs `distance <= halfWidth - 0.5`, and the worst-case distance from a pixel centre to the centreline is 0.5, so a 1 px stroke (`halfWidth = 0.5`) has zero slack — its peak alpha modulates with the stroke's exact alignment to the pixel grid instead of pinning to 255 (Daniel's ruling, 2026-08-01: every stroker-drawn width on the editor is now >= 2 px for this reason — `testSubOpaqueCoreAtOnePixelWidth` in `tests/test_stroke_aa.cpp` still pins the 1 px case as a property of the stroker, independent of whether any surface ships at that width). Long segments are subdivided before rasterizing — EXACT, not an approximation (min-distance to a partition of a segment is min-distance to the whole), purely to keep each piece's bounding box tight, since one long diagonal's box has area O(len²).
|
||||
@@ -119,9 +125,12 @@ L7 sub-pass, 2026-07-27):
|
||||
worked example. A prior revision wrote the threshold ~25% low and let 15px
|
||||
semibold clear a floor it was not entitled to.
|
||||
- `card_drag`'s precedence order must stay a pure decision helper mirroring
|
||||
`drag_out::decideGesture` — don't let a shell reimplement gesture precedence
|
||||
`drag_out::decideDropClass` — don't let a shell reimplement gesture precedence
|
||||
ad hoc; the cursor-cue mapping in the shell must stay a thin lookup over the
|
||||
pure result.
|
||||
- `drag_out` is deliberately **dependency-free**, and `instrument_drop` links it
|
||||
rather than the reverse. Inverting that edge would drag the instrument's state
|
||||
serializer into `card_drag` and into every `drag_out` consumer.
|
||||
- `rect`'s prior role names survive only as `using` aliases at their old call
|
||||
sites — changing `rect.h` itself ripples across every directory that aliases
|
||||
it (e.g. `editor_geometry::Rect`); check all alias sites, not just this one.
|
||||
|
||||
@@ -7,6 +7,18 @@
|
||||
|
||||
namespace reasampler::ui {
|
||||
|
||||
Rect cardNameStrip(const Rect& cell) {
|
||||
// Both strips plus a waveform band at least as tall as one strip; below that the card
|
||||
// is a text block, not a thumbnail.
|
||||
const int minHeight = 3 * kCardStripHeight;
|
||||
if (cell.width <= 2 * kCardStripPad || cell.height < minHeight) return Rect{};
|
||||
// Inset by 1px from the top edge so the name never sits on the focused-cell inner ring
|
||||
// (drawn at rect.y+1, panel_render.cpp) — the selection border itself is at rect.y and is
|
||||
// clear regardless.
|
||||
return Rect{cell.x + kCardStripPad, cell.y + 1,
|
||||
cell.width - 2 * kCardStripPad, kCardStripHeight};
|
||||
}
|
||||
|
||||
std::string formatBarsBeats(const MusicalLength& m) {
|
||||
if (m.tempoBpm <= 0.0 || m.timeSigNum <= 0 || m.timeSigDenom <= 0) return {};
|
||||
|
||||
|
||||
@@ -5,8 +5,25 @@
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "core/ui/rect.h"
|
||||
|
||||
namespace reasampler::ui {
|
||||
|
||||
// Both card overlay strips — the name line across the top, the length read-out along the
|
||||
// bottom — are this tall, with this much horizontal inset.
|
||||
inline constexpr int kCardStripHeight = 12;
|
||||
inline constexpr int kCardStripPad = 3;
|
||||
|
||||
// The strip the card's name line occupies: across the top of the cell, drawn OVER the
|
||||
// waveform exactly as the length read-out is drawn over it at the bottom, rather than a
|
||||
// reserved non-drawing band — kCardStripHeight (12px) is a small slice of the shipping 84px
|
||||
// cell, and the overlay matches the bottom read-out's existing convention rather than
|
||||
// introducing a second layout rule. The draw site scrims behind the text so it stays legible
|
||||
// over the waveform's accent fill (`kCardNameScrimAlpha`, `theme.h`). Empty when the cell has
|
||||
// no room for both strips plus a waveform worth looking at — the caller draws nothing rather
|
||||
// than burying the card under text.
|
||||
Rect cardNameStrip(const Rect& cell);
|
||||
|
||||
// Musical length inputs, taken straight off a Sample's capture-time stamp. tempoBpm 0 = unknown;
|
||||
// timeSigNum/Denom 0 = unstamped.
|
||||
struct MusicalLength {
|
||||
|
||||
@@ -15,14 +15,49 @@ bool insideClient(int px, int py, const PanelClientRect& c) {
|
||||
|
||||
} // namespace
|
||||
|
||||
DragGesture decideGesture(int px, int py, const PanelClientRect& client,
|
||||
const DragState& state) {
|
||||
if (!state.dragging || !state.hasArmedSamples) return DragGesture::None;
|
||||
if (insideClient(px, py, client)) return DragGesture::Internal;
|
||||
// Outside the client: a single-capture drag still over REAPER's own UI is an instrument
|
||||
// drop; anything else (multi-capture, or pointer off REAPER entirely) is an OS drag-out.
|
||||
if (state.singleCapture && state.overReaperUi) return DragGesture::InstrumentDrop;
|
||||
return DragGesture::OsDrag;
|
||||
bool needsSurfaceProbe(int px, int py, const PanelClientRect& client) {
|
||||
return !insideClient(px, py, client);
|
||||
}
|
||||
|
||||
DropClass decideDropClass(int px, int py, const PanelClientRect& client,
|
||||
const DropContext& ctx) {
|
||||
if (!ctx.drag.dragging || !ctx.drag.hasArmedSamples) return DropClass::None;
|
||||
if (insideClient(px, py, client)) return DropClass::Internal;
|
||||
|
||||
switch (ctx.surface) {
|
||||
case ReaperSurface::OffReaper:
|
||||
return DropClass::OsHandoff;
|
||||
|
||||
case ReaperSurface::TrackPanel:
|
||||
case ReaperSurface::FxSurface:
|
||||
// One instance holds one capture, so a multi payload names no instrument to build —
|
||||
// it refuses with a cue rather than falling through to some other surface's outcome.
|
||||
return (ctx.singlePayload && ctx.haveTrack) ? DropClass::InstrumentDrop
|
||||
: DropClass::Refuse;
|
||||
|
||||
case ReaperSurface::Arrange:
|
||||
// GetThingFromPoint may return a null track with a valid info string (the SDK says
|
||||
// so): over the arrange that is the region below the last track, which names no lane
|
||||
// to place on. Refuse rather than guess a track.
|
||||
return ctx.haveTrack ? DropClass::ArrangeInsert : DropClass::Refuse;
|
||||
|
||||
case ReaperSurface::FxEmbed:
|
||||
case ReaperSurface::Other:
|
||||
return DropClass::Refuse;
|
||||
}
|
||||
return DropClass::Refuse; // an unclassifiable surface still refuses visibly, never silently
|
||||
}
|
||||
|
||||
DropCue cueForDropClass(DropClass cls) {
|
||||
switch (cls) {
|
||||
case DropClass::Internal: return DropCue::Internal;
|
||||
case DropClass::InstrumentDrop: return DropCue::Instrument;
|
||||
case DropClass::ArrangeInsert: return DropCue::ArrangeInsert;
|
||||
case DropClass::Refuse: return DropCue::Refuse;
|
||||
case DropClass::OsHandoff: return DropCue::OsOwned;
|
||||
case DropClass::None: return DropCue::None;
|
||||
}
|
||||
return DropCue::None;
|
||||
}
|
||||
|
||||
PathList assemblePathList(const std::vector<ResolvedSample>& resolved) {
|
||||
|
||||
+59
-24
@@ -1,15 +1,15 @@
|
||||
#pragma once
|
||||
#include "core/ui/rect.h"
|
||||
// drag_out — decision logic behind the bank_panel's native OS drag-out. OLE/SWELL initiation and
|
||||
// the panel gesture hook stay in the shell (drag_out_win.* + shell/panel/panel_drag.cpp).
|
||||
// drag_out — the pure drag-out gesture law plus the OS hand-off's path-list assembly. REAPER
|
||||
// hit-testing, cursor setting, and outcome execution stay in the shell (panel_drag.cpp +
|
||||
// instrument_drop_win / arrange_drop_win / drag_out_win).
|
||||
//
|
||||
// Gesture boundary: the panel's own internal drag (press a selected cell, drop onto a pool/bank
|
||||
// region or tab) lives entirely inside the panel client rect. The moment the pointer LEAVES that
|
||||
// rect while a drag is armed with samples, the gesture becomes OS-bound — dragged out to another
|
||||
// window/Explorer/DAW. A single-capture drag that leaves the rect but is still over REAPER's own
|
||||
// UI is instead an InstrumentDrop (heading for a track's FX button); do not regress this boundary.
|
||||
// THE LAW: the class is resolved from what is under the cursor on EVERY move; every transition
|
||||
// is reversible until release or until the pointer leaves REAPER entirely; the OS hand-off is
|
||||
// reserved for leaving REAPER, and every REAPER-internal target executes natively on release.
|
||||
// Do not reintroduce a first-move class lock or a drag-lifetime "blocked" latch.
|
||||
//
|
||||
// Path-list assembly: turns armed sample ids into the absolute path list the OS drop carries
|
||||
// Path-list assembly: turns armed sample ids into the absolute path list an OS drop carries
|
||||
// (Windows CF_HDROP / macOS file-list pasteboard) — set algebra only; the shell resolves each id
|
||||
// to its on-disk bank file. No temp files; copy-only is enforced at the OS layer (drag_out_win).
|
||||
|
||||
@@ -18,34 +18,69 @@
|
||||
|
||||
namespace reasampler::ui {
|
||||
|
||||
// --- Gesture boundary ---------------------------------------------------------
|
||||
// --- Gesture law --------------------------------------------------------------
|
||||
|
||||
// The panel's client rect, own client coords, top-left origin. Half-open: [x, x+width) x
|
||||
// [y, y+height).
|
||||
using PanelClientRect = Rect;
|
||||
|
||||
// Live drag state reduced to what the boundary decision needs. Pre-threshold "armed but not yet
|
||||
// dragging" is not a drag for this decision.
|
||||
// Live drag state reduced to what every gesture decision needs. Pre-threshold "armed but not
|
||||
// yet dragging" is not a drag. Shared with card_drag's in-grid precedence decision.
|
||||
struct DragState {
|
||||
bool dragging = false; // threshold crossed; a drag is in progress
|
||||
bool hasArmedSamples = false; // payload holds >= 1 sample id
|
||||
bool singleCapture = false; // payload holds EXACTLY one sample (arms InstrumentDrop)
|
||||
bool overReaperUi = false; // pointer is over REAPER's own UI (shell-supplied)
|
||||
};
|
||||
|
||||
// What the shell should do with the drag given the current pointer position.
|
||||
enum class DragGesture {
|
||||
None, // no drag under way, or an empty payload
|
||||
Internal, // dragging inside the panel — bank-to-bank move/copy
|
||||
InstrumentDrop, // single-capture drag left the panel but is over REAPER's UI — shell
|
||||
// hover-tracks the TCP FX button; on release adds a preloaded instance
|
||||
OsDrag, // dragging with samples, pointer left REAPER entirely — hand to the OS
|
||||
// What REAPER reports under the pointer, reduced to the surfaces the law distinguishes.
|
||||
// Produced from GetThingFromPoint's info token by wire::classifyReaperSurface.
|
||||
enum class ReaperSurface {
|
||||
OffReaper, // not over REAPER at all — the one irreversible exit
|
||||
TrackPanel, // TCP/MCP, ANY sub-element: the WHOLE panel is the instrument hotspot
|
||||
FxSurface, // fx_* — the FX chain and floating-FX windows
|
||||
FxEmbed, // tcp.fxembed / mcp.fxembed — an instance already draws there
|
||||
Arrange, // the timeline
|
||||
Other, // ruler, transport, spacers, docker chrome, unknown future tokens
|
||||
Count, // sentinel, NOT a real surface — tests/test_drag_out.cpp's kAllSurfaces is
|
||||
// pinned against this via static_assert so a 7th surface can't silently skip
|
||||
// the exhaustiveness matrix
|
||||
};
|
||||
|
||||
// Decides the gesture for a drag at pointer (px, py) over `client`, given `state`. Position-only
|
||||
// + state-only (no hidden state), so re-entry back inside always returns Internal.
|
||||
DragGesture decideGesture(int px, int py, const PanelClientRect& client,
|
||||
const DragState& state);
|
||||
// The resolved target class. Every value is a DEFINED outcome the shell executes or visibly
|
||||
// refuses — there is deliberately no "nothing happens" member, which is what makes "no silent
|
||||
// no-op release" a property of the enumeration rather than of any one call site.
|
||||
enum class DropClass {
|
||||
None, // no drag under way, or an empty payload — nothing to resolve
|
||||
Internal, // inside the panel client — the bank-to-bank drag, unchanged
|
||||
InstrumentDrop, // a track's panel or FX surface — add ReaSampler 9000 preloaded
|
||||
ArrangeInsert, // the timeline — place items at the pointer's track and time
|
||||
Refuse, // a REAPER surface with no defined outcome for this payload — cue it
|
||||
OsHandoff, // the pointer left REAPER — hand the file list to the OS
|
||||
};
|
||||
|
||||
// Everything the law reads. Nothing here is remembered between evaluations: two evaluations at
|
||||
// the same point with the same payload resolve identically, whatever happened in between.
|
||||
struct DropContext {
|
||||
DragState drag;
|
||||
bool singlePayload = false; // payload holds EXACTLY one capture (arms InstrumentDrop)
|
||||
ReaperSurface surface = ReaperSurface::OffReaper; // only read outside the client rect
|
||||
bool haveTrack = false; // GetThingFromPoint returned a non-null MediaTrack*
|
||||
};
|
||||
|
||||
// Resolves the class for a drag at pointer (px, py) over `client`. Position-and-context only,
|
||||
// so re-entry into the client always returns Internal and a surface transition always reverses.
|
||||
DropClass decideDropClass(int px, int py, const PanelClientRect& client, const DropContext& ctx);
|
||||
|
||||
// True outside the panel client rect — the same half-open test decideDropClass gates the SDK
|
||||
// hit-test on. Exported so the shell reads this one pure predicate instead of reimplementing the
|
||||
// inside-client math inline (core/ui/CLAUDE.md forbids hit-test geometry living in shell code).
|
||||
bool needsSurfaceProbe(int px, int py, const PanelClientRect& client);
|
||||
|
||||
// The pointer cue for a class, so "will this work" is visible BEFORE release. Internal defers
|
||||
// to card_drag's own cue; OsOwned means the OS drag loop draws the cursor and we must not fight
|
||||
// it.
|
||||
enum class DropCue { None, Internal, Instrument, ArrangeInsert, Refuse, OsOwned };
|
||||
|
||||
DropCue cueForDropClass(DropClass cls);
|
||||
|
||||
// --- Path-list assembly -------------------------------------------------------
|
||||
|
||||
|
||||
@@ -64,4 +64,8 @@ FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout) {
|
||||
return FooterHit::None;
|
||||
}
|
||||
|
||||
bool modeSegmentEnabled(bool isActiveSegment, bool transportRunning, bool routable) {
|
||||
return isActiveSegment || (!transportRunning && routable);
|
||||
}
|
||||
|
||||
} // namespace reasampler::ui
|
||||
|
||||
@@ -61,4 +61,15 @@ FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec&
|
||||
// a passive readout, never a control). Half-open bounds match computeFooterBar.
|
||||
FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout);
|
||||
|
||||
// Whether one [Arrange|Design] segment is live. A mode switch is refused while the project
|
||||
// plays or records (shell/view::transportBlocksModeSwitch), so an unreachable segment must READ
|
||||
// dead before the click, not merely refuse on it. The ACTIVE segment stays live regardless: it
|
||||
// fires a reapply, which is never gated, and dimming the mode you are already in would read as
|
||||
// "this mode is unavailable" rather than "you cannot leave it right now". `routable` is whether
|
||||
// the shell resolved a live command id for this segment (the model is N-mode, the UI ships two
|
||||
// seeded ids — a third registered mode has no action to route through, so its segment must read
|
||||
// dead rather than paint live and silently no-op on click). Passed IN so this predicate — and
|
||||
// `core/ui` — stays REAPER-free, exactly as `transportRunning` is.
|
||||
bool modeSegmentEnabled(bool isActiveSegment, bool transportRunning, bool routable);
|
||||
|
||||
} // namespace reasampler::ui
|
||||
|
||||
@@ -97,6 +97,13 @@ KitColor compositeOver(const KitColor& over, const KitColor& under, double alpha
|
||||
// test composes the same value the shell draws with (see compositeOver).
|
||||
inline constexpr double kLoopSpanFillAlpha = 0.20;
|
||||
|
||||
// The card name strip's scrim: bg/base composited at this alpha UNDER the name text, so
|
||||
// text/primary stays readable when a loud capture's waveform peak (accent/primary) reaches
|
||||
// into the strip. Named HERE, same reason as kLoopSpanFillAlpha above — test_theme.cpp composes
|
||||
// this exact value against the strip's worst-case background (accent/primary) to pin the 4.5:1
|
||||
// body floor Font::Micro answers to.
|
||||
inline constexpr double kCardNameScrimAlpha = 0.75;
|
||||
|
||||
// Relative luminance per WCAG 2.1 (sRGB linearization + 0.2126/0.7152/0.0722 weighting). Alpha
|
||||
// is ignored — a translucent overlay's effective color is the caller's to compose first
|
||||
// (compositeOver).
|
||||
|
||||
+15
-7
@@ -3,12 +3,13 @@
|
||||
## Scope
|
||||
|
||||
Pure, REAPER-free Design View model: mode/track membership, folder-derived
|
||||
visibility, snapshot-based park/restore planning, new-content (GUID) detection,
|
||||
and the managed/manual lane-identity convention that underlies per-item mode
|
||||
separation (fixed lanes). Does **not** include: the actual DAW-side flag
|
||||
application (hide, CPU-park, per-FX offline, restore via `B_SHOWINTCP` /
|
||||
`B_SHOWINMIXER` / `B_MAINSEND` / `I_FXEN`) or the never-touch-master/mute/solo
|
||||
enforcement — those live in `shell/view`.
|
||||
visibility, snapshot-based park/restore planning, the per-mode solo cache and its
|
||||
replay plan, new-content (GUID) detection, and the managed/manual lane-identity
|
||||
convention that underlies per-item mode separation (fixed lanes). Does **not**
|
||||
include: the actual DAW-side flag application (hide, CPU-park, per-FX offline,
|
||||
restore via `B_SHOWINTCP` / `B_SHOWINMIXER` / `B_MAINSEND` / `I_FXEN`, solo
|
||||
cache/clear/replay via `I_SOLO`) or the never-touch-master/mute enforcement —
|
||||
those live in `shell/view`.
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -33,6 +34,12 @@ settled 2026-07-23):
|
||||
from the snapshot, never to a hardcoded default. Round-trip (snapshot → park →
|
||||
restore) returns every driven flag to its captured value — this is the
|
||||
phase's trust anchor, the analog of the capture null test.
|
||||
- **Disjoint solo surfaces, cached not destroyed.** Solo is per mode: a real
|
||||
switch banks the outgoing mode's raw `I_SOLO` values, clears them, and replays
|
||||
the incoming mode's verbatim. Same snapshot sense of non-destructive as the
|
||||
bullet above — the tool never *loses* the user's solo, it parks it with the mode
|
||||
it belongs to. `B_MUTE` and the master track stay untouched absolutely. A
|
||||
reapply touches solo not at all.
|
||||
- **GUID-keyed, reorder-safe.** Membership keys on track GUID (`GetTrackGUID`),
|
||||
never track index; tolerates unknown/stale GUIDs (pruned on reconcile via
|
||||
`ViewModeModel::reconcile(liveGuids)`).
|
||||
@@ -84,7 +91,8 @@ settled 2026-07-23):
|
||||
|
||||
## Modules
|
||||
|
||||
- `view_mode_model` — Design View mode system: mode registry, GUID-keyed membership, folder-tree-aware visibility derivation, snapshot-based park/restore planner, JSON round-trip.
|
||||
- `view_mode_model` — Design View mode system: mode registry, GUID-keyed membership, folder-tree-aware visibility derivation, snapshot-based park/restore planner, the per-mode `SoloCache` it owns, JSON round-trip.
|
||||
- `solo_cache` — the per-mode solo surface: `SoloCache` (mode id → GUID → raw `I_SOLO`), the soloed-subset filter, and `planSoloRestore`, whose two drop rules (dead GUID, not visible in the incoming mode) and their reasoning live in its header.
|
||||
- `view_tree` — pure `I_FOLDERDEPTH`→FolderTree helper for the Design View shell.
|
||||
- `mode_switch` — REAPER-free segment layout + hit-test for the bank_panel's Design View mode switch.
|
||||
- `guid_diff` — the pure, REAPER-free core of the D2 Wave-2 new-content detection: `newGuids(previous, current)` computes the GUIDs present in `current` but absent from `previous` (empty GUIDs ignored); `GuidBaseline` tracks the live GUID set across polls for one project, implementing the first-poll-after-open guard (the first `observe()` after construction/`reset()` records a baseline and reports nothing new, so pre-existing content is never mass-tagged) and re-arms via `reset()` on a detected project switch so detection never diffs across two unrelated projects.
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
reasampler_pure_library(lane_keys SOURCES lane_keys.cpp)
|
||||
reasampler_test(lane_keys LINK lane_keys)
|
||||
|
||||
# lane_keys is PUBLIC: the lane-minting plan names managed lanes through the one durable-key
|
||||
# convention, so every consumer has to resolve that symbol too.
|
||||
reasampler_pure_library(solo_cache SOURCES solo_cache.cpp)
|
||||
reasampler_test(solo_cache LINK solo_cache)
|
||||
|
||||
# lane_keys and solo_cache are PUBLIC: the lane-minting plan names managed lanes through the
|
||||
# one durable-key convention, and ViewModeModel exposes the SoloCache by reference, so every
|
||||
# consumer has to resolve those symbols too.
|
||||
reasampler_pure_library(view_mode_model
|
||||
SOURCES view_mode_model.cpp
|
||||
LINK PRIVATE json PUBLIC lane_keys)
|
||||
LINK PRIVATE json PUBLIC lane_keys solo_cache)
|
||||
reasampler_test(view_mode_model LINK view_mode_model)
|
||||
|
||||
reasampler_pure_library(view_tree SOURCES view_tree.cpp LINK PUBLIC view_mode_model)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// solo_cache — pure implementation. See solo_cache.h.
|
||||
|
||||
#include "core/view/solo_cache.h"
|
||||
|
||||
namespace reasampler::view {
|
||||
|
||||
std::map<std::string, int> soloedTracks(const std::vector<TrackSolo>& live) {
|
||||
std::map<std::string, int> soloed;
|
||||
for (const TrackSolo& t : live) {
|
||||
if (t.guid.empty() || t.solo == 0) continue;
|
||||
soloed.emplace(t.guid, t.solo); // first reading wins if a GUID repeats
|
||||
}
|
||||
return soloed;
|
||||
}
|
||||
|
||||
bool SoloCache::store(const std::string& modeId, const std::map<std::string, int>& soloed) {
|
||||
if (modeId.empty()) return false;
|
||||
if (soloed.empty()) {
|
||||
byMode_.erase(modeId);
|
||||
return true;
|
||||
}
|
||||
byMode_[modeId] = soloed;
|
||||
return true;
|
||||
}
|
||||
|
||||
const std::map<std::string, int>* SoloCache::query(const std::string& modeId) const {
|
||||
auto it = byMode_.find(modeId);
|
||||
return it == byMode_.end() ? nullptr : &it->second;
|
||||
}
|
||||
|
||||
bool SoloCache::clear(const std::string& modeId) {
|
||||
return byMode_.erase(modeId) > 0;
|
||||
}
|
||||
|
||||
std::size_t SoloCache::reconcile(const std::set<std::string>& liveGuids) {
|
||||
std::size_t removed = 0;
|
||||
for (auto mode = byMode_.begin(); mode != byMode_.end();) {
|
||||
for (auto entry = mode->second.begin(); entry != mode->second.end();) {
|
||||
if (liveGuids.count(entry->first) == 0) {
|
||||
entry = mode->second.erase(entry);
|
||||
++removed;
|
||||
} else {
|
||||
++entry;
|
||||
}
|
||||
}
|
||||
// A mode emptied by pruning must not survive as an empty record — same
|
||||
// reason store() drops one (see header).
|
||||
if (mode->second.empty()) mode = byMode_.erase(mode);
|
||||
else ++mode;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
std::vector<SoloOp> planSoloRestore(const std::map<std::string, int>& cached,
|
||||
const std::set<std::string>& liveGuids,
|
||||
const std::set<std::string>& visibleGuids) {
|
||||
std::vector<SoloOp> ops;
|
||||
for (const auto& [guid, value] : cached) {
|
||||
if (liveGuids.count(guid) == 0) continue;
|
||||
if (visibleGuids.count(guid) == 0) continue;
|
||||
ops.push_back(SoloOp{guid, value});
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
} // namespace reasampler::view
|
||||
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
// Per-mode solo surface: the cache of raw I_SOLO values a real mode switch banks on
|
||||
// the way out and replays on the way back, plus the two decisions over it. Pure —
|
||||
// the GetMediaTrackInfo_Value/SetMediaTrackInfo_Value pair is shell/view/view_solo.
|
||||
// Values are the RAW I_SOLO int, never collapsed to a bool: the SDK's domain is
|
||||
// 0=off, 1=solo, 2=solo-in-place, 5=safe solo, 6=safe solo-in-place, and all four
|
||||
// non-zero variants must survive the round trip.
|
||||
|
||||
#include <cstddef>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler::view {
|
||||
|
||||
// One live (track GUID, I_SOLO) reading from the shell's enumeration.
|
||||
struct TrackSolo {
|
||||
std::string guid;
|
||||
int solo = 0;
|
||||
};
|
||||
|
||||
// One I_SOLO write the shell must apply.
|
||||
struct SoloOp {
|
||||
std::string guid;
|
||||
int value = 0;
|
||||
|
||||
bool operator==(const SoloOp& o) const { return guid == o.guid && value == o.value; }
|
||||
};
|
||||
|
||||
// The soloed subset of a live enumeration. Zero is the resting value — a track at
|
||||
// zero is neither cached (nothing to replay) nor cleared (nothing to undo), so a
|
||||
// project with no solo anywhere produces no cache entry and no project write at
|
||||
// all. Empty GUIDs are dropped: they can never resolve back to a track.
|
||||
std::map<std::string, int> soloedTracks(const std::vector<TrackSolo>& live);
|
||||
|
||||
// mode id -> (track GUID -> raw I_SOLO). Lifecycle mirrors the park/restore
|
||||
// snapshots: stored on the way out of a mode, consumed on the way back in, pruned
|
||||
// when a GUID stops existing.
|
||||
class SoloCache {
|
||||
public:
|
||||
// Replaces `modeId`'s entry. An EMPTY set removes it rather than storing an
|
||||
// empty record — otherwise a serialized cache would parse back to a model that
|
||||
// differs from its source, breaking the model's round-trip contract.
|
||||
bool store(const std::string& modeId, const std::map<std::string, int>& soloed);
|
||||
|
||||
const std::map<std::string, int>* query(const std::string& modeId) const;
|
||||
|
||||
bool clear(const std::string& modeId);
|
||||
|
||||
const std::map<std::string, std::map<std::string, int>>& all() const { return byMode_; }
|
||||
|
||||
bool empty() const { return byMode_.empty(); }
|
||||
|
||||
// Drops every cached GUID absent from `liveGuids`, and any mode left empty.
|
||||
// Returns the number of GUID entries removed.
|
||||
//
|
||||
// Pruned like the snapshots and unlike membership: a cached solo is a captured
|
||||
// prior value awaiting replay onto one specific track, so a stale entry
|
||||
// surviving a delete would replay onto whatever track later reuses that GUID —
|
||||
// soloing a track the user never soloed and silencing the rest of the mix.
|
||||
// The cost is the mirror case: undoing a track delete restores the GUID but not
|
||||
// its cached solo. One lost solo the user can see and re-click beats an
|
||||
// inexplicable mix-wide mute.
|
||||
std::size_t reconcile(const std::set<std::string>& liveGuids);
|
||||
|
||||
bool operator==(const SoloCache& o) const { return byMode_ == o.byMode_; }
|
||||
|
||||
private:
|
||||
std::map<std::string, std::map<std::string, int>> byMode_;
|
||||
};
|
||||
|
||||
// The incoming mode's restore writes, in GUID order. Two kinds of entry are dropped
|
||||
// rather than written:
|
||||
// * a GUID absent from `liveGuids` — the track is gone (same prune rule as above);
|
||||
// * a GUID absent from `visibleGuids` — not visible in the incoming mode, whether
|
||||
// because the leaf is parked or because it is a folder parent that is itself
|
||||
// derived-invisible there. Either way the track is hidden and (for a parked
|
||||
// leaf) carries B_MAINSEND=0, so soloing it would silence the whole mix while
|
||||
// contributing nothing audible, and the user would have no visible control to
|
||||
// undo it. A show-both leaf is a member of every mode's visible set, so it is
|
||||
// never dropped by this rule.
|
||||
// The caller consumes the whole mode entry regardless (clear-on-restore), so a
|
||||
// dropped entry does not linger as zombie state waiting on a track that may never
|
||||
// become visible again.
|
||||
std::vector<SoloOp> planSoloRestore(const std::map<std::string, int>& cached,
|
||||
const std::set<std::string>& liveGuids,
|
||||
const std::set<std::string>& visibleGuids);
|
||||
|
||||
} // namespace reasampler::view
|
||||
@@ -247,6 +247,8 @@ const TrackSnapshot* ViewModeModel::snapshot(const std::string& guid) const {
|
||||
|
||||
std::size_t ViewModeModel::reconcile(const std::set<std::string>& liveGuids) {
|
||||
// See header: snapshots are pruned, membership is not (undo-delete rationale).
|
||||
soloCache_.reconcile(liveGuids);
|
||||
|
||||
std::size_t removed = 0;
|
||||
for (auto it = snapshots_.begin(); it != snapshots_.end();) {
|
||||
if (liveGuids.count(it->first) == 0) {
|
||||
@@ -347,7 +349,8 @@ std::set<LaneRef> ViewModeModel::lanesTouchedByToggle() const {
|
||||
|
||||
bool ViewModeModel::operator==(const ViewModeModel& o) const {
|
||||
return modes_ == o.modes_ && membership_ == o.membership_ && lanes_ == o.lanes_ &&
|
||||
activeModeId_ == o.activeModeId_ && snapshots_ == o.snapshots_;
|
||||
activeModeId_ == o.activeModeId_ && snapshots_ == o.snapshots_ &&
|
||||
soloCache_ == o.soloCache_;
|
||||
}
|
||||
|
||||
namespace {
|
||||
@@ -443,6 +446,31 @@ std::string ViewModeModel::serialize() const {
|
||||
}
|
||||
}
|
||||
out += ']';
|
||||
|
||||
// soloCache: array of { mode, tracks: [ { guid, solo } ] }
|
||||
root.keyBegin("soloCache");
|
||||
out += '[';
|
||||
{
|
||||
bool firstMode = true;
|
||||
for (const auto& [modeId, byGuid] : soloCache_.all()) {
|
||||
if (!firstMode) out += ',';
|
||||
firstMode = false;
|
||||
ObjWriter m(out);
|
||||
m.keyStr("mode", modeId);
|
||||
m.keyBegin("tracks");
|
||||
out += '[';
|
||||
bool firstTrack = true;
|
||||
for (const auto& [guid, solo] : byGuid) {
|
||||
if (!firstTrack) out += ',';
|
||||
firstTrack = false;
|
||||
ObjWriter e(out);
|
||||
e.keyStr("guid", guid);
|
||||
e.keyRaw("solo", intToStr(solo));
|
||||
}
|
||||
out += ']';
|
||||
}
|
||||
}
|
||||
out += ']';
|
||||
} // root closes here (NRVO + deferred close, mirrors bank_model)
|
||||
return out;
|
||||
}
|
||||
@@ -569,6 +597,59 @@ bool parseLanes(json::Reader& r, LaneOwnershipIndex& idx) {
|
||||
return r.consume(']');
|
||||
}
|
||||
|
||||
// One mode's cached solo set. Strict like parseLanes: every key the writer emits is
|
||||
// mandatory and non-empty. An empty tracks array is rejected — serialize never emits
|
||||
// one (store drops an empty set), so accepting it would let a hand-edited blob parse
|
||||
// into a model that re-serializes differently.
|
||||
bool parseSoloTracks(json::Reader& r, std::map<std::string, int>& byGuid) {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume(']')) return true;
|
||||
do {
|
||||
if (!r.consume('{')) return false;
|
||||
std::string guid;
|
||||
int solo = 0;
|
||||
bool haveGuid = false, haveSolo = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!r.parseKey(k)) return false;
|
||||
if (k == "guid") { if (!r.parseString(guid)) return false; haveGuid = true; }
|
||||
else if (k == "solo") { if (!r.parseInt(solo)) return false; haveSolo = true; }
|
||||
else if (!r.skipValue()) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveGuid || !haveSolo || guid.empty()) return false;
|
||||
byGuid[guid] = solo;
|
||||
} while (r.consume(','));
|
||||
return r.consume(']');
|
||||
}
|
||||
|
||||
bool parseSoloCache(json::Reader& r, view::SoloCache& cache) {
|
||||
if (!r.consume('[')) return false;
|
||||
r.skipWs();
|
||||
if (r.consume(']')) return true;
|
||||
do {
|
||||
if (!r.consume('{')) return false;
|
||||
std::string modeId;
|
||||
std::map<std::string, int> byGuid;
|
||||
bool haveMode = false, haveTracks = false;
|
||||
do {
|
||||
std::string k;
|
||||
if (!r.parseKey(k)) return false;
|
||||
if (k == "mode") { if (!r.parseString(modeId)) return false; haveMode = true; }
|
||||
else if (k == "tracks") {
|
||||
if (!parseSoloTracks(r, byGuid)) return false;
|
||||
haveTracks = true;
|
||||
}
|
||||
else if (!r.skipValue()) return false;
|
||||
} while (r.consume(','));
|
||||
if (!r.consume('}')) return false;
|
||||
if (!haveMode || modeId.empty() || !haveTracks || byGuid.empty()) return false;
|
||||
if (!cache.store(modeId, byGuid)) return false;
|
||||
} while (r.consume(','));
|
||||
return r.consume(']');
|
||||
}
|
||||
|
||||
bool parseModel(json::Reader& r, ViewModeModel& out) {
|
||||
if (!r.consume('{')) return false;
|
||||
r.skipWs();
|
||||
@@ -581,6 +662,7 @@ bool parseModel(json::Reader& r, ViewModeModel& out) {
|
||||
MembershipIndex membership;
|
||||
LaneOwnershipIndex lanes;
|
||||
std::map<std::string, TrackSnapshot> snaps;
|
||||
view::SoloCache soloCache;
|
||||
|
||||
do {
|
||||
std::string key;
|
||||
@@ -599,6 +681,8 @@ bool parseModel(json::Reader& r, ViewModeModel& out) {
|
||||
if (!parseSnapshots(r, snaps)) return false;
|
||||
} else if (key == "lanes") {
|
||||
if (!parseLanes(r, lanes)) return false;
|
||||
} else if (key == "soloCache") {
|
||||
if (!parseSoloCache(r, soloCache)) return false;
|
||||
} else {
|
||||
if (!r.skipValue()) return false; // unknown keys / "version" placeholder
|
||||
}
|
||||
@@ -611,6 +695,7 @@ bool parseModel(json::Reader& r, ViewModeModel& out) {
|
||||
if (haveModes) out.modes() = reg;
|
||||
out.membership() = membership;
|
||||
out.lanes() = lanes;
|
||||
out.soloCache() = soloCache;
|
||||
for (const auto& [guid, snap] : snaps) out.storeSnapshot(guid, snap);
|
||||
if (haveActive) {
|
||||
if (!out.setActiveMode(activeMode)) return false; // active mode must exist
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/view/solo_cache.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Stable seed-mode ids. Arrange is the default home for untagged leaves.
|
||||
@@ -291,6 +293,8 @@ public:
|
||||
const MembershipIndex& membership() const { return membership_; }
|
||||
LaneOwnershipIndex& lanes() { return lanes_; }
|
||||
const LaneOwnershipIndex& lanes() const { return lanes_; }
|
||||
view::SoloCache& soloCache() { return soloCache_; }
|
||||
const view::SoloCache& soloCache() const { return soloCache_; }
|
||||
|
||||
const std::string& activeModeId() const { return activeModeId_; }
|
||||
// Returns false (no change) if the id is not registered.
|
||||
@@ -302,8 +306,9 @@ public:
|
||||
const TrackSnapshot* snapshot(const std::string& guid) const;
|
||||
const std::map<std::string, TrackSnapshot>& snapshots() const { return snapshots_; }
|
||||
|
||||
// Drops every snapshot whose GUID is NOT in `liveGuids`. Returns the count
|
||||
// removed.
|
||||
// Drops every snapshot whose GUID is NOT in `liveGuids`, and prunes the solo
|
||||
// cache the same way (see SoloCache::reconcile). Returns the count of
|
||||
// SNAPSHOTS removed — the solo cache's own count is available from it directly.
|
||||
//
|
||||
// Snapshots are pruned, membership is not: a parked track's snapshot is
|
||||
// dead weight once the track is deleted (can never restore; a reused GUID
|
||||
@@ -355,6 +360,7 @@ private:
|
||||
LaneOwnershipIndex lanes_; // (guid, laneKey) -> ownership
|
||||
std::string activeModeId_; // always a registered id
|
||||
std::map<std::string, TrackSnapshot> snapshots_; // guid -> pre-park snapshot
|
||||
view::SoloCache soloCache_; // modeId -> guid -> raw I_SOLO
|
||||
};
|
||||
|
||||
// Fixed-zero park plan for one leaf, offlining `fxCount` slots.
|
||||
|
||||
@@ -81,7 +81,7 @@ This directory owns two cross-artifact contracts specifically:
|
||||
- `reasampler_uid.h` — SDK-free header owning the FOREVER-FROZEN VST3 class-UID integer macros (stable + beta pairs, `REASAMPLER_PROC_UID_*` / `REASAMPLER_PROC_UID_BETA_*`) and the `REASAMPLER_ACTIVE_UID_*` channel-selector macros. Split out of `reasampler_vst.h` so the pure extension side (`instrument_drop`) can derive the `.vstpreset` class-ID hex string without pulling in the VST3 SDK. Both `reasampler_vst.h` (runtime `FUID`) and `instrument_drop` (preset hex string) source from this single header — the binary identity and the preset-file identity cannot diverge.
|
||||
- `assignment_request` — pure ingest-assign wire: typed request record carrying the drop payload from the `ingest` shell through to the VST3 bridge.
|
||||
- `bake_wire` — the resample bake's request/outcome pair on ONE per-instance key (`rsbake_<guid>`): the instrument writes a `BakeRequest`, invokes the extension's action synchronously, and reads the extension's `BakeOutcome` back over the same key inside that one call. Not a handshake — a call and a return, and it must not grow a claim protocol. Also the ONE home of the bake action's command-id suffix and of the leading underscore `NamedCommandLookup` needs but `rec->Register("command_id", …)` does not, so both artifacts name one action. `BakeStatus` values are WIRE INTEGERS: never renumber, only append, and an unrecognized value decodes as `Failed` rather than as the numeric default `Ok`.
|
||||
- `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns the `infoNamesFxHotspot` prefix classifier for `GetThingFromPoint` tokens. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure.
|
||||
- `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns `classifyReaperSurface`, the prefix classifier mapping a `GetThingFromPoint` (info token, track-present) pair onto `core/ui/drag_out`'s `ReaperSurface`. Classifier ordering is load-bearing: the embed strip is matched before the `tcp`/`mcp` panel family, which now claims the WHOLE track panel rather than just its FX sub-elements. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure.
|
||||
- `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record; `counted` carries key-attributed live records), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW. It lives here because it is a *wire format* with an instrument-side writer; the fold's output is consumed by `core/tracking`'s authority, which owns every consumer-facing decision built on it.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -18,6 +18,9 @@ reasampler_test(sample_usage LINK sample_usage prune_reconcile)
|
||||
# parallel byte writer, so the cross-artifact contract cannot drift — hence the link to
|
||||
# component_state_io, which stays engine-free. The class-ID string derives from the frozen
|
||||
# UID macros, channel-selected via the generated version header, hence its include dir.
|
||||
reasampler_pure_library(instrument_drop SOURCES instrument_drop.cpp LINK PUBLIC component_state_io)
|
||||
# drag_out is the dependency-free owner of the ReaperSurface vocabulary this module classifies
|
||||
# INTO; the edge points this way so drag_out (and card_drag through it) stays free of the
|
||||
# serializer.
|
||||
reasampler_pure_library(instrument_drop SOURCES instrument_drop.cpp LINK PUBLIC component_state_io drag_out)
|
||||
target_include_directories(instrument_drop PUBLIC ${PROJECT_BINARY_DIR}/generated)
|
||||
reasampler_test(instrument_drop LINK instrument_drop)
|
||||
|
||||
@@ -87,11 +87,16 @@ DropOutcome decideDropOutcome(const DropAttempt& attempt) {
|
||||
return out;
|
||||
}
|
||||
|
||||
bool infoNamesFxHotspot(const std::string& info) {
|
||||
// See the header contract for the prefix rule and the embed-strip exclusion.
|
||||
ui::ReaperSurface classifyReaperSurface(const std::string& info, bool haveTrack) {
|
||||
// See the header contract for the prefix rule and the ordering it depends on.
|
||||
auto startsWith = [&info](const char* p) { return info.rfind(p, 0) == 0; };
|
||||
if (startsWith("tcp.fxembed") || startsWith("mcp.fxembed")) return false;
|
||||
return startsWith("fx_") || startsWith("tcp.fx") || startsWith("mcp.fx");
|
||||
|
||||
if (info.empty()) return haveTrack ? ui::ReaperSurface::Other : ui::ReaperSurface::OffReaper;
|
||||
if (startsWith("tcp.fxembed") || startsWith("mcp.fxembed")) return ui::ReaperSurface::FxEmbed;
|
||||
if (startsWith("fx_")) return ui::ReaperSurface::FxSurface;
|
||||
if (startsWith("tcp") || startsWith("mcp")) return ui::ReaperSurface::TrackPanel;
|
||||
if (startsWith("arrange")) return ui::ReaperSurface::Arrange;
|
||||
return ui::ReaperSurface::Other;
|
||||
}
|
||||
|
||||
} // namespace reasampler::wire
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#pragma once
|
||||
// instrument_drop — pure payload-construction core of drop-and-load: dropping a
|
||||
// bank capture onto a track's FX surface instantiates ReaSampler 9000 on that
|
||||
// track already playing that capture. No REAPER/SWELL/VST3 SDK/vendor includes
|
||||
// bank capture onto a track's panel or FX surface instantiates ReaSampler 9000 on
|
||||
// that track already playing that capture. Also the classifier that names those
|
||||
// surfaces. No REAPER/SWELL/VST3 SDK/vendor includes
|
||||
// (+ the pure sample_map it reuses and the SDK-free UID macros in
|
||||
// reasampler_uid.h); unit-tested outside the DAW.
|
||||
//
|
||||
@@ -25,6 +26,8 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/ui/drag_out.h" // ReaperSurface — the surface vocabulary the gesture law reads
|
||||
|
||||
namespace reasampler::wire {
|
||||
|
||||
// The 32-char uppercase-hex class-ID string of this build's channel-active
|
||||
@@ -52,16 +55,23 @@ std::vector<std::uint8_t> buildVstPresetBytes(const std::string& classIdHex32,
|
||||
// preset. Deterministic.
|
||||
std::vector<std::uint8_t> buildInstrumentDropPreset(const std::string& sampleId);
|
||||
|
||||
// Pure classifier for GetThingFromPoint's info string: is the point over a
|
||||
// surface where an instrument drop should instantiate ReaSampler 9000? The
|
||||
// SDK warns future versions may append information, so the rule is
|
||||
// PREFIX-based: "fx_" (FX-chain/floating windows) or "tcp.fx"/"mcp.fx" (the
|
||||
// TCP/MCP FX button + sibling elements) EXCEPT "tcp.fxembed"/"mcp.fxembed" —
|
||||
// the embed-strip surface where an instance already draws; dropping there
|
||||
// must not add a second instance. Bare "tcp"/"mcp" and non-FX sub-elements
|
||||
// are not hotspots. The exact live token is DAW-only — confirm via
|
||||
// Pure classifier for GetThingFromPoint's (info string, track-was-returned) pair
|
||||
// into the surfaces the drag-out gesture law distinguishes. The SDK warns future
|
||||
// versions may append information, so every rule is PREFIX-based:
|
||||
// "tcp.fxembed*" / "mcp.fxembed*" -> FxEmbed. Checked FIRST: it is the surface
|
||||
// an existing instance already draws on, and a drop must not stack a second.
|
||||
// "fx_*" -> FxSurface (FX chain + floating FX windows).
|
||||
// "tcp*" / "mcp*" -> TrackPanel — the WHOLE panel, every
|
||||
// sub-element, not just the FX button. A TCP too narrow to draw that button
|
||||
// still means "sampler on this track", and the old glyph-only rule is exactly
|
||||
// why such a drop landed nowhere.
|
||||
// "arrange*" -> Arrange.
|
||||
// "" with no track -> OffReaper (the pointer left REAPER).
|
||||
// anything else, incl. "" WITH a track -> Other. Over REAPER on a surface we
|
||||
// cannot name: refuse visibly, never guess an outcome.
|
||||
// The exact live token is DAW-only — confirm via
|
||||
// reaper.GetThingFromPoint(reaper.GetMousePosition()) in ReaScript if unsure.
|
||||
bool infoNamesFxHotspot(const std::string& info);
|
||||
ui::ReaperSurface classifyReaperSurface(const std::string& info, bool haveTrack);
|
||||
|
||||
// The raw component-state bytes the preset carries, exposed so the round-trip
|
||||
// test can decode them back through sample_map::deserializeComponentState and
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
The bindable action families routed through REAPER's `command_id`/`gaccel`/
|
||||
`hookcommand` contract (Design View toggle actions, bank actions, the prune
|
||||
action, and the shared registration plumbing/table), plus the OS drag-out and
|
||||
FX-drop shells, plus the extension-side ingest-through-the-bank shell. This is
|
||||
action, and the shared registration plumbing/table), plus the three drag-out
|
||||
outcome shells (OS hand-off, instrument drop, arrange drop), plus the
|
||||
extension-side ingest-through-the-bank shell. This is
|
||||
where user-facing REAPER actions and OS-level drag/drop live; the underlying
|
||||
mutation logic (bank verbs, prune's orphan computation, view-mode reconciliation)
|
||||
is owned by other directories and only skinned here.
|
||||
@@ -15,6 +16,10 @@ is owned by other directories and only skinned here.
|
||||
- **Ingest is an extension act; the instrument is a read-only bank consumer.** Any
|
||||
instrument code path that captures, imports, inserts a timeline item, or writes
|
||||
back into the bank is a bug — the instrument reads and plays only.
|
||||
- **`arrange_drop_win` is the only timeline-placing shell in this directory**, and
|
||||
it places because the USER dragged a card onto the arrange. Root `CLAUDE.md`'s
|
||||
capture/placement separation forbids a CAPTURE placing an item; a deliberate drop
|
||||
is placement on demand. No other module here may grow an `InsertMedia` call.
|
||||
- **Ingest NEVER inserts a timeline item.** Arrange capture→bank→assign reuses the
|
||||
existing capture add-path and assigns the resulting `Sample` id to the target
|
||||
instance; it never places anything on the timeline — capture/placement
|
||||
@@ -33,8 +38,9 @@ is owned by other directories and only skinned here.
|
||||
|
||||
- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). `BANK_PRUNE_FOLDER` halts on `blockedByTracking` and prints each blocker that fired, with recovery instructions.
|
||||
- `drag_out_win` — OS drag-out shell: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`.
|
||||
- `instrument_drop_win` — FX-button drop shell: resolves a screen point to a track + FX-surface hotspot, then adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.**
|
||||
- `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.**
|
||||
- `instrument_drop_win` — instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns), and the drop half adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.**
|
||||
- `arrange_drop_win` — the drag-out gesture's arrange outcome: `arrangeTimeAtScreenX` (pointer column → time via `GetSet_ArrangeView2`'s one-pixel-span reading — inferred, not SDK-documented) and `performArrangeDrop` (snap the drop time, then one `InsertMedia` per capture on the pointer's track — assumed, not confirmed, to land end-to-end via REAPER's own cursor advance — in ONE undo block, counting only InsertMedia's reported successes, with the caller's track selection and edit cursor restored). The one timeline-placing shell here, per the invariant above; it never captures and never writes the bank.
|
||||
- `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.** Surface (2)'s action is the one in this directory published into a NON-main action section (Media Explorer) as well as Main — two ids, one handler, two dispatch hooks; see root `CLAUDE.md` §"REAPER extension contract" for the mechanism.
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// arrange_drop_win.cpp — see arrange_drop_win.h. main.cpp owns the API pointers; this TU gets
|
||||
// them extern via the WANT list.
|
||||
//
|
||||
// Runtime assumptions, all DAW-verifiable and none confirmed by the SDK header:
|
||||
// A. InsertMedia base mode 0 targets the sole selected track and inserts at the edit cursor;
|
||||
// SetOnlyTrackSelected isolates that track first (the same pair insert.cpp relies on).
|
||||
// B. InsertMedia advances the edit cursor past the media it added. That advance IS the
|
||||
// multi-file layout: the cursor is deliberately not reset between files, so N captures
|
||||
// land end to end. If REAPER does not advance it, they stack at one position instead —
|
||||
// visible and one Ctrl-Z away, never silent. insert.cpp does NOT share this assumption: it
|
||||
// resets the cursor before every track's insert specifically to stay independent of
|
||||
// cursor-advance behavior (insert.cpp:18-20, "this doesn't matter either way"). This call
|
||||
// site is the first in the tree to depend on it.
|
||||
// C. SnapToGrid honors the project's snap-enabled toggle. The header documents no
|
||||
// snap-enabled query for the arrange, so a drop taken with snapping OFF is the test that
|
||||
// settles it.
|
||||
// D. GetSet_ArrangeView2's one-pixel span [screenX, screenX+1) reads the time at that column.
|
||||
// The header documents only the all-zero span (screen_x_start==screen_x_end==0) as the
|
||||
// "whole view" special case; the per-column reading for any other span is inferred, not
|
||||
// documented.
|
||||
// E. InsertMedia's int return isn't SDK-documented; treated conservatively as 0=failure,
|
||||
// nonzero=success — performArrangeDrop counts only the latter.
|
||||
|
||||
#include "shell/actions/arrange_drop_win.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/insert_plan.h" // computeInsertMode — the ONE InsertMedia bitfield owner
|
||||
|
||||
#include "reaper_plugin.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_CountSelectedTracks
|
||||
#define REAPERAPI_WANT_GetCursorPosition
|
||||
#define REAPERAPI_WANT_GetSelectedTrack
|
||||
#define REAPERAPI_WANT_GetSet_ArrangeView2
|
||||
#define REAPERAPI_WANT_InsertMedia
|
||||
#define REAPERAPI_WANT_SetEditCurPos
|
||||
#define REAPERAPI_WANT_SetOnlyTrackSelected
|
||||
#define REAPERAPI_WANT_SetTrackSelected
|
||||
#define REAPERAPI_WANT_SnapToGrid
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
using capture::computeInsertMode;
|
||||
using capture::InsertOptions;
|
||||
|
||||
namespace {
|
||||
|
||||
// Selection snapshot/restore, so a drop leaves the user's track selection exactly as it found
|
||||
// it. Mirrors insert.cpp's pair; kept separate here because insert.cpp sits in the capture
|
||||
// pillar, outside this track's surface fence — not a ruling that the two should never share a
|
||||
// helper, just not this track's call to make.
|
||||
//
|
||||
// CountSelectedTracks/GetSelectedTrack both skip the master track (SDK header), so a user with
|
||||
// the master selected loses that selection across the drop — pre-existing behavior inherited
|
||||
// from insert.cpp's identical pair, not fixed here.
|
||||
std::vector<MediaTrack*> snapshotSelectedTracks() {
|
||||
const int n = CountSelectedTracks(nullptr); // nullptr = active project
|
||||
std::vector<MediaTrack*> tracks;
|
||||
tracks.reserve(static_cast<std::size_t>(n));
|
||||
for (int i = 0; i < n; ++i) tracks.push_back(GetSelectedTrack(nullptr, i));
|
||||
return tracks;
|
||||
}
|
||||
|
||||
void restoreSelectedTracks(const std::vector<MediaTrack*>& tracks) {
|
||||
if (tracks.empty()) return; // nothing was selected; leave whatever the drop selected
|
||||
SetOnlyTrackSelected(tracks[0]);
|
||||
for (std::size_t i = 1; i < tracks.size(); ++i) SetTrackSelected(tracks[i], true);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
double arrangeTimeAtScreenX(int screenX) {
|
||||
double start = 0.0, end = 0.0;
|
||||
// isSet=false with a one-pixel span [screenX, screenX+1) is assumed to read the time at
|
||||
// that column — inferred, not documented (assumption D in the file header). The SDK's ONLY
|
||||
// documented special form is screen_x_start==screen_x_end==0 (both zero) for "the whole
|
||||
// arrange view's start/end time"; a zero-width span at a nonzero column (e.g. screenX,
|
||||
// screenX) is NOT that special case, so the +1 here is precautionary rather than required.
|
||||
GetSet_ArrangeView2(nullptr, false, screenX, screenX + 1, &start, &end);
|
||||
return start < 0.0 ? 0.0 : start;
|
||||
}
|
||||
|
||||
int performArrangeDrop(MediaTrack* track, double time,
|
||||
const std::vector<std::string>& absolutePaths) {
|
||||
if (!track || absolutePaths.empty()) return 0;
|
||||
|
||||
const std::vector<MediaTrack*> priorSelection = snapshotSelectedTracks();
|
||||
const double priorCursor = GetCursorPosition();
|
||||
const int mode = computeInsertMode(InsertOptions{}); // current track, native length, no stretch
|
||||
|
||||
// One undo block around the whole drop (every item plus the selection/cursor restore) so a
|
||||
// single Ctrl-Z returns the project to exactly its pre-drop state.
|
||||
Undo_BeginBlock2(nullptr);
|
||||
|
||||
SetOnlyTrackSelected(track);
|
||||
SetEditCurPos(SnapToGrid(nullptr, time), /*moveview=*/false, /*seekplay=*/false);
|
||||
|
||||
int inserted = 0;
|
||||
for (const std::string& path : absolutePaths) {
|
||||
// No cursor reset between files — see assumption B in the file header. InsertMedia's
|
||||
// return isn't SDK-documented (assumption E); treated conservatively as 0=failure, so a
|
||||
// REAPER-side refusal is reflected in the count and in the undo label, not silent.
|
||||
if (InsertMedia(path.c_str(), mode) != 0) ++inserted;
|
||||
}
|
||||
|
||||
// A fixed stack buffer, not std::string concatenation: the prior shape built the label with
|
||||
// std::to_string + `+` between the restore below and Undo_EndBlock2, so a bad_alloc there
|
||||
// would leave an unbalanced undo block open. snprintf here removes the allocation outright.
|
||||
char label[64];
|
||||
std::snprintf(label, sizeof(label), "ReaSampler: drop %d %s onto arrange", inserted,
|
||||
inserted == 1 ? "capture" : "captures");
|
||||
|
||||
restoreSelectedTracks(priorSelection);
|
||||
SetEditCurPos(priorCursor, /*moveview=*/false, /*seekplay=*/false);
|
||||
|
||||
// extraflags -1 = UNDO_STATE_ALL, matching the insert action's own block.
|
||||
Undo_EndBlock2(nullptr, label, -1);
|
||||
return inserted;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
// arrange_drop_win — the arrange outcome of the panel's drag-out gesture: places the dragged
|
||||
// bank captures on the timeline at the track and time under the pointer.
|
||||
//
|
||||
// LOAD-BEARING: this is USER-INITIATED PLACEMENT, the same class of act as RunInsertSelected.
|
||||
// Root CLAUDE.md's "capture and placement are separate acts" forbids a CAPTURE placing an item;
|
||||
// a deliberate drop onto the timeline is placement on demand, and the user chose the spot.
|
||||
// Nothing here captures or writes the bank.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Declared as a class (matching reaper_plugin.h) so the mangled name agrees, without pulling
|
||||
// in the SDK.
|
||||
class MediaTrack;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The arrange time under a screen X, via GetSet_ArrangeView2's one-pixel-column reading —
|
||||
// inferred behavior, not SDK-documented (see the .cpp's assumption D). Times left of project
|
||||
// start clamp to 0.
|
||||
double arrangeTimeAtScreenX(int screenX);
|
||||
|
||||
// Places every path in `absolutePaths` on `track`, inside ONE undo block. The first lands at
|
||||
// `time`; whether REAPER's own cursor advance lands the rest end-to-end, or stacks them at one
|
||||
// position instead, is assumption B in the .cpp (visible, one Ctrl-Z away, either way). The
|
||||
// drop time is assumed to honor the project's snap setting (assumption C). The caller's track
|
||||
// selection and edit-cursor position are restored before returning. Returns the number of files
|
||||
// InsertMedia reported inserting successfully — its return isn't SDK-documented; treated as
|
||||
// 0=failure (assumption E in the .cpp).
|
||||
int performArrangeDrop(MediaTrack* track, double time,
|
||||
const std::vector<std::string>& absolutePaths);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -36,6 +36,7 @@
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_Main_SaveProject
|
||||
#define REAPERAPI_WANT_Help_Set
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||
@@ -160,21 +161,31 @@ void persistViewState() {
|
||||
g_session->saveToActiveProject();
|
||||
}
|
||||
|
||||
// The footer segment already reads disabled while the transport runs, but an action can
|
||||
// be fired with the panel closed — so the refusal also goes through Help_Set (SDK
|
||||
// documents only the signature, not its display surface; inferred to be visible
|
||||
// feedback by name, not confirmed). Guarded on the transport because applyMode's
|
||||
// other refusal is an unregistered mode id.
|
||||
void reportModeSwitchRefused() {
|
||||
if (transportBlocksModeSwitch(nullptr) && Help_Set)
|
||||
Help_Set("ReaSampler: stop the transport to switch view mode", true);
|
||||
}
|
||||
|
||||
// Cycle to the next mode in ordinal order. applyMode itself sets the model's active
|
||||
// mode, so we only compute the target and apply.
|
||||
void doToggleMode() {
|
||||
const std::string target =
|
||||
nextModeId(g_session->view().modes(), g_session->view().activeModeId());
|
||||
if (target.empty()) return; // no modes to cycle to (degenerate)
|
||||
applyMode(g_session->view(), target, nullptr);
|
||||
if (!applyMode(g_session->view(), target, nullptr)) { reportModeSwitchRefused(); return; }
|
||||
persistViewState();
|
||||
bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately
|
||||
}
|
||||
|
||||
// Direct jump to a named mode. applyMode is a no-op (returns false, no mutation) if
|
||||
// the id is unregistered, so an absent mode fails safe.
|
||||
// the id is unregistered or the transport is running, so both fail safe.
|
||||
void doActivateMode(const std::string& modeId) {
|
||||
applyMode(g_session->view(), modeId, nullptr);
|
||||
if (!applyMode(g_session->view(), modeId, nullptr)) { reportModeSwitchRefused(); return; }
|
||||
persistViewState();
|
||||
bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately
|
||||
}
|
||||
|
||||
@@ -62,18 +62,22 @@ namespace {
|
||||
// Not owned here (main.cpp owns g_session).
|
||||
ReaSamplerSession* g_session = nullptr;
|
||||
|
||||
// FOREVER-STABLE suffix — NEVER change after ship. Only the Media-Explorer import
|
||||
// registers here — the arrange capture+assign action lives in the capture family in
|
||||
// main.cpp, and the drop path is a panel callback (ingestDroppedFiles), not a
|
||||
// bindable action.
|
||||
constexpr const char* kIdImportMediaExplorer = "INGEST_IMPORT_MEDIA_EXPLORER";
|
||||
// Only the Media-Explorer import registers here — the arrange capture+assign action lives
|
||||
// in the capture family in main.cpp, and the drop path is a panel callback
|
||||
// (ingestDroppedFiles), not a bindable action. Its two FOREVER-STABLE suffixes are in
|
||||
// ingest.h.
|
||||
constexpr int kSectionMediaExplorer = 32063;
|
||||
|
||||
int g_cmdImportMediaExplorer = 0;
|
||||
gaccel_register_t g_accelImportMediaExplorer{};
|
||||
int g_cmdImportMediaExplorer = 0;
|
||||
int g_cmdImportMediaExplorerMx = 0;
|
||||
gaccel_register_t g_accelImportMediaExplorer{};
|
||||
custom_action_register_t g_customImportMediaExplorer{};
|
||||
|
||||
// c_str() pointers are handed to REAPER at register and re-presented at unregister,
|
||||
// so these strings must not be mutated after registration.
|
||||
// so these strings must not be mutated after registration. The label backs BOTH the
|
||||
// gaccel desc and the custom_action name.
|
||||
std::string g_idImportStr;
|
||||
std::string g_idImportMxStr;
|
||||
std::string g_labelImportStr;
|
||||
|
||||
// Forward-slashed, no trailing slash. Empty for an unsaved/no-active project, which
|
||||
@@ -457,14 +461,31 @@ void ingestDroppedFiles(const std::vector<std::string>& absolutePaths) {
|
||||
void ingestRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) {
|
||||
g_session = session;
|
||||
|
||||
g_idImportStr = channelCommandId(kIdImportMediaExplorer);
|
||||
g_labelImportStr = channelActionName("import Media Explorer file into selected track");
|
||||
|
||||
g_idImportStr = channelCommandId(kIngestImportMediaExplorerId);
|
||||
g_cmdImportMediaExplorer = rec->Register("command_id", (void*)g_idImportStr.c_str());
|
||||
if (g_cmdImportMediaExplorer) {
|
||||
g_labelImportStr = channelActionName("import Media Explorer file into selected track");
|
||||
g_accelImportMediaExplorer.accel.cmd = g_cmdImportMediaExplorer;
|
||||
g_accelImportMediaExplorer.desc = g_labelImportStr.c_str();
|
||||
rec->Register("gaccel", (void*)&g_accelImportMediaExplorer);
|
||||
}
|
||||
|
||||
// Second publication of the SAME action, into the Media Explorer section, so it can
|
||||
// be put on that window's toolbar. gaccel cannot express this — it registers into the
|
||||
// main keyboard section only — so custom_action is the one mechanism. It carries no
|
||||
// ACCEL, hence no default keybinding for this entry; toolbar reach is the point.
|
||||
g_idImportMxStr = channelCommandId(kIngestImportMediaExplorerMxId);
|
||||
g_customImportMediaExplorer.uniqueSectionId = kSectionMediaExplorer;
|
||||
g_customImportMediaExplorer.idStr = g_idImportMxStr.c_str();
|
||||
g_customImportMediaExplorer.name = g_labelImportStr.c_str();
|
||||
g_customImportMediaExplorer.extra = nullptr;
|
||||
g_cmdImportMediaExplorerMx =
|
||||
rec->Register("custom_action", (void*)&g_customImportMediaExplorer);
|
||||
if (!g_cmdImportMediaExplorerMx)
|
||||
ShowConsoleMsg("ReaSampler: could not publish the Media Explorer import action into "
|
||||
"the Media Explorer action section -- it is still available in the "
|
||||
"Main section.\n");
|
||||
}
|
||||
|
||||
bool ingestHandleCommand(int command) {
|
||||
@@ -473,10 +494,24 @@ bool ingestHandleCommand(int command) {
|
||||
return false; // not ours — caller's hookcommand keeps looking
|
||||
}
|
||||
|
||||
bool ingestHandleSectionCommand(int command) {
|
||||
if (command == 0 || !g_session) return false;
|
||||
// ONLY the Media Explorer id — partitioning contract: root `CLAUDE.md`
|
||||
// §"REAPER extension contract".
|
||||
if (command == g_cmdImportMediaExplorerMx) { doImportFromMediaExplorer(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
void ingestUnregisterActions(reaper_plugin_info_t* rec) {
|
||||
// A 0 return from "custom_action" means REAPER holds no registration of ours (a dupe
|
||||
// idStr is one documented cause) — mirroring it anyway could retire someone else's.
|
||||
if (g_cmdImportMediaExplorerMx)
|
||||
rec->Register("-custom_action", (void*)&g_customImportMediaExplorer);
|
||||
// '-command_id' re-presents the SAME interned id used at register (g_idImportStr).
|
||||
rec->Register("-gaccel", (void*)&g_accelImportMediaExplorer);
|
||||
rec->Register("-command_id", (void*)g_idImportStr.c_str());
|
||||
g_cmdImportMediaExplorer = 0;
|
||||
g_cmdImportMediaExplorerMx = 0;
|
||||
g_session = nullptr;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,12 +29,25 @@ namespace reasampler {
|
||||
|
||||
class ReaSamplerSession;
|
||||
|
||||
// `session` is shared with the capture / bank / Design-View families; the single
|
||||
// hookcommand in main.cpp routes fired ids here via ingestHandleCommand.
|
||||
// FOREVER-STABLE command-id suffixes — NEVER change after ship; user keybindings key off
|
||||
// the composed ids. Two, because the Media-Explorer import publishes into two action
|
||||
// sections and a custom_action idStr must be unique across all of them. Exposed here so
|
||||
// the id-composition contract is assertable without linking this REAPER-facing TU.
|
||||
inline constexpr const char* kIngestImportMediaExplorerId = "INGEST_IMPORT_MEDIA_EXPLORER";
|
||||
inline constexpr const char* kIngestImportMediaExplorerMxId = "INGEST_IMPORT_MEDIA_EXPLORER_MX";
|
||||
|
||||
// `session` is shared with the capture / bank / Design-View families; main.cpp's two
|
||||
// dispatch hooks route fired ids back through the two handlers below. Both registration
|
||||
// mechanisms are specified in root CLAUDE.md §"REAPER extension contract".
|
||||
void ingestRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session);
|
||||
|
||||
// Main-section dispatch ("hookcommand").
|
||||
bool ingestHandleCommand(int command);
|
||||
|
||||
// Non-main-section dispatch ("hookcommand2"). Claims only ids this family published
|
||||
// outside the Main section, so the two hooks never both claim one command.
|
||||
bool ingestHandleSectionCommand(int command);
|
||||
|
||||
void ingestUnregisterActions(reaper_plugin_info_t* rec);
|
||||
|
||||
// "The active sampler instance should now play (bankId, sampleId)." Called by EVERY
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/version/app_version.h" // vstPluginName() — the CHANNEL-correct FX name (stable/beta pairing)
|
||||
#include "core/wire/instrument_drop.h" // infoNamesFxHotspot — the PURE, unit-tested hotspot classifier
|
||||
#include "core/wire/instrument_drop.h" // classifyReaperSurface — the PURE, unit-tested classifier
|
||||
|
||||
#include "reaper_plugin.h"
|
||||
|
||||
@@ -33,7 +33,7 @@ using version::vstPluginName;
|
||||
using wire::decideDropOutcome;
|
||||
using wire::DropAttempt;
|
||||
using wire::DropOutcome;
|
||||
using wire::infoNamesFxHotspot;
|
||||
using wire::classifyReaperSurface;
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -76,17 +76,14 @@ std::filesystem::path writeTempPreset(const std::vector<std::uint8_t>& bytes) {
|
||||
|
||||
} // namespace
|
||||
|
||||
FxDropTarget resolveFxDropTarget(int screenX, int screenY) {
|
||||
FxDropTarget out;
|
||||
DropProbe probeDropTarget(int screenX, int screenY) {
|
||||
DropProbe out;
|
||||
char info[256] = {0};
|
||||
// A non-empty info OR a non-null track means the point is over REAPER's own UI;
|
||||
// a null track with empty info means the pointer has left REAPER entirely.
|
||||
MediaTrack* track = GetThingFromPoint(screenX, screenY, info, sizeof(info));
|
||||
out.track = track;
|
||||
out.overReaperUi = (track != nullptr) || (info[0] != '\0');
|
||||
// The hotspot is either the FX chain/floating window ("fx_*") OR the FX-button family of
|
||||
// the track/mixer panel ("tcp.fx*"/"mcp.fx*"). The pure classifier owns the rule.
|
||||
out.overFxHotspot = (track != nullptr) && infoNamesFxHotspot(info);
|
||||
// GetThingFromPoint may return a null track together with a valid info string (its own
|
||||
// doc-comment says so), so the track and the surface are two independent facts and both
|
||||
// are reported. The pure classifier owns every token rule.
|
||||
out.track = GetThingFromPoint(screenX, screenY, info, sizeof(info));
|
||||
out.surface = classifyReaperSurface(info, out.track != nullptr);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,33 +9,33 @@
|
||||
//
|
||||
// LOAD-BEARING: an EXPLICIT user placement-of-the-player gesture — adds a READER of
|
||||
// the bank on a track, pointed at an already-captured sample. NEVER captures, NEVER
|
||||
// writes the bank, NEVER inserts a timeline item. The only writes are a new FX
|
||||
// instance + its component state, both wrapped in one undo block (one Ctrl-Z), plus
|
||||
// a transient .vstpreset deleted before returning.
|
||||
// writes the bank, NEVER inserts a timeline item (the drag's arrange outcome is a
|
||||
// separate shell, arrange_drop_win). The only writes are a new FX instance + its
|
||||
// component state, both wrapped in one undo block (one Ctrl-Z), plus a transient
|
||||
// .vstpreset deleted before returning.
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/ui/drag_out.h" // ReaperSurface
|
||||
|
||||
// Declared as a class (matching reaper_plugin.h) so the mangled name agrees, without
|
||||
// pulling in the SDK.
|
||||
class MediaTrack;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
struct FxDropTarget {
|
||||
// One evaluation of what sits under a screen point. Carries no verdict — the pure law
|
||||
// (ui::decideDropClass) turns this plus the payload size into an outcome.
|
||||
struct DropProbe {
|
||||
MediaTrack* track = nullptr; // the track under the pointer (null if none / not a track)
|
||||
bool overReaperUi = false; // the point is over REAPER's own window/UI at all
|
||||
bool overFxHotspot = false; // specifically over this track's FX button/chain surface
|
||||
|
||||
bool valid() const { return track != nullptr && overFxHotspot; }
|
||||
ui::ReaperSurface surface = ui::ReaperSurface::OffReaper;
|
||||
};
|
||||
|
||||
// Wraps GetThingFromPoint, whose info string tells us what was hit ("tcp.fx*"/
|
||||
// "mcp.fx*" for the TCP/MCP FX button family; "fx_chain"/"fx_N" for the FX-chain and
|
||||
// floating windows). `overReaperUi` is true when the point is over REAPER's own UI
|
||||
// at all; `overFxHotspot` is true only for a genuine FX-bearing surface (decided by
|
||||
// the pure instrument_drop::infoNamesFxHotspot).
|
||||
FxDropTarget resolveFxDropTarget(int screenX, int screenY);
|
||||
// Wraps GetThingFromPoint and hands its (info string, track) pair to the pure
|
||||
// wire::classifyReaperSurface. Cheap enough to run on every mouse-move, but the panel
|
||||
// evaluates it only OUTSIDE its own client rect — the internal drag never pays for it.
|
||||
DropProbe probeDropTarget(int screenX, int screenY);
|
||||
|
||||
// Adds a fresh ReaSampler 9000 instance to `track` and applies `presetBytes` as its
|
||||
// component state. Wraps add + apply in one REAPER undo block. All-or-nothing: if
|
||||
|
||||
@@ -20,6 +20,23 @@ relative-paths-only) and the load-bearing capture/placement separation — see r
|
||||
`CLAUDE.md` §Precision invariants and §The load-bearing principle. Shell-specific
|
||||
detail not covered there:
|
||||
|
||||
- **The item scope's render source is window-dependent.** `ResolveScopeSource` is
|
||||
where that is decided — it measures the selected items' extent against the
|
||||
resolved range (`core/capture/render_window`) and hands the answer to
|
||||
`sourceModeForScope` on `ResolvedSource`. Why, in
|
||||
`src/core/capture/CLAUDE.md`.
|
||||
- **A ranged item capture isolates TRACKS, not ITEMS.** Routing it through the
|
||||
selected-tracks source widens what the render hears, and the two widenings are
|
||||
answered differently. Folder children and receives are cut for the render's
|
||||
duration (`render_isolation`) because they are tracks, and a recipe carrying
|
||||
tracks can recompute that plan at replay time. An overlapping item on the source
|
||||
track itself is NOT isolated: the recipe stores tracks and a range, never item
|
||||
GUIDs, so a mute plan over items could not be replayed and the capture would stop
|
||||
reproducing itself. Do not "fix" the second by muting items.
|
||||
- **`renderOffline` is the one seam both a fresh capture and a recipe replay
|
||||
cross**, which is why the refusal and both transient guards live there rather
|
||||
than in the action bodies — anything placed in `ResolveScopeSource` alone would
|
||||
miss `RunRecaptureFromSource` entirely.
|
||||
- **FX-bypass guard ordering.** `scope_resolve` reads the M10 provenance-assembly
|
||||
inputs (track/item selection, FX-chain identity) BEFORE the FX-bypass guard
|
||||
neutralizes the in-scope chain — provenance must see the chain as it really is,
|
||||
@@ -34,15 +51,17 @@ detail not covered there:
|
||||
|
||||
## Modules
|
||||
|
||||
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`.
|
||||
- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain).
|
||||
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. It also owns the two file-side steps both backends share, in this order: `collapseCapturedFileToMono` (the lossless mono collapse, applied to the landed file) and `stampCaptureSample`, which measures the channel count off that same file so the entry and the audio cannot disagree. And `captureNameFor` — the impure local-clock read the entry points call to build a request's label + stem, kept out of the pure `core/capture/capture_name` composition it feeds.
|
||||
- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain). Also the one place a source track's NAME is read (`trackName`, via `GetTrackName` — chosen over `P_NAME` because it already answers REAPER's `"Track N"` convention for an unnamed track), landed on `ResolvedSource::trackNames` parallel to `sourceTracks` and composed into the capture's label + stem by the pure `core/capture/capture_name`.
|
||||
- `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged ONLY for that source mode, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`.
|
||||
- `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`).
|
||||
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places).
|
||||
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded, and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert". A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header.
|
||||
- `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action.
|
||||
- `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload.
|
||||
- `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26).
|
||||
- `capture_realtime_finalize` (`shell/capture`) — the file-side half of the realtime-record shell (Q-W3, T4-08): discovers the file REAPER actually recorded, moves it into the bank, runs the Auto-tail PCM decay-scan trim, and populates the finished `Sample`.
|
||||
- `insert` — placement via `InsertMedia`. **Conform-to-project-tempo is an explicit opt-in flag, never silent stretching.**
|
||||
- `insert` — placement via `InsertMedia`. **Conform-to-project-tempo is an explicit opt-in flag, never silent stretching.** The mono collapse needs no change here: `insert.cpp` passes only a path to `InsertMedia`, and REAPER derives the item's channel count from the file itself — a 1-channel WAV yields a mono item for free.
|
||||
- `provenance_shell` — FX-chain identity queries via `TrackFX_*`/`TakeFX_*` APIs; feeds the pure `provenance` fingerprint builder. Stamps `Sample.provenance` on capture; ambiguous/mixed cases record nothing conservatively.
|
||||
- `track_guid` — shared `MediaTrack*` → canonical GUID-string formatter; single source of truth for membership keys.
|
||||
- `item_read` — the ONE place a `MediaItem*` is read for its canonical GUID string (`itemGuid`) and for the durable `P_LANENAME` of the fixed lane it sits on (`itemLaneName`); extracted from previously-duplicated `itemGuid`/`itemLaneName` pairs in `view.cpp` and `bank_panel.cpp` — the item-read analog of `track_guid`'s single `MediaTrack*`→GUID-key formatter. Callers must already know the track is fixed-lane (`I_FREEMODE==2`) before calling `itemLaneName`; the pure `isOnManualLane` predicate handles the non-fixed-lane case separately.
|
||||
@@ -54,3 +73,18 @@ detail not covered there:
|
||||
- `capture` and `capture_realtime_shell` deliberately share NO common interface with
|
||||
each other (the former `ICaptureBackend` was removed) — do not reintroduce one
|
||||
without a real second polymorphic call site.
|
||||
- **The selected-tracks render (`&128`) is read as emitting one file per selected
|
||||
track** — the single-file bit `&(4<<16)` is documented for item/razor sources only
|
||||
(SDK header ~3041), and that is the whole basis for the reading; it is DAW-unverified.
|
||||
If it holds, then since `RENDER_PATTERN` is one literal stem and success is a
|
||||
file-exists check, N tracks would land one track's audio as a successful capture.
|
||||
`renderOffline` refuses EVERY multi-track render through that source
|
||||
(`render_settings::isMultiTrackStemRender`) — the ranged item capture and the plain
|
||||
track capture alike, each with its own way out
|
||||
(`render_settings::multiTrackRefusalMessage`). The refusal is keyed on the render
|
||||
SOURCE and not on the scope, so a future caller that reaches `&128` inherits it.
|
||||
Re-opening a multi-track track capture needs the DAW check in
|
||||
`docs/verify-track-scope-multitrack.md` to come back the other way first.
|
||||
- **Realtime is the one capture path that accepts a multi-track selection**, and it is
|
||||
correct to: its per-source-track sends sum in the one temp track, which is a real mix
|
||||
rather than a stem collapse. The offline refusal above does not apply to it.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// REAPER-facing offline-render backend (OfflineRenderBackend) plus the shared
|
||||
// backend helpers (makeUniqueTag / stampCaptureSample).
|
||||
// backend helpers (makeUniqueTag / captureNameFor / collapseCapturedFileToMono /
|
||||
// stampCaptureSample).
|
||||
//
|
||||
// Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is
|
||||
// the one TU that defines the API pointers; here they are extern.
|
||||
@@ -21,17 +22,20 @@
|
||||
#include "shell/capture/capture.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <vector>
|
||||
|
||||
#include "core/capture/capture_paths.h"
|
||||
#include "core/capture/wav_codec.h" // hashWavContent — the one WAV/RIFF owner
|
||||
#include "core/capture/wav_codec.h" // hashWavContent / collapseToMono — the one WAV/RIFF owner
|
||||
#include "core/util/file_bytes.h"
|
||||
#include "core/capture/render_settings.h"
|
||||
#include "core/capture/render_window.h" // frameCountFor — the exact-bounds number
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
@@ -41,6 +45,7 @@
|
||||
#define REAPERAPI_WANT_Main_OnCommand
|
||||
#define REAPERAPI_WANT_Main_SaveProject
|
||||
#define REAPERAPI_WANT_Master_GetTempo
|
||||
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||
#define REAPERAPI_WANT_TimeMap_GetTimeSigAtTime
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
@@ -198,13 +203,108 @@ std::string makeUniqueTag(const std::string& prefix) {
|
||||
std::to_string(++counter);
|
||||
}
|
||||
|
||||
CaptureName captureNameFor(const std::vector<std::string>& sourceNames,
|
||||
int ordinal, const std::string& fallback) {
|
||||
CaptureNameInputs in;
|
||||
in.sourceNames = sourceNames;
|
||||
in.ordinal = ordinal;
|
||||
in.fallback = fallback;
|
||||
|
||||
// localtime, not gmtime: the discriminator is read by the person who made the
|
||||
// capture, so it must match the clock on their wall. A failed conversion leaves the
|
||||
// stamp zeroed, which composeCaptureName renders as no discriminator at all.
|
||||
const std::time_t now = std::time(nullptr);
|
||||
std::tm local{};
|
||||
#ifdef _WIN32
|
||||
const bool ok = (localtime_s(&local, &now) == 0);
|
||||
#else
|
||||
const bool ok = (localtime_r(&now, &local) != nullptr);
|
||||
#endif
|
||||
if (ok) {
|
||||
in.stamp.month = local.tm_mon + 1; // tm_mon is 0-based
|
||||
in.stamp.day = local.tm_mday;
|
||||
in.stamp.hour = local.tm_hour;
|
||||
in.stamp.minute = local.tm_min;
|
||||
}
|
||||
return composeCaptureName(in);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// One console line per genuine collapse failure — silence here is what made a failed
|
||||
// rewrite read exactly like a legitimately stereo capture. Deliberately does NOT claim
|
||||
// the captured bytes are intact: a 0-byte render can reach this branch too (it passes the
|
||||
// exists/bounds gates upstream; see docs/TODO.md), and this path never verified the bytes
|
||||
// it's reporting on.
|
||||
void reportCollapseFailure(const std::string& absolutePath, const char* what,
|
||||
const char* consoleLabel) {
|
||||
ShowConsoleMsg((std::string(consoleLabel) + ": the lossless mono collapse " +
|
||||
std::string(what) + " -- " + absolutePath +
|
||||
" already reached the bank; only the size win from the collapse "
|
||||
"was lost.\n").c_str());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MonoCollapseOutcome collapseCapturedFileToMono(const std::string& absolutePath,
|
||||
const char* consoleLabel) {
|
||||
const std::vector<std::uint8_t> bytes = util::readFileBytes(absolutePath);
|
||||
if (bytes.empty()) {
|
||||
// Failed, not Declined: the read that would have decided never happened, so
|
||||
// "the channels differ" is a claim this path cannot make.
|
||||
reportCollapseFailure(absolutePath, "could not read the captured file", consoleLabel);
|
||||
return MonoCollapseOutcome::Failed;
|
||||
}
|
||||
|
||||
const MonoCollapse collapse = collapseToMono(bytes);
|
||||
if (!collapse.collapsed) return MonoCollapseOutcome::Declined;
|
||||
|
||||
// Sibling temp + rename, NOT an in-place truncating write: this runs unconditionally
|
||||
// on the deterministic offline path (which never reopened its render for write before
|
||||
// this step existed), so a mid-write failure here must not land a truncated file that
|
||||
// stampCaptureSample then hashes as a false CaptureStatus::Ok. rename() replaces the
|
||||
// destination in one step, so the original bytes are never destroyed until the
|
||||
// replacement is known-complete; a failed write or rename leaves the original file
|
||||
// untouched and self-cleans the temp rather than littering it.
|
||||
const std::string tempPath = absolutePath + ".moncollapse.tmp";
|
||||
{
|
||||
std::ofstream out(tempPath, std::ios::binary | std::ios::trunc);
|
||||
if (!out) {
|
||||
reportCollapseFailure(absolutePath, "could not open its temporary file", consoleLabel);
|
||||
return MonoCollapseOutcome::Failed;
|
||||
}
|
||||
out.write(reinterpret_cast<const char*>(collapse.bytes.data()),
|
||||
static_cast<std::streamsize>(collapse.bytes.size()));
|
||||
const bool wroteOk = static_cast<bool>(out);
|
||||
out.close();
|
||||
if (!wroteOk) {
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(tempPath, ec);
|
||||
reportCollapseFailure(absolutePath, "could not write the rebuilt file", consoleLabel);
|
||||
return MonoCollapseOutcome::Failed;
|
||||
}
|
||||
}
|
||||
std::error_code ec;
|
||||
std::filesystem::rename(tempPath, absolutePath, ec);
|
||||
if (ec) {
|
||||
std::filesystem::remove(tempPath, ec); // don't leave litter on a failed rename
|
||||
reportCollapseFailure(absolutePath, "could not replace the captured file", consoleLabel);
|
||||
return MonoCollapseOutcome::Failed;
|
||||
}
|
||||
return MonoCollapseOutcome::Collapsed;
|
||||
}
|
||||
|
||||
void stampCaptureSample(Sample& s, const CaptureRequest& req,
|
||||
ReaProject* rateProj, ReaProject* timeSigProj,
|
||||
const std::string& absolutePath) {
|
||||
// Track GUIDs + channel count: echoed from the request (the caller resolved
|
||||
// the selection; the backends stay source-agnostic).
|
||||
// Track GUIDs echoed from the request (the caller resolved the selection; the
|
||||
// backends stay source-agnostic). channelCount starts at 0 (unknown, the same
|
||||
// sentinel bank_model already uses for a pre-field entry) rather than the
|
||||
// request's value — the request always asks for 2, so echoing it would claim a
|
||||
// measurement that never happened for the unparseable-file case below. The
|
||||
// produced FILE overrides it below whenever it parses.
|
||||
s.trackGuids = req.trackGuids;
|
||||
s.channelCount = req.channelCount;
|
||||
s.channelCount = 0;
|
||||
|
||||
// PROJECT_SRATE can read 0 on a project that never pinned a rate — stays 0
|
||||
// (honest "unknown") rather than a bogus literal.
|
||||
@@ -235,6 +335,10 @@ void stampCaptureSample(Sample& s, const CaptureRequest& req,
|
||||
const std::vector<std::uint8_t> fileBytes = util::readFileBytes(absolutePath);
|
||||
if (!fileBytes.empty()) {
|
||||
s.contentHash = hashWavContent(fileBytes);
|
||||
// The one authority for the entry's channel count is the file's own `fmt`
|
||||
// — never the render request, which asks for 2 on every capture path.
|
||||
const WavLayout layout = parseWavLayout(fileBytes);
|
||||
if (layout.valid) s.channelCount = static_cast<int>(layout.channelCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,12 +498,74 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Exact bounds, made structural: with no tail requested the file must contain
|
||||
// (within a tolerance, see below) the requested window's frames, so a source
|
||||
// mode that silently widened the render fails loudly here instead of landing as
|
||||
// a successful capture. Auto and Manual add frames by design and are skipped.
|
||||
// (On TailMode::None the landed file is read three times on this path — this gate,
|
||||
// the mono collapse, and stampCaptureSample — plus one rewrite when the collapse
|
||||
// fires; Auto/Manual skip this gate entirely, so they read it twice. A
|
||||
// once-per-capture cost on an already-warm file, judged acceptable.) A
|
||||
// bounded/header-only read is not a clean substitute: parseWavLayout only marks the
|
||||
// data chunk valid when the buffer holds the chunk's FULL declared body
|
||||
// (bodyInBounds), so a truncated read would read as invalid here on every real
|
||||
// capture, not just malformed ones.
|
||||
if (request.tailMode == TailMode::None) {
|
||||
const WavLayout layout =
|
||||
parseWavLayout(util::readFileBytes(expectedPath));
|
||||
const long long expectedFrames = layout.valid
|
||||
? frameCountFor(request.startSeconds, request.endSeconds,
|
||||
static_cast<int>(layout.sampleRate))
|
||||
: 0;
|
||||
const long long actualFrames = static_cast<long long>(layout.frameCount());
|
||||
// frameCountFor is a difference of frame indices, not a rounded duration
|
||||
// (see render_window.h) — REAPER's own edge-rounding can legitimately land
|
||||
// one frame off that, so the gate tolerates +/-1 rather than exact equality.
|
||||
// The defect this refuses is a whole-item widening (seconds of extra audio,
|
||||
// thousands of frames), which a 1-frame tolerance still catches with
|
||||
// certainty. Tightening to exact equality needs a DAW pass confirming REAPER
|
||||
// resolves the window's two edges to frame indices the same way this does.
|
||||
const long long frameDelta = actualFrames > expectedFrames
|
||||
? actualFrames - expectedFrames
|
||||
: expectedFrames - actualFrames;
|
||||
if (expectedFrames > 0 && frameDelta > 1) {
|
||||
result.status = CaptureStatus::BoundsMismatch;
|
||||
result.message = "Render produced " + std::to_string(actualFrames) +
|
||||
" frames but the requested range is " +
|
||||
std::to_string(expectedFrames) + " at " +
|
||||
std::to_string(layout.sampleRate) +
|
||||
" Hz -- the render did not honor the requested bounds. "
|
||||
"Requested [" + std::to_string(request.startSeconds) +
|
||||
"s, " + std::to_string(request.endSeconds) +
|
||||
"s) -> frame indices [" +
|
||||
std::to_string(std::llround(request.startSeconds *
|
||||
layout.sampleRate)) +
|
||||
", " +
|
||||
std::to_string(std::llround(request.endSeconds *
|
||||
layout.sampleRate)) +
|
||||
"). Nothing was added to the bank; the render at " +
|
||||
expectedPath + " was never indexed and has been cleaned up.";
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(expectedPath, ec);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Lossless mono collapse, deliberately AFTER the bounds gate: the gate measures
|
||||
// REAPER's own render against the requested window, so nothing of ours may sit
|
||||
// between the render and that measurement, and a refusal must delete the
|
||||
// renderer's file rather than one this step had already rewritten. The collapse
|
||||
// preserves the frame count, so the two are order-independent in outcome — only
|
||||
// in what each is measuring.
|
||||
const MonoCollapseOutcome collapseOutcome =
|
||||
collapseCapturedFileToMono(expectedPath);
|
||||
|
||||
// Record the request's own bounds (exact) rather than re-measuring the file.
|
||||
Sample s;
|
||||
// Same uniqueTag that named the file — calling makeUniqueTag() again could
|
||||
// yield a different value and desync Sample.id from the file name.
|
||||
s.id = "cap-" + uniqueTag + "-" + paths.fileName;
|
||||
s.displayName = request.baseName;
|
||||
s.displayName = request.label();
|
||||
s.relativePath = paths.relativePath; // project-relative (invariant)
|
||||
s.sourceMode = request.sourceMode;
|
||||
s.sourceRange.startSeconds = request.startSeconds;
|
||||
@@ -420,7 +586,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
||||
result.message = "Captured [" +
|
||||
std::to_string(request.startSeconds) + "s, " +
|
||||
std::to_string(request.endSeconds) + "s] -> " +
|
||||
paths.relativePath;
|
||||
paths.relativePath + monoCollapseSuffix(collapseOutcome);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
+49
-10
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
// The shared capture seam: CaptureRequest/CaptureResult (types both backends
|
||||
// speak), OfflineRenderBackend, and the makeUniqueTag/stampCaptureSample helpers.
|
||||
// speak), OfflineRenderBackend, and the helpers both backends share (naming, the
|
||||
// mono collapse, the Sample stamp).
|
||||
// Realtime's async begin/tick/abort surface lives in capture_realtime_shell.h.
|
||||
//
|
||||
// REAPER-free on purpose (bank_model only) so callers can depend on the seam
|
||||
@@ -10,7 +11,9 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/bank_model.h"
|
||||
#include "core/capture/capture_name.h" // CaptureName — label + file-stem base
|
||||
#include "core/capture/render_settings.h" // TailMode — the three-state tail contract
|
||||
#include "core/capture/wav_codec.h" // MonoCollapseOutcome — the collapse's report
|
||||
|
||||
// Forward-declared, never dereferenced here — only the REAPER-facing .cpp touches these.
|
||||
class MediaTrack;
|
||||
@@ -53,6 +56,9 @@ struct CaptureRequest {
|
||||
|
||||
// 0 sampleRate => follow project rate.
|
||||
int sampleRate = 0;
|
||||
// What the RENDER is asked for (RENDER_CHANNELS / the realtime record mode), not
|
||||
// what the capture lands as: a dual-mono render is collapsed to 1 channel after
|
||||
// the fact, and the Sample's count comes from the produced file.
|
||||
int channelCount = 2;
|
||||
WavBitDepth bitDepth = WavBitDepth::Float32;
|
||||
|
||||
@@ -60,17 +66,28 @@ struct CaptureRequest {
|
||||
// backend caller so the pure naming logic stays testable.
|
||||
std::string baseName = "capture";
|
||||
std::string uniqueTag;
|
||||
|
||||
// The label the bank shows, which may legitimately differ from the file stem: the
|
||||
// stem must survive sanitizeStem, the label carries the source name verbatim. Empty
|
||||
// means "the stem base is also the label" — what a caller that names nothing else gets.
|
||||
std::string displayName;
|
||||
|
||||
// The one home for that fallback rule; both backends populate Sample::displayName
|
||||
// from here rather than each spelling the condition out.
|
||||
std::string label() const { return displayName.empty() ? baseName : displayName; }
|
||||
};
|
||||
|
||||
// Every failure is an explicit code, never a thrown exception across the REAPER boundary.
|
||||
enum class CaptureStatus {
|
||||
Ok,
|
||||
NoProject, // no active project to render / resolve a bank folder
|
||||
EmptyRange, // start >= end: nothing to render
|
||||
UnsupportedMode, // backend does not implement this source mode
|
||||
UnsupportedFormat, // requested bit depth has no known REAPER blob (Float32 only)
|
||||
RenderFailed, // the render action ran but produced no output file
|
||||
TransportBusy, // realtime backend: transport already playing/recording — refused
|
||||
NoProject, // no active project to render / resolve a bank folder
|
||||
EmptyRange, // start >= end: nothing to render
|
||||
UnsupportedMode, // backend does not implement this source mode
|
||||
UnsupportedFormat, // requested bit depth has no known REAPER blob (Float32 only)
|
||||
RenderFailed, // the render action ran but produced no output file
|
||||
TransportBusy, // realtime backend: transport already playing/recording — refused
|
||||
MultiTrackSelection, // a selected-tracks render over >1 track — would render N files
|
||||
BoundsMismatch, // the rendered file's frame count is not the requested window's
|
||||
};
|
||||
|
||||
struct CaptureResult {
|
||||
@@ -98,9 +115,31 @@ public:
|
||||
// backend's family marker ("" offline, "rt-" realtime).
|
||||
std::string makeUniqueTag(const std::string& prefix);
|
||||
|
||||
// Stamps the metadata shared by both backends onto `s`: trackGuids + channelCount
|
||||
// (echoed from the request), resolved sampleRate (request rate, else PROJECT_SRATE
|
||||
// from `rateProj`), captureTempo, the capture-start time signature
|
||||
// Composes one capture's label + file-stem base (core/capture/capture_name) from the
|
||||
// resolved source-track names, reading the LOCAL clock for the discriminator — the one
|
||||
// impure step, kept here so the composition itself stays pure and tested. `ordinal` is a
|
||||
// batch unit's number (0 for a single capture); `fallback` is the scope literal, used
|
||||
// only when no source name resolved.
|
||||
CaptureName captureNameFor(const std::vector<std::string>& sourceNames,
|
||||
int ordinal, const std::string& fallback);
|
||||
|
||||
// Rewrites a just-captured WAV in place as a 1-channel file when its channels are
|
||||
// bit-identical (the pure `collapseToMono` decides). Every other file is left
|
||||
// untouched, byte for byte, so the not-collapsed path is exactly what the backend
|
||||
// produced. Must run BEFORE stampCaptureSample, which measures the landed file.
|
||||
// A Failed outcome is ALSO logged to the console here, because a successful capture's
|
||||
// CaptureResult::message is not printed by any caller — the return value alone would
|
||||
// leave a genuine I/O failure indistinguishable from a legitimately stereo capture.
|
||||
// `consoleLabel` matches each caller's own console-prefix convention (offline:
|
||||
// "ReaSampler capture"; realtime: "ReaSampler realtime capture").
|
||||
MonoCollapseOutcome collapseCapturedFileToMono(const std::string& absolutePath,
|
||||
const char* consoleLabel = "ReaSampler capture");
|
||||
|
||||
// Stamps the metadata shared by both backends onto `s`: trackGuids (echoed from the
|
||||
// request) + channelCount (measured from the produced file's `fmt`; 0/unknown as the
|
||||
// fallback for a file that cannot be parsed — never the request's value, which is
|
||||
// always 2 and was never actually measured), resolved sampleRate (request rate,
|
||||
// else PROJECT_SRATE from `rateProj`), captureTempo, the capture-start time signature
|
||||
// (TimeMap_GetTimeSigAtTime against `timeSigProj` — offline passes nullptr for the
|
||||
// active project, realtime pins the record's own project), the WAV-aware
|
||||
// contentHash of `absolutePath` (left empty when unreadable), and createdTimestamp.
|
||||
|
||||
@@ -54,9 +54,9 @@ namespace reasampler::capture {
|
||||
// captureAndIndexOne so every precision invariant holds; nothing lands in the
|
||||
// arrange (load-bearing principle).
|
||||
//
|
||||
// Each unit's baseName carries its ordinal ("item-1", "item-2", ...) so two units
|
||||
// in one batch never share a stem, and makeUniqueTag's per-session monotonic
|
||||
// counter keeps same-second units across batches from colliding too.
|
||||
// Each unit is named after its own source track and carries its batch ordinal, so two
|
||||
// units in one batch read apart even when they came off the same track; makeUniqueTag's
|
||||
// per-session monotonic counter keeps same-second units across batches from colliding.
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -214,12 +214,19 @@ void RunBatchCaptureItems(ReaSamplerSession& session)
|
||||
src.startSeconds = unit.startSeconds;
|
||||
src.endSeconds = unit.endSeconds;
|
||||
src.sourceTracks.push_back(u.track);
|
||||
// The unit's range IS this item's own extent (read above from
|
||||
// D_POSITION/D_LENGTH) and it is the only selected item, so the
|
||||
// selected-items render source prints exactly it — batch keeps the
|
||||
// one-sample-per-item-at-item-extent semantics, unchanged.
|
||||
src.itemExtentIsWindow = true;
|
||||
src.trackNames.push_back(trackName(u.track));
|
||||
if (std::string g = guidString(u.track); !g.empty())
|
||||
src.trackGuids.push_back(std::move(g));
|
||||
|
||||
const std::string baseName = "item-" + std::to_string(unit.ordinal);
|
||||
const CaptureName name =
|
||||
captureNameFor(src.trackNames, unit.ordinal, "item");
|
||||
CaptureResult res = captureAndIndexOne(
|
||||
session, CaptureScope::Item, src, baseName,
|
||||
session, CaptureScope::Item, src, name,
|
||||
unit.startSeconds, unit.endSeconds);
|
||||
|
||||
const bool ok = (res.status == CaptureStatus::Ok);
|
||||
@@ -278,12 +285,14 @@ void RunBatchCaptureRazor(ReaSamplerSession& session)
|
||||
src.startSeconds = unit.startSeconds;
|
||||
src.endSeconds = unit.endSeconds;
|
||||
src.sourceTracks.push_back(tr);
|
||||
src.trackNames.push_back(trackName(tr));
|
||||
if (std::string g = guidString(tr); !g.empty())
|
||||
src.trackGuids.push_back(std::move(g));
|
||||
|
||||
const std::string baseName = "razor-" + std::to_string(unit.ordinal);
|
||||
const CaptureName name =
|
||||
captureNameFor(src.trackNames, unit.ordinal, "razor");
|
||||
CaptureResult res = captureAndIndexOne(
|
||||
session, CaptureScope::Track, src, baseName,
|
||||
session, CaptureScope::Track, src, name,
|
||||
unit.startSeconds, unit.endSeconds);
|
||||
|
||||
const bool ok = (res.status == CaptureStatus::Ok);
|
||||
@@ -392,6 +401,10 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
|
||||
req.sampleRate = recipe->sampleRate;
|
||||
req.channelCount = recipe->channelCount;
|
||||
req.bitDepth = WavBitDepth::Float32;
|
||||
// orig->displayName is the ORIGINAL capture's label (recapture preserves identity, it
|
||||
// does not re-mint it — see this function's header comment), so the regenerated file's
|
||||
// stem carries the original capture's stamp, not this render's — do not read it as a
|
||||
// render timestamp.
|
||||
req.baseName = orig->displayName.empty() ? "recapture" : orig->displayName;
|
||||
req.trackGuids = recipe->trackGuids;
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
#include "shell/persist/session.h" // ReaSamplerSession
|
||||
#include "shell/capture/insert.h" // runInsert / InsertRequest
|
||||
#include "shell/capture/realtime_lifecycle.h" // the in-flight realtime state
|
||||
#include "shell/capture/render_selection.h" // RenderTrackSelection
|
||||
#include "shell/capture/render_isolation.h" // UpstreamIsolation
|
||||
|
||||
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
|
||||
|
||||
@@ -180,7 +182,38 @@ CaptureResult renderOffline(CaptureScope scope,
|
||||
const std::vector<MediaTrack*>& sourceTracks,
|
||||
const CaptureRequest& req)
|
||||
{
|
||||
// Refused BEFORE anything is touched, so the refusal path has nothing to
|
||||
// restore. This is the seam BOTH a fresh capture and a recipe replay cross, so
|
||||
// neither can land the multi-stem render the predicate names — and both scopes
|
||||
// reach it, so a track capture and a ranged item capture refuse alike.
|
||||
if (isMultiTrackStemRender(req.sourceMode,
|
||||
static_cast<int>(sourceTracks.size())))
|
||||
{
|
||||
CaptureResult refused;
|
||||
refused.status = CaptureStatus::MultiTrackSelection;
|
||||
refused.message = multiTrackRefusalMessage(scope);
|
||||
return refused;
|
||||
}
|
||||
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
// A selected-tracks render prints the DAW's track selection, so the request's
|
||||
// own tracks must BE that selection for the render — the plain track scope
|
||||
// already resolved them from the live selection (identity), but a ranged item
|
||||
// capture and a re-capture-from-source both name tracks the user has not
|
||||
// selected. Every guard below outlives the render call and restores on every path.
|
||||
std::optional<RenderTrackSelection> selection;
|
||||
std::optional<UpstreamIsolation> isolation;
|
||||
if (req.sourceMode == SourceMode::SelectedTracks)
|
||||
{
|
||||
selection.emplace(sourceTracks);
|
||||
// An ITEM capture routed through the tracks source would otherwise print
|
||||
// everything upstream of the track — folder children, receives — which the
|
||||
// selected-items source excluded. The refusal above is what makes the single
|
||||
// source track here the whole set. Track scope is left alone: a folder
|
||||
// parent's own output IS its children summed.
|
||||
if (scope == CaptureScope::Item && !sourceTracks.empty())
|
||||
isolation.emplace(sourceTracks.front());
|
||||
}
|
||||
FxBypassGuard fxGuard(scope, sourceTracks, proj);
|
||||
OfflineRenderBackend backend;
|
||||
return backend.capture(req);
|
||||
@@ -207,7 +240,7 @@ CaptureResult renderOffline(CaptureScope scope,
|
||||
CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
||||
CaptureScope scope,
|
||||
const ResolvedSource& src,
|
||||
const std::string& baseName,
|
||||
const CaptureName& name,
|
||||
double startSeconds,
|
||||
double endSeconds)
|
||||
{
|
||||
@@ -217,7 +250,7 @@ CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
||||
const TailSetting tail = bankPanelTailSetting();
|
||||
|
||||
CaptureRequest req;
|
||||
req.sourceMode = sourceModeForScope(scope);
|
||||
req.sourceMode = sourceModeForScope(scope, src.itemExtentIsWindow);
|
||||
req.startSeconds = startSeconds; // exact bounds — no rounding
|
||||
req.endSeconds = endSeconds;
|
||||
req.wetDry = 1.0; // wet post the FX left enabled by the scope
|
||||
@@ -226,7 +259,8 @@ CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
||||
req.sampleRate = 0; // follow project rate
|
||||
req.channelCount = 2;
|
||||
req.bitDepth = WavBitDepth::Float32; // deterministic, no dither
|
||||
req.baseName = baseName;
|
||||
req.baseName = name.stemBase;
|
||||
req.displayName = name.label;
|
||||
req.trackGuids = src.trackGuids; // recorded on the Sample (provenance)
|
||||
|
||||
// M10: compute provenance BEFORE the FxBypassGuard neutralizes the in-scope chain —
|
||||
@@ -284,7 +318,12 @@ std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def)
|
||||
return {};
|
||||
}
|
||||
|
||||
CaptureResult res = captureAndIndexOne(session, def.scope, src, def.baseName,
|
||||
// The scope literal survives only as the fallback for a source whose name could not
|
||||
// be read at all — the source track names the capture on every reachable path.
|
||||
const CaptureName name =
|
||||
captureNameFor(src.trackNames, /*ordinal=*/0, def.baseName);
|
||||
|
||||
CaptureResult res = captureAndIndexOne(session, def.scope, src, name,
|
||||
src.startSeconds, src.endSeconds);
|
||||
if (res.status != CaptureStatus::Ok)
|
||||
{
|
||||
@@ -402,7 +441,10 @@ void RunCaptureRealtimeTrack(ReaSamplerSession& session)
|
||||
req.sampleRate = 0; // follow project rate
|
||||
req.channelCount = 2;
|
||||
req.bitDepth = WavBitDepth::Float32;
|
||||
req.baseName = "realtime";
|
||||
const CaptureName name =
|
||||
captureNameFor(src.trackNames, /*ordinal=*/0, "realtime");
|
||||
req.baseName = name.stemBase;
|
||||
req.displayName = name.label;
|
||||
req.trackGuids = src.trackGuids; // provenance on the Sample
|
||||
|
||||
CaptureResult failure;
|
||||
|
||||
@@ -40,7 +40,7 @@ CaptureResult renderOffline(CaptureScope scope,
|
||||
CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
||||
CaptureScope scope,
|
||||
const ResolvedSource& src,
|
||||
const std::string& baseName,
|
||||
const CaptureName& name,
|
||||
double startSeconds,
|
||||
double endSeconds);
|
||||
|
||||
|
||||
@@ -184,6 +184,11 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
|
||||
request.endSeconds);
|
||||
}
|
||||
|
||||
// Channel-domain rewrite, after the frame-domain trim so it acts on the final
|
||||
// frame set; it preserves the frame count, so the trimmed length above still holds.
|
||||
const MonoCollapseOutcome collapseOutcome =
|
||||
collapseCapturedFileToMono(destPath, "ReaSampler realtime capture");
|
||||
|
||||
// Pure recorded-capture -> Sample mapping (identity, bounds echo, tier).
|
||||
RecordedCapture cap;
|
||||
cap.relativePath = paths.relativePath;
|
||||
@@ -192,9 +197,11 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
|
||||
cap.startSeconds = request.startSeconds;
|
||||
cap.endSeconds = request.endSeconds;
|
||||
cap.wetDry = request.wetDry;
|
||||
cap.displayName = request.baseName;
|
||||
cap.displayName = request.label();
|
||||
cap.trackGuids = request.trackGuids;
|
||||
cap.channelCount = request.channelCount;
|
||||
// channelCount deliberately left unset here: stampCaptureSample measures it from
|
||||
// the file below. Echoing the request was this path's own defect — it parsed the
|
||||
// recorded layout for the trim and still reported the requested 2.
|
||||
|
||||
result.status = CaptureStatus::Ok;
|
||||
result.sample = sampleFromRecordedCapture(cap);
|
||||
@@ -219,7 +226,7 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
|
||||
std::to_string(request.startSeconds) + "s, " +
|
||||
std::to_string(request.endSeconds) + "s] (recorded " +
|
||||
std::to_string(result.sample.lengthSeconds) + "s) -> " +
|
||||
paths.relativePath;
|
||||
paths.relativePath + monoCollapseSuffix(collapseOutcome);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,10 @@
|
||||
// TAP: the hidden temp track receives a send FROM each selected source track
|
||||
// (CreateTrackSend(source, temp)) and records its own output (B_MAINSEND=0, so
|
||||
// it never sums back into the master — no feedback, no monitoring double).
|
||||
// Multiple selected tracks sum in the one temp track, matching how offline
|
||||
// track scope handles a multi-track selection.
|
||||
// Multiple selected tracks sum in the one temp track — a real mix, which is why
|
||||
// realtime accepts a multi-track selection where the offline track scope refuses
|
||||
// it (the offline render source is read as emitting one file per track, DAW-
|
||||
// unverified — see src/shell/capture/CLAUDE.md §Gotchas).
|
||||
//
|
||||
// Why this needs no FxBypassGuard: CreateTrackSend defaults to I_SENDMODE=0
|
||||
// (post-fader), which taps the source track after its own FX/fader/pan — its
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// render_isolation.cpp — see the header.
|
||||
//
|
||||
// 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; here they are extern.
|
||||
|
||||
#include "shell/capture/render_isolation.h"
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
#include "core/capture/track_topology.h" // directChildIndices
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_GetTrackNumSends
|
||||
#define REAPERAPI_WANT_GetTrackSendInfo_Value
|
||||
#define REAPERAPI_WANT_SetTrackSendInfo_Value
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
// GetTrackNumSends / *TrackSendInfo_Value category: < 0 selects the RECEIVE list
|
||||
// (SDK header ~3644, ~3676).
|
||||
constexpr int kReceivesCategory = -1;
|
||||
|
||||
} // namespace
|
||||
|
||||
UpstreamIsolation::UpstreamIsolation(MediaTrack* track)
|
||||
: track_(track)
|
||||
{
|
||||
if (!track_) return;
|
||||
|
||||
// One pass over the track list for both the depth deltas and this track's index;
|
||||
// the pure walk turns the flat delta list into the direct-child set. The master
|
||||
// is absent from GetTrack's index space (it is reached only via GetMasterTrack)
|
||||
// and has no send-to-parent to cut, so nothing is missed by ignoring it.
|
||||
const int total = CountTracks(nullptr);
|
||||
std::vector<MediaTrack*> tracks;
|
||||
std::vector<int> depths;
|
||||
tracks.reserve(static_cast<std::size_t>(total < 0 ? 0 : total));
|
||||
depths.reserve(static_cast<std::size_t>(total < 0 ? 0 : total));
|
||||
int parentIndex = -1;
|
||||
for (int i = 0; i < total; ++i)
|
||||
{
|
||||
MediaTrack* tr = GetTrack(nullptr, i);
|
||||
tracks.push_back(tr);
|
||||
depths.push_back(tr ? static_cast<int>(
|
||||
GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH"))
|
||||
: 0);
|
||||
if (tr == track_) parentIndex = i;
|
||||
}
|
||||
|
||||
for (int idx : directChildIndices(depths, parentIndex))
|
||||
{
|
||||
MediaTrack* child = tracks[static_cast<std::size_t>(idx)];
|
||||
if (!child) continue;
|
||||
// B_MAINSEND: "track sends audio to parent" (SDK header ~2238/~2957).
|
||||
children_.push_back({child, GetMediaTrackInfo_Value(child, "B_MAINSEND")});
|
||||
SetMediaTrackInfo_Value(child, "B_MAINSEND", 0.0);
|
||||
}
|
||||
|
||||
const int receives = GetTrackNumSends(track_, kReceivesCategory);
|
||||
receiveMutes_.reserve(static_cast<std::size_t>(receives < 0 ? 0 : receives));
|
||||
for (int i = 0; i < receives; ++i)
|
||||
{
|
||||
receiveMutes_.push_back(
|
||||
GetTrackSendInfo_Value(track_, kReceivesCategory, i, "B_MUTE"));
|
||||
SetTrackSendInfo_Value(track_, kReceivesCategory, i, "B_MUTE", 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
UpstreamIsolation::~UpstreamIsolation()
|
||||
{
|
||||
for (std::size_t i = 0; i < receiveMutes_.size(); ++i)
|
||||
SetTrackSendInfo_Value(track_, kReceivesCategory, static_cast<int>(i),
|
||||
"B_MUTE", receiveMutes_[i]);
|
||||
for (const ChildSend& c : children_)
|
||||
SetMediaTrackInfo_Value(c.track, "B_MAINSEND", c.mainSend);
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
// The transient silencing a ranged ITEM render needs: REAPER's selected-tracks
|
||||
// source prints everything upstream of the track — its folder children and its
|
||||
// receives — which an item capture must not hear. Stack RAII, restored on every
|
||||
// path.
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "shell/capture/capture.h" // MediaTrack fwd
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// RAII: for one render, cuts every route by which audio that is not `track`'s own
|
||||
// items reaches `track` — each direct folder child's send-to-parent (B_MAINSEND) and
|
||||
// each of `track`'s own receives (that receive's B_MUTE). Both are per-route levers,
|
||||
// so a child or a sender keeps its FX, its fader, and its routing elsewhere intact.
|
||||
// Every touched value is restored on every exit path; nothing is created or removed,
|
||||
// so the project is unchanged afterwards (non-destructive invariant).
|
||||
//
|
||||
// An overlapping item on `track` ITSELF is deliberately NOT isolated — the reason is
|
||||
// in src/shell/capture/CLAUDE.md.
|
||||
class UpstreamIsolation
|
||||
{
|
||||
public:
|
||||
explicit UpstreamIsolation(MediaTrack* track);
|
||||
~UpstreamIsolation();
|
||||
|
||||
UpstreamIsolation(const UpstreamIsolation&) = delete;
|
||||
UpstreamIsolation& operator=(const UpstreamIsolation&) = delete;
|
||||
|
||||
private:
|
||||
struct ChildSend {
|
||||
MediaTrack* track = nullptr;
|
||||
double mainSend = 0.0; // original B_MAINSEND
|
||||
};
|
||||
|
||||
MediaTrack* track_ = nullptr;
|
||||
std::vector<ChildSend> children_;
|
||||
std::vector<double> receiveMutes_; // original B_MUTE, indexed by receive index
|
||||
};
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,56 @@
|
||||
// render_selection.cpp — see the header.
|
||||
//
|
||||
// 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; here they are extern.
|
||||
|
||||
#include "shell/capture/render_selection.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_CountSelectedTracks
|
||||
#define REAPERAPI_WANT_GetSelectedTrack
|
||||
#define REAPERAPI_WANT_SetTrackSelected
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
namespace {
|
||||
|
||||
// Deselect every track in GetTrack's index space, then select exactly `tracks` — so
|
||||
// the resulting selection is the set, not the set unioned with whatever was already
|
||||
// selected. That index space holds no master track (the master is reached only via
|
||||
// GetMasterTrack); the header states this nowhere, so treat it as behavior rather
|
||||
// than a documented guarantee.
|
||||
void selectOnly(const std::vector<MediaTrack*>& tracks)
|
||||
{
|
||||
const int total = CountTracks(nullptr);
|
||||
for (int i = 0; i < total; ++i)
|
||||
if (MediaTrack* tr = GetTrack(nullptr, i))
|
||||
SetTrackSelected(tr, false);
|
||||
for (MediaTrack* tr : tracks)
|
||||
if (tr) SetTrackSelected(tr, true);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
RenderTrackSelection::RenderTrackSelection(const std::vector<MediaTrack*>& tracks)
|
||||
{
|
||||
if (tracks.empty()) return;
|
||||
|
||||
const int n = CountSelectedTracks(nullptr); // nullptr = active project
|
||||
for (int i = 0; i < n; ++i)
|
||||
if (MediaTrack* tr = GetSelectedTrack(nullptr, i))
|
||||
original_.push_back(tr);
|
||||
|
||||
selectOnly(tracks);
|
||||
applied_ = true;
|
||||
}
|
||||
|
||||
RenderTrackSelection::~RenderTrackSelection()
|
||||
{
|
||||
if (applied_) selectOnly(original_);
|
||||
}
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
// The transient DAW track selection a selected-tracks render requires. REAPER's
|
||||
// &128 source prints whatever tracks are selected, not the tracks the request
|
||||
// names, so the render owns the selection for its duration and hands it back.
|
||||
// The .cpp includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT.
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "shell/capture/capture.h" // MediaTrack fwd
|
||||
|
||||
namespace reasampler::capture {
|
||||
|
||||
// RAII: selects exactly `tracks`, restores the project's original track selection
|
||||
// on every exit path (non-destructive — selection flags only, no restructuring).
|
||||
// Two callers need this and neither has the right selection standing: a ranged
|
||||
// item capture renders through the items' tracks while the user's track selection
|
||||
// is unrelated, and re-capture-from-source resolves its tracks by GUID and selects
|
||||
// nothing at all. An empty `tracks` is a deliberate no-op — forcing an empty
|
||||
// selection would render silence.
|
||||
class RenderTrackSelection
|
||||
{
|
||||
public:
|
||||
explicit RenderTrackSelection(const std::vector<MediaTrack*>& tracks);
|
||||
~RenderTrackSelection();
|
||||
|
||||
RenderTrackSelection(const RenderTrackSelection&) = delete;
|
||||
RenderTrackSelection& operator=(const RenderTrackSelection&) = delete;
|
||||
|
||||
private:
|
||||
std::vector<MediaTrack*> original_;
|
||||
bool applied_ = false;
|
||||
};
|
||||
|
||||
} // namespace reasampler::capture
|
||||
@@ -7,9 +7,11 @@
|
||||
|
||||
#include "shell/capture/scope_resolve.h"
|
||||
|
||||
#include <cstring> // strnlen — bounded read of GetTrackName's buffer
|
||||
#include <filesystem> // project-dir derivation for provenance parent resolution
|
||||
#include <utility>
|
||||
|
||||
#include "core/capture/render_window.h" // itemExtentPrintsWindow
|
||||
#include "shell/capture/provenance_shell.h" // fxChainIdentity* / *SourceFiles / bankFileRefs
|
||||
#include "shell/capture/track_guid.h" // guidString
|
||||
|
||||
@@ -19,11 +21,14 @@
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
|
||||
#define REAPERAPI_WANT_EnumProjects
|
||||
#define REAPERAPI_WANT_GetSetProjectInfo
|
||||
#define REAPERAPI_WANT_CountSelectedMediaItems
|
||||
#define REAPERAPI_WANT_GetSelectedMediaItem
|
||||
#define REAPERAPI_WANT_GetMediaItem_Track
|
||||
#define REAPERAPI_WANT_GetMediaItemInfo_Value
|
||||
#define REAPERAPI_WANT_CountSelectedTracks
|
||||
#define REAPERAPI_WANT_GetSelectedTrack
|
||||
#define REAPERAPI_WANT_GetTrackName
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler::capture {
|
||||
@@ -50,14 +55,27 @@ model::ProvenanceScope provenanceScopeFor(CaptureScope scope)
|
||||
// Collects the tracks that own the selected items (Item scope) into
|
||||
// out.sourceTracks (deduped) — these are the tracks whose FX must be bypassed so an
|
||||
// item capture hears take/item FX only. GUIDs recorded for provenance.
|
||||
bool collectSelectedItemTracks(ResolvedSource& out)
|
||||
// extentStart/extentEnd come back as the union of the selected items' own extents:
|
||||
// INFERRED to be the window REAPER's selected-items render source would print
|
||||
// (SDK ~1990: D_POSITION/D_LENGTH in seconds) — unverified; see
|
||||
// src/core/capture/CLAUDE.md §Gotchas for what that inference rests on.
|
||||
bool collectSelectedItemTracks(ResolvedSource& out,
|
||||
double& extentStart, double& extentEnd)
|
||||
{
|
||||
const int n = CountSelectedMediaItems(nullptr);
|
||||
if (n <= 0) return false;
|
||||
bool anyExtent = false;
|
||||
for (int i = 0; i < n; ++i)
|
||||
{
|
||||
MediaItem* it = GetSelectedMediaItem(nullptr, i);
|
||||
if (!it) continue;
|
||||
const double pos = GetMediaItemInfo_Value(it, "D_POSITION");
|
||||
const double len = GetMediaItemInfo_Value(it, "D_LENGTH");
|
||||
if (!anyExtent) { extentStart = pos; extentEnd = pos + len; anyExtent = true; }
|
||||
else {
|
||||
if (pos < extentStart) extentStart = pos;
|
||||
if (pos + len > extentEnd) extentEnd = pos + len;
|
||||
}
|
||||
MediaTrack* tr = GetMediaItem_Track(it);
|
||||
if (!tr) continue;
|
||||
// Dedup: several selected items can share a track.
|
||||
@@ -65,14 +83,39 @@ bool collectSelectedItemTracks(ResolvedSource& out)
|
||||
for (MediaTrack* t : out.sourceTracks) if (t == tr) { seen = true; break; }
|
||||
if (seen) continue;
|
||||
out.sourceTracks.push_back(tr);
|
||||
out.trackNames.push_back(trackName(tr));
|
||||
std::string g = guidString(tr);
|
||||
if (!g.empty()) out.trackGuids.push_back(std::move(g));
|
||||
}
|
||||
return !out.sourceTracks.empty();
|
||||
}
|
||||
|
||||
// The header says only that PROJECT_SRATE is IGNORED unless PROJECT_SRATE_USE is set
|
||||
// (SDK ~3064); that it READS 0 on a project which never pinned a rate is an
|
||||
// inference, unverified. Either way a non-positive answer is passed through as
|
||||
// "unknown", which the pure window comparison handles by falling back to exact
|
||||
// equality.
|
||||
int projectSampleRate()
|
||||
{
|
||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||
if (!proj) return 0;
|
||||
return static_cast<int>(GetSetProjectInfo(proj, "PROJECT_SRATE", 0.0, false));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string trackName(MediaTrack* tr)
|
||||
{
|
||||
if (!tr) return {};
|
||||
// Track names are user-typed and unbounded; 1 KB is far past any real one, and this
|
||||
// runs once per capture, not per frame.
|
||||
std::vector<char> buf(1024, '\0');
|
||||
if (!GetTrackName(tr, buf.data(), static_cast<int>(buf.size()))) return {};
|
||||
// Bounded construction: the SDK doesn't document NUL-termination within bufOut_sz, so
|
||||
// strnlen over the whole buffer (never past it) rather than trusting one.
|
||||
return std::string(buf.data(), strnlen(buf.data(), buf.size()));
|
||||
}
|
||||
|
||||
// Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of
|
||||
// start, end, envGuidString) and returns the union of parsed track-audio areas.
|
||||
// Reads only — never clears the razor selection.
|
||||
@@ -120,6 +163,7 @@ bool collectSelectedTracks(ResolvedSource& out)
|
||||
MediaTrack* tr = GetSelectedTrack(nullptr, i);
|
||||
if (!tr) continue;
|
||||
out.sourceTracks.push_back(tr);
|
||||
out.trackNames.push_back(trackName(tr));
|
||||
std::string g = guidString(tr);
|
||||
if (!g.empty()) out.trackGuids.push_back(std::move(g));
|
||||
}
|
||||
@@ -128,10 +172,11 @@ bool collectSelectedTracks(ResolvedSource& out)
|
||||
|
||||
bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why)
|
||||
{
|
||||
double itemExtentStart = 0.0, itemExtentEnd = 0.0;
|
||||
switch (scope)
|
||||
{
|
||||
case CaptureScope::Item:
|
||||
if (!collectSelectedItemTracks(out)) {
|
||||
if (!collectSelectedItemTracks(out, itemExtentStart, itemExtentEnd)) {
|
||||
why = "select at least one media item"; return false;
|
||||
}
|
||||
break;
|
||||
@@ -141,7 +186,13 @@ bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& wh
|
||||
}
|
||||
break;
|
||||
}
|
||||
return resolveRange(out.startSeconds, out.endSeconds, why);
|
||||
if (!resolveRange(out.startSeconds, out.endSeconds, why)) return false;
|
||||
|
||||
if (scope == CaptureScope::Item)
|
||||
out.itemExtentIsWindow = itemExtentPrintsWindow(
|
||||
out.startSeconds, out.endSeconds, itemExtentStart, itemExtentEnd,
|
||||
projectSampleRate());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Empty for an unsaved project (EnumProjects writes an empty .rpp path), which
|
||||
|
||||
@@ -34,6 +34,17 @@ struct ResolvedSource
|
||||
double endSeconds = 0.0;
|
||||
std::vector<MediaTrack*> sourceTracks;
|
||||
std::vector<std::string> trackGuids;
|
||||
|
||||
// Parallel to sourceTracks (one entry per track, in the same order) — the names the
|
||||
// capture is labeled and filed after. trackGuids is NOT parallel: an unreadable GUID
|
||||
// is dropped there, while an unreadable name still holds its track's slot.
|
||||
std::vector<std::string> trackNames;
|
||||
|
||||
// Item scope only: does the selected items' own extent already print
|
||||
// [startSeconds, endSeconds)? Feeds sourceModeForScope. Defaults false so a
|
||||
// hand-built source fails closed to the time-bounded render — a caller whose
|
||||
// range IS the item extent (batch item capture) says so explicitly.
|
||||
bool itemExtentIsWindow = false;
|
||||
};
|
||||
|
||||
// Reads every track's P_RAZOREDITS and returns the union of parsed track-audio
|
||||
@@ -44,9 +55,19 @@ bool resolveRazorRange(double& start, double& end);
|
||||
// selection. Returns false with a reason when neither yields a non-empty range.
|
||||
bool resolveRange(double& start, double& end, std::string& why);
|
||||
|
||||
// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs.
|
||||
// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs + names.
|
||||
bool collectSelectedTracks(ResolvedSource& out);
|
||||
|
||||
// The track's display name. GetTrackName (SDK header ~3626) is used rather than P_NAME
|
||||
// because it already answers REAPER's own convention for an unnamed track ("Track N"),
|
||||
// which is exactly the deterministic fallback a capture label wants; P_NAME would hand
|
||||
// back an empty string instead. `[verify]` the SDK header states neither that the read is
|
||||
// non-mutating nor what a `false` return means; the call site treats it as read-only and
|
||||
// treats `false` (or a `true` with an untouched buffer) the same way — an empty name, which
|
||||
// falls through to the scope literal fallback either way, so both readings are safe.
|
||||
// Callers that build a ResolvedSource by hand (batch capture) use this directly.
|
||||
std::string trackName(MediaTrack* tr);
|
||||
|
||||
// Resolves the source for a scope: the selection tracks (item/track), plus the
|
||||
// inferred range. Returns false with a reason on nothing to do.
|
||||
bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why);
|
||||
|
||||
@@ -148,8 +148,9 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
// no-play — no crash, no retry loop.
|
||||
if (const SelectedSample* sel = findRef(refs, selId)) {
|
||||
// Auto-default: channelModeFor computes the mode from the loaded capture's channel
|
||||
// count (always 2 for extension captures; mono only for ingest-imported mono files).
|
||||
// An unknown count (0) or explicit user choice keeps the mode.
|
||||
// count — 1 for an ingested mono file or a capture whose channels came out
|
||||
// bit-identical and collapsed, 2 otherwise. An unknown count (0) or an explicit
|
||||
// user choice keeps the mode.
|
||||
{
|
||||
std::lock_guard<std::mutex> cm(channelModeMutex_);
|
||||
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
|
||||
|
||||
+188
-134
@@ -1,10 +1,10 @@
|
||||
// panel_drag.cpp — the card-drag / hover state machine seam of the docked bank panel:
|
||||
// WM_MOUSEMOVE (hover + tooltip timing + the live drag), drop-target/gesture
|
||||
// classification, cursor cues, button-up drop dispatch, and right-click menu routing.
|
||||
// Its PURE mirror is core/ui/card_drag (gesture precedence + slot hit-test), with
|
||||
// core/ui/drag_out owning the OS-drag boundary decision — this shell supplies only the
|
||||
// live rects, modifier state, and side effects. Per-mouse-move work stays plain
|
||||
// free-function calls — no interface, no virtual dispatch.
|
||||
// Its PURE mirrors are core/ui/card_drag (in-grid precedence + slot hit-test) and
|
||||
// core/ui/drag_out (the out-of-client gesture law) — this shell supplies only the live
|
||||
// rects, modifier state, and side effects. Per-mouse-move work stays plain free-function
|
||||
// calls — no interface, no virtual dispatch.
|
||||
//
|
||||
// Compiled into the reaper_reasampler MODULE. No REAPER API functions are called
|
||||
// directly here; REAPER SDK types arrive via panel_state.h.
|
||||
@@ -16,8 +16,9 @@
|
||||
#include "shell/panel/panel_state.h"
|
||||
|
||||
#include "shell/bank_ops/bank_ops.h" // persistBankOp — shared undo-block wrapper
|
||||
#include "shell/actions/arrange_drop_win.h" // arrangeTimeAtScreenX / performArrangeDrop
|
||||
#include "shell/actions/drag_out_win.h" // OLE / SWELL drag-out initiation seam
|
||||
#include "shell/actions/instrument_drop_win.h" // resolveFxDropTarget / performInstrumentDrop
|
||||
#include "shell/actions/instrument_drop_win.h" // probeDropTarget / performInstrumentDrop
|
||||
|
||||
namespace reasampler::panel {
|
||||
|
||||
@@ -147,6 +148,102 @@ void applyDragCursor(CardGesture g) {
|
||||
SetCursor(LoadCursor(nullptr, idc));
|
||||
}
|
||||
|
||||
// The out-of-client half of the same thin lookup: pure class -> pure cue -> stock cursor. The
|
||||
// cue is set on EVERY move, so "will this work" is visible before the button comes up, and a
|
||||
// refusal is a cursor the user can see rather than a release that does nothing. Cursor-only by
|
||||
// design — the docked panel is usually not under the pointer during an out-of-client drag, so
|
||||
// panel status text would be invisible exactly when it is needed.
|
||||
void applyDropCue(DropCue cue) {
|
||||
const char* idc = nullptr;
|
||||
switch (cue) {
|
||||
case DropCue::Instrument: idc = IDC_HAND; break;
|
||||
case DropCue::ArrangeInsert: idc = IDC_IBEAM; break; // an insertion point on a timeline
|
||||
case DropCue::Refuse: idc = IDC_NO; break;
|
||||
case DropCue::OsOwned: return; // reached once per move resolving to OsHandoff,
|
||||
// right before handOffToOs is attempted; the OS
|
||||
// drag loop (once it actually starts) draws its
|
||||
// own copy cursor, so leave the cursor alone here
|
||||
case DropCue::Internal: return; // applyDragCursor owns the in-client cue
|
||||
case DropCue::None: return;
|
||||
}
|
||||
SetCursor(LoadCursor(nullptr, idc));
|
||||
}
|
||||
|
||||
// One independent evaluation of the drag's target: the resolved class plus the live facts its
|
||||
// outcome needs. Nothing is remembered between calls (core/ui/CLAUDE.md: "the drag-out law is
|
||||
// per-move and stateless").
|
||||
struct LiveDrop {
|
||||
DropClass cls = DropClass::None;
|
||||
MediaTrack* track = nullptr;
|
||||
int screenX = 0; // the evaluated point in screen coords; meaningful only outside the client
|
||||
};
|
||||
|
||||
LiveDrop resolveLiveDrop(int x, int y) {
|
||||
RECT cr{};
|
||||
GetClientRect(g_panel.hwnd, &cr);
|
||||
const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top};
|
||||
|
||||
DropContext ctx;
|
||||
ctx.drag = DragState{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()};
|
||||
ctx.singlePayload = g_panel.dragSampleIds.size() == 1;
|
||||
|
||||
LiveDrop out;
|
||||
// The SDK hit-test is evaluated ONLY outside the client rect (needsSurfaceProbe — exported
|
||||
// by the pure law so this gate can't drift from decideDropClass's own inside-client check),
|
||||
// so the common internal-drag path costs nothing. Unlike before, it runs for multi payloads
|
||||
// too — still per-mouse-move cold, and it is what gives a multi drag a defined outcome on
|
||||
// every surface.
|
||||
if (needsSurfaceProbe(x, y, client)) {
|
||||
POINT sp{x, y};
|
||||
ClientToScreen(g_panel.hwnd, &sp);
|
||||
const DropProbe probe = probeDropTarget(sp.x, sp.y);
|
||||
ctx.surface = probe.surface;
|
||||
ctx.haveTrack = probe.track != nullptr;
|
||||
out.track = probe.track;
|
||||
out.screenX = sp.x;
|
||||
}
|
||||
out.cls = decideDropClass(x, y, client, ctx);
|
||||
|
||||
// ArrangeInsert has no defined outcome once every armed sample is stale/missing — the same
|
||||
// gate handOffToOs applies via decideOsHandoff before an OS hand-off, so acceptance
|
||||
// criterion 7 (no silent no-op release) holds on this cell too, at both a cueing move and
|
||||
// the release itself (both call this function). Cheap: a few fs::exists checks, only
|
||||
// reached outside the client — already a cold path.
|
||||
if (out.cls == DropClass::ArrangeInsert && resolveDragPathsForOs().empty()) {
|
||||
out.cls = DropClass::Refuse;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The law's one irreversible transition: DoDragDrop takes mouse capture and runs its own modal
|
||||
// loop, so the internal drag must be fully wound down first, and only once the payload is known
|
||||
// to be hand-off-able. This runs on every qualifying move — do not memoize a failed attempt.
|
||||
//
|
||||
// Residual (accepted): once the pointer has left REAPER, dragging back INTO a REAPER window
|
||||
// mid-modal-loop delivers a CF_HDROP to REAPER's own file-import drop target rather than to our
|
||||
// gesture law. NOT confirmed by experiment — inferred from REAPER's handling of external file
|
||||
// drops, and the inferred outcome (an item at the drop point) coincides with what our own
|
||||
// arrange path would have done.
|
||||
void handOffToOs() {
|
||||
// Resolve BEFORE tearing anything down (the resolver reads the live drag payload), then let
|
||||
// the pure rule couple the two side effects: an unresolvable payload leaves the internal
|
||||
// drag intact rather than winding it down for a hand-off that never runs — a half-torn-down
|
||||
// drag reads as "the drag did nothing, try again". canInitiateDragOut extends the same
|
||||
// coupling to the OS-readiness failures the pure decision cannot see.
|
||||
const std::vector<std::string> paths = resolveDragPathsForOs();
|
||||
if (!ui::decideOsHandoff(paths).handOffToOs || !canInitiateDragOut(paths)) {
|
||||
applyDropCue(DropCue::Refuse);
|
||||
invalidatePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (GetCapture() == g_panel.hwnd) ReleaseCapture();
|
||||
resetDragState();
|
||||
invalidatePanel();
|
||||
|
||||
initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows
|
||||
}
|
||||
|
||||
// Resolves the topmost INTERACTIVE element under client (x, y) for hover feedback,
|
||||
// mirroring handleClick's precedence exactly (so the element that lights on hover is
|
||||
// the one a click would hit). Returns HoverKind::None for the grid / dead space (the
|
||||
@@ -259,80 +356,25 @@ void onMouseMove(int x, int y) {
|
||||
}
|
||||
}
|
||||
if (g_panel.dragging) {
|
||||
// Inside the client rect it stays the internal bank-to-bank drag. Once it LEAVES,
|
||||
// drag_out::decideGesture splits three ways: single-capture over REAPER's OWN UI ->
|
||||
// InstrumentDrop; multi-capture or fully outside REAPER -> OsDrag; inside -> Internal.
|
||||
RECT cr{};
|
||||
GetClientRect(g_panel.hwnd, &cr);
|
||||
const PanelClientRect client{cr.left, cr.top, cr.right - cr.left, cr.bottom - cr.top};
|
||||
const bool inside = (x >= cr.left && x < cr.right && y >= cr.top && y < cr.bottom);
|
||||
// Re-resolved from scratch every move — core/ui/CLAUDE.md, "the drag-out law is
|
||||
// per-move and stateless."
|
||||
const LiveDrop live = resolveLiveDrop(x, y);
|
||||
|
||||
DragState st{/*dragging=*/true, /*hasArmedSamples=*/!g_panel.dragSampleIds.empty()};
|
||||
st.singleCapture = (g_panel.dragSampleIds.size() == 1);
|
||||
|
||||
// Only resolved OUTSIDE the client rect and for a single-capture payload, so the SDK
|
||||
// hit-test costs nothing on the common internal-drag path.
|
||||
FxDropTarget fx;
|
||||
if (!inside && st.singleCapture) {
|
||||
POINT sp{x, y};
|
||||
ClientToScreen(g_panel.hwnd, &sp);
|
||||
fx = resolveFxDropTarget(sp.x, sp.y);
|
||||
st.overReaperUi = fx.overReaperUi;
|
||||
}
|
||||
|
||||
const DragGesture gesture = decideGesture(x, y, client, st);
|
||||
|
||||
if (gesture == DragGesture::InstrumentDrop) {
|
||||
// Track the FX hotspot for the release. Unlike OsDrag this does NOT hand off to
|
||||
// a modal OS loop, so the internal-drag capture stays alive; clear any bank
|
||||
// drop-target highlight so the panel doesn't paint that cue too.
|
||||
g_panel.instrumentDropTrack = fx.valid() ? fx.track : nullptr;
|
||||
if (live.cls != DropClass::Internal) {
|
||||
// Anything but the in-grid drag (including an empty payload, which resolves to
|
||||
// None): clear the bank drop-target highlight so the panel does not paint a cue for
|
||||
// a drop that is not going there, then show whatever cue this class carries.
|
||||
g_panel.dropKind = DropKind::None;
|
||||
g_panel.dropBankId.clear();
|
||||
applyDropCue(cueForDropClass(live.cls)); // OsHandoff -> OsOwned, a documented no-op cue
|
||||
if (live.cls == DropClass::OsHandoff) {
|
||||
handOffToOs();
|
||||
return;
|
||||
}
|
||||
invalidatePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Left InstrumentDrop territory (back inside, or over a non-FX area): drop the FX target.
|
||||
g_panel.instrumentDropTrack = nullptr;
|
||||
|
||||
if (gesture == DragGesture::OsDrag) {
|
||||
if (g_panel.dragOsHandoffBlocked) {
|
||||
// Already known un-hand-off-able for this gesture (empty/unresolvable payload,
|
||||
// or the OS wasn't ready) — skip the fs::exists work and the readiness probe on
|
||||
// every move; keep the drag alive with no drop-target highlight.
|
||||
g_panel.dropKind = DropKind::None;
|
||||
g_panel.dropBankId.clear();
|
||||
invalidatePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the payload to existing on-disk paths BEFORE tearing down internal
|
||||
// drag state (the resolver reads dragSourceBankId / dragSampleIds), then let the
|
||||
// pure rule couple the two side effects: an unresolvable payload must leave the
|
||||
// internal drag intact rather than wind it down for a hand-off that never runs —
|
||||
// a half-torn-down drag reads to the user as "the drag did nothing, try again".
|
||||
// canInitiateDragOut extends the same coupling to the OS-readiness failure modes
|
||||
// (OLE unavailable, HDROP build failure) that the pure decision cannot see.
|
||||
const std::vector<std::string> paths = resolveDragPathsForOs();
|
||||
const ui::OsHandoff handoff = ui::decideOsHandoff(paths);
|
||||
if (!handoff.handOffToOs || !canInitiateDragOut(paths)) {
|
||||
g_panel.dragOsHandoffBlocked = true;
|
||||
g_panel.dropKind = DropKind::None;
|
||||
g_panel.dropBankId.clear();
|
||||
invalidatePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
// DoDragDrop runs its own modal loop and takes over mouse capture, so the internal
|
||||
// drag must be fully wound down first.
|
||||
if (GetCapture() == g_panel.hwnd) ReleaseCapture();
|
||||
resetDragState();
|
||||
invalidatePanel();
|
||||
|
||||
initiateDragOut(g_panel.hwnd, paths); // COPY-ONLY; blocking on Windows
|
||||
return;
|
||||
}
|
||||
// Inside the client: classify the in-grid gesture (reorder/replace vs move/copy) and
|
||||
// reflect it as a cursor cue. updateDropTarget first so dropKind/dropBankId are
|
||||
// current for classifyCardDrag's same-vs-other-bank decision.
|
||||
@@ -369,6 +411,39 @@ void doReplaceDrop(const std::string& newId, const std::string& oldId,
|
||||
invalidatePanel();
|
||||
}
|
||||
|
||||
// The in-client release: re-resolve the in-grid gesture at the drop point (modifiers may have
|
||||
// changed since the last move) and commit it. Bank-to-bank semantics are unchanged.
|
||||
void commitInternalDrop(int x, int y) {
|
||||
RECT cr{};
|
||||
GetClientRect(g_panel.hwnd, &cr);
|
||||
|
||||
updateDropTarget(x, y);
|
||||
classifyCardDrag(x, y);
|
||||
const CardGesture g = g_panel.cardGesture;
|
||||
|
||||
if (g == CardGesture::Reorder) {
|
||||
doReorderDrop(g_panel.dragPrimaryId, g_panel.dragSourceBankId, g_panel.dragTargetSlot);
|
||||
} else if (g == CardGesture::Replace) {
|
||||
// Replace targets the OCCUPANT of the target slot with the single grabbed card.
|
||||
const bool isBanks = g_panel.dragSourceRegion == Region::Banks;
|
||||
const int w = cr.right - cr.left, h = cr.bottom - cr.top;
|
||||
const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h);
|
||||
const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion);
|
||||
const std::string occupant = disp.idAtSlot(g_panel.dragTargetSlot);
|
||||
// Replace only makes sense for a single grabbed card over a DIFFERENT occupant.
|
||||
if (!occupant.empty() && occupant != g_panel.dragPrimaryId)
|
||||
doReplaceDrop(g_panel.dragPrimaryId, occupant, g_panel.dragSourceBankId);
|
||||
} else if (g == CardGesture::Move || g == CardGesture::Copy) {
|
||||
const std::string destId = dropTargetBankId();
|
||||
if (!destId.empty() && destId != g_panel.dragSourceBankId &&
|
||||
!g_panel.dragSampleIds.empty()) {
|
||||
transferSamples(g_panel.dragSampleIds, g_panel.dragSourceBankId, destId,
|
||||
/*copy=*/g == CardGesture::Copy);
|
||||
}
|
||||
}
|
||||
// CardGesture::None -> a release over dead space or the source-bank gap: no bank change.
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Clears all drag-state fields to their resting values. Called from every exit path
|
||||
@@ -382,75 +457,54 @@ void resetDragState() {
|
||||
g_panel.cardGesture = CardGesture::None;
|
||||
g_panel.dragTargetSlot = -1;
|
||||
g_panel.dragPrimaryId.clear();
|
||||
g_panel.instrumentDropTrack = nullptr;
|
||||
g_panel.dragOsHandoffBlocked = false;
|
||||
}
|
||||
|
||||
// Commits (or abandons) a drag on button-up. The resolved pure CardGesture decides:
|
||||
// * Reorder / Replace -> in-grid, within the source bank; one Ctrl-Z each.
|
||||
// * Move / Copy -> the cross-bank transfer (Ctrl = copy).
|
||||
// * None -> a drop over dead space / the source-bank gap = no-op.
|
||||
// OsDragOut is never seen here: the pointer-left-client handoff happens live in onMouseMove.
|
||||
// Commits (or abandons) a drag on button-up, over the class resolved AT THE RELEASE POINT. Every
|
||||
// DropClass either performs its outcome or is an explicit, already-cued refusal (core/ui/
|
||||
// CLAUDE.md: "no DropClass means nothing happens"). The switch below has no default so each case
|
||||
// is spelled out by hand — but this build sets no warning flags (root CLAUDE.md), so a missing
|
||||
// case is NOT a compile error here; exhaustiveness is a review discipline, not a compiler
|
||||
// guarantee.
|
||||
void onLBtnUp(int x, int y) {
|
||||
if (g_panel.dragging) {
|
||||
// Drop-and-load: a release over a valid FX hotspot instantiates a ReaSampler 9000 on
|
||||
// that track preloaded with the dragged capture — NOT a bank move, NOT an OS drag,
|
||||
// NEVER a timeline insert. Takes priority over the in-grid / cross-bank drop.
|
||||
// Single-capture only, so dragSampleIds.front() is the capture.
|
||||
//
|
||||
// Re-resolve at the RELEASE point rather than trusting only the hover-tracked target:
|
||||
// WM_MOUSEMOVE is coalesced, so a fast drag onto a dense surface (an FX chain row, a
|
||||
// container) can release over a hotspot no processed move ever reported. Gated on
|
||||
// !inside exactly like onMouseMove's live resolve — GetThingFromPoint can return a
|
||||
// track+FX hit at a release point that is still inside the panel's own client rect, and
|
||||
// an in-grid release must always go through the reorder/replace/move/copy path below,
|
||||
// never be reinterpreted as an FX add. Strictly additive otherwise — a release-point
|
||||
// miss falls back to the tracked target, so the hover-then-release-on-the-FX-button
|
||||
// path is untouched.
|
||||
const bool singleCapture = g_panel.dragSampleIds.size() == 1;
|
||||
MediaTrack* dropTrack = g_panel.instrumentDropTrack;
|
||||
RECT cr{};
|
||||
GetClientRect(g_panel.hwnd, &cr);
|
||||
const bool inside = (x >= cr.left && x < cr.right && y >= cr.top && y < cr.bottom);
|
||||
if (!inside && singleCapture) {
|
||||
POINT sp{x, y};
|
||||
ClientToScreen(g_panel.hwnd, &sp);
|
||||
const FxDropTarget fx = resolveFxDropTarget(sp.x, sp.y);
|
||||
if (fx.valid()) dropTrack = fx.track;
|
||||
}
|
||||
if (!inside && dropTrack && singleCapture) {
|
||||
const std::string sampleId = g_panel.dragSampleIds.front();
|
||||
performInstrumentDrop(dropTrack, buildInstrumentDropPreset(sampleId));
|
||||
// Read-only over the bank + arrange: the only mutations are the new FX instance +
|
||||
// its state (both undoable in performInstrumentDrop).
|
||||
} else {
|
||||
updateDropTarget(x, y);
|
||||
classifyCardDrag(x, y); // re-resolve at the drop point (modifiers may have changed)
|
||||
const CardGesture g = g_panel.cardGesture;
|
||||
// Resolved at the release point, not from what the (coalesced) moves last recorded —
|
||||
// core/ui/CLAUDE.md, "the drag-out law is per-move and stateless."
|
||||
const LiveDrop live = resolveLiveDrop(x, y);
|
||||
|
||||
if (g == CardGesture::Reorder) {
|
||||
doReorderDrop(g_panel.dragPrimaryId, g_panel.dragSourceBankId,
|
||||
g_panel.dragTargetSlot);
|
||||
} else if (g == CardGesture::Replace) {
|
||||
// Replace targets the OCCUPANT of the target slot with the single grabbed card.
|
||||
const bool isBanks = g_panel.dragSourceRegion == Region::Banks;
|
||||
const int w = cr.right - cr.left, h = cr.bottom - cr.top;
|
||||
const RECT region = isBanks ? banksRegionRect(w, h) : poolRegionRect(w, h);
|
||||
const RegionDisplay disp = regionDisplay(region, isBanks, g_panel.dragSourceRegion);
|
||||
const std::string occupant = disp.idAtSlot(g_panel.dragTargetSlot);
|
||||
// Replace only makes sense for a single grabbed card over a DIFFERENT occupant.
|
||||
if (!occupant.empty() && occupant != g_panel.dragPrimaryId)
|
||||
doReplaceDrop(g_panel.dragPrimaryId, occupant, g_panel.dragSourceBankId);
|
||||
} else if (g == CardGesture::Move || g == CardGesture::Copy) {
|
||||
const std::string destId = dropTargetBankId();
|
||||
if (!destId.empty() && destId != g_panel.dragSourceBankId &&
|
||||
!g_panel.dragSampleIds.empty()) {
|
||||
transferSamples(g_panel.dragSampleIds, g_panel.dragSourceBankId, destId,
|
||||
/*copy=*/g == CardGesture::Copy);
|
||||
}
|
||||
switch (live.cls) {
|
||||
case DropClass::InstrumentDrop: {
|
||||
// Adds a ReaSampler 9000 on that track preloaded with the capture — NOT a bank
|
||||
// move, NOT an OS drag, NEVER a timeline insert. singlePayload is what armed
|
||||
// this class, so front() IS the capture.
|
||||
const std::string sampleId = g_panel.dragSampleIds.front();
|
||||
performInstrumentDrop(live.track, buildInstrumentDropPreset(sampleId));
|
||||
break;
|
||||
}
|
||||
|
||||
case DropClass::ArrangeInsert:
|
||||
// The one branch here that places timeline items, and legitimately so — see
|
||||
// arrange_drop_win.h for why a deliberate drop is not a capture auto-insert.
|
||||
performArrangeDrop(live.track, arrangeTimeAtScreenX(live.screenX),
|
||||
resolveDragPathsForOs());
|
||||
break;
|
||||
|
||||
case DropClass::Internal:
|
||||
commitInternalDrop(x, y);
|
||||
break;
|
||||
|
||||
case DropClass::Refuse:
|
||||
case DropClass::OsHandoff:
|
||||
case DropClass::None:
|
||||
// Nothing to perform, and nothing silent about it: the refuse cursor has been
|
||||
// showing since the move that resolved this class. OsHandoff reaching release
|
||||
// usually means a live hand-off consumed the drag inside DoDragDrop's modal loop
|
||||
// and this call never ran — but WM_MOUSEMOVE coalescing can still deliver a
|
||||
// WM_LBUTTONUP with no intervening processed move (or right after a
|
||||
// canInitiateDragOut refusal), so this case CAN be reached with a stale
|
||||
// OsHandoff/Refuse class; the no-op here is correct either way.
|
||||
break;
|
||||
}
|
||||
// CardGesture::None -> no-op drop (dead space, or same-bank gap resolved to None).
|
||||
|
||||
SetCursor(LoadCursor(nullptr, IDC_ARROW)); // restore the arrow on drop
|
||||
if (GetCapture() == g_panel.hwnd) ReleaseCapture();
|
||||
} else if (g_panel.dragArmed) {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
#include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag
|
||||
#include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam
|
||||
#include "shell/capture/track_guid.h" // guidString — canonical track GUID key
|
||||
#include "shell/view/view.h" // applyMode / mintManagedLanes — mode activation
|
||||
#include "shell/view/view.h" // mintManagedLanes / transportBlocksModeSwitch
|
||||
|
||||
// New-content detection: enumerate live tracks + items and read fixed-lane state to
|
||||
// classify an item's lane as managed vs manual.
|
||||
@@ -44,6 +44,20 @@ TailSetting currentTail() {
|
||||
return g_panel.session ? g_panel.session->tail() : TailSetting{};
|
||||
}
|
||||
|
||||
// The registered activate action behind a footer mode segment, or 0 if the mode has none.
|
||||
// Routing the segment through the SAME action the Actions list fires is what gives a
|
||||
// panel-initiated switch the persist + repaint it used to skip; a mode with no such
|
||||
// action (the model is N-mode, the UI ships two) resolves to 0 here, which
|
||||
// modeSegmentEnabled reads as unroutable so the segment paints dead rather than live-
|
||||
// but-inert. Non-anonymous: panel_render.cpp resolves the same id to compute that bool.
|
||||
int modeActivateCommandId(const std::string& modeId) {
|
||||
ActionBarRow row{};
|
||||
if (modeId == kArrangeModeId) row.suffix = "VIEW_ACTIVATE_ARRANGE";
|
||||
else if (modeId == kDesignModeId) row.suffix = "VIEW_ACTIVATE_DESIGN";
|
||||
else return 0;
|
||||
return resolveBarCommandId(row);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Commits the current tail setting to ext state and marks the active project dirty so the
|
||||
@@ -299,11 +313,17 @@ void handleClick(int x, int y) {
|
||||
{
|
||||
const int seg = footerToggleSegmentHit(x, y, w, h);
|
||||
if (seg >= 0) {
|
||||
const std::vector<Mode>& modes = g_panel.session->view().modes().all();
|
||||
const ViewModeModel& view = g_panel.session->view();
|
||||
const std::vector<Mode>& modes = view.modes().all();
|
||||
if (seg < static_cast<int>(modes.size())) {
|
||||
applyMode(g_panel.session->view(),
|
||||
modes[static_cast<std::size_t>(seg)].id, nullptr);
|
||||
invalidatePanel();
|
||||
const std::string& id = modes[static_cast<std::size_t>(seg)].id;
|
||||
const bool isActive = id == view.activeModeId();
|
||||
const int cmd = modeActivateCommandId(id);
|
||||
// Disabled/dead rationale: core/ui/footer_bar.h. Claimed but inert, the
|
||||
// same shape a disabled toolbar row takes — never falls through to the grid.
|
||||
if (modeSegmentEnabled(isActive, g_panel.modeSwitchBlocked, cmd != 0)) {
|
||||
if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -534,6 +554,14 @@ void bankPanelRefresh() {
|
||||
|
||||
if (!panel::g_panel.open || !panel::g_panel.hwnd) return;
|
||||
|
||||
// Transport transitions are not ours to cause and REAPER offers no change callback,
|
||||
// so the mode segments' disabled state is polled here and repainted only on an edge.
|
||||
const bool blocked = transportBlocksModeSwitch(nullptr);
|
||||
if (blocked != panel::g_panel.modeSwitchBlocked) {
|
||||
panel::g_panel.modeSwitchBlocked = blocked;
|
||||
InvalidateRect(panel::g_panel.hwnd, nullptr, FALSE);
|
||||
}
|
||||
|
||||
// The custom hover-delay tooltip is driven off this poll tick (no dedicated timer) — if
|
||||
// a toolbar button has rested under the pointer past the delay, latch + repaint it.
|
||||
panel::maybeShowTooltip();
|
||||
|
||||
@@ -30,17 +30,33 @@ void drawCardMeta(LICE_IBitmap* bmp, const CellRect& rect, const Sample& s) {
|
||||
const std::string bars = formatBarsBeats(ml);
|
||||
const std::string secs = formatSecondsMs(s.lengthSeconds);
|
||||
|
||||
const int stripH = 12;
|
||||
const int pad = 3;
|
||||
const int y = rect.y + rect.height - stripH;
|
||||
const int y = rect.y + rect.height - kCardStripHeight;
|
||||
if (!bars.empty()) {
|
||||
const KitBox left{rect.x + pad, y, rect.width / 2 - pad, stripH};
|
||||
const KitBox left{rect.x + kCardStripPad, y,
|
||||
rect.width / 2 - kCardStripPad, kCardStripHeight};
|
||||
text(bmp, left, bars.c_str(), Font::Micro, Role::TextDim, Align::Left);
|
||||
}
|
||||
const KitBox right{rect.x + rect.width / 2, y, rect.width / 2 - pad, stripH};
|
||||
const KitBox right{rect.x + rect.width / 2, y,
|
||||
rect.width / 2 - kCardStripPad, kCardStripHeight};
|
||||
text(bmp, right, secs.c_str(), Font::ValueMono, Role::TextDim, Align::Right);
|
||||
}
|
||||
|
||||
// The capture's name across the top of the card. The kit clips with an end-ellipsis, so a
|
||||
// long name shortens ON SCREEN only — the stored label is never truncated. An entry with an
|
||||
// empty label (old banks predate this track and are not migrated) draws nothing.
|
||||
void drawCardName(LICE_IBitmap* bmp, const CellRect& rect, const Sample& s) {
|
||||
if (s.displayName.empty()) return;
|
||||
const CellRect strip = cardNameStrip(rect);
|
||||
if (strip.empty()) return;
|
||||
// Scrim behind the name: text/primary alone reads ~1:1 against the accent-lime waveform
|
||||
// fill a loud capture's peak reaches into this strip. kCardNameScrimAlpha (core/ui/theme.h)
|
||||
// is picked so bg/base composited at that alpha over accent/primary clears the 4.5:1 body
|
||||
// floor Font::Micro answers to — pinned in test_theme.cpp.
|
||||
LICE_FillRect(bmp, strip.x, strip.y, strip.width, strip.height,
|
||||
toLice(roleColor(Role::BgBase)), static_cast<float>(kCardNameScrimAlpha), 0);
|
||||
text(bmp, strip, s.displayName.c_str(), Font::Micro, Role::TextPrimary, Align::Left);
|
||||
}
|
||||
|
||||
void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env,
|
||||
bool selected, bool focused, bool hovered, const Sample* sample) {
|
||||
// Selected cards draw the normal cell surface, not an inverted fill — selection
|
||||
@@ -59,7 +75,10 @@ void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env,
|
||||
|
||||
drawWaveform(bmp, cell, env);
|
||||
|
||||
if (sample) drawCardMeta(bmp, rect, *sample);
|
||||
if (sample) {
|
||||
drawCardName(bmp, rect, *sample);
|
||||
drawCardMeta(bmp, rect, *sample);
|
||||
}
|
||||
}
|
||||
|
||||
KitBox toKitBox(const RECT& r) {
|
||||
@@ -112,13 +131,17 @@ void drawFooter(LICE_IBitmap* bmp, int w, int h) {
|
||||
const SegmentRect& s = segs[static_cast<std::size_t>(i)];
|
||||
const Mode& mode = modes[static_cast<std::size_t>(i)];
|
||||
const bool active = mode.id == activeId;
|
||||
// Disabled/dead rationale: core/ui/footer_bar.h.
|
||||
const bool live = modeSegmentEnabled(active, g_panel.modeSwitchBlocked,
|
||||
modeActivateCommandId(mode.id) != 0);
|
||||
const InteractionState state =
|
||||
active ? InteractionState::Active
|
||||
: hoverState(g_panel.hovered, HoverKind::ModeSegment, i);
|
||||
: (live ? hoverState(g_panel.hovered, HoverKind::ModeSegment, i)
|
||||
: InteractionState::Disabled);
|
||||
fillSurface(bmp, KitBox{s.x, s.y, s.width, s.height}, Role::BgCell, state);
|
||||
LICE_DrawRect(bmp, s.x, s.y, s.width, s.height,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
const Role tr = active ? Role::BgBase : Role::TextPrimary;
|
||||
const Role tr = active ? Role::BgBase : (live ? Role::TextPrimary : Role::TextDim);
|
||||
kitText(bmp, KitBox{s.x, s.y, s.width, s.height}, mode.displayName.c_str(),
|
||||
Font::Label, tr, Align::Center);
|
||||
}
|
||||
|
||||
@@ -71,9 +71,11 @@ using ui::CardGesture;
|
||||
using ui::CellRect;
|
||||
using ui::ClusterSpec;
|
||||
using ui::CursorCue;
|
||||
using ui::DragGesture;
|
||||
using ui::DragModifiers;
|
||||
using ui::DragState;
|
||||
using ui::DropClass;
|
||||
using ui::DropContext;
|
||||
using ui::DropCue;
|
||||
using ui::DropRegion;
|
||||
using ui::FooterBarLayout;
|
||||
using ui::FooterBarSpec;
|
||||
@@ -107,6 +109,7 @@ using ui::TooltipBox;
|
||||
using ui::TooltipSpec;
|
||||
using ui::applyClick;
|
||||
using ui::assemblePathList;
|
||||
using ui::cardNameStrip;
|
||||
using ui::clampTabScroll;
|
||||
using ui::columnsForWidth;
|
||||
using ui::computeBarSlots;
|
||||
@@ -118,9 +121,10 @@ using ui::computeSlotRectsForDrop;
|
||||
using ui::computeTabRects;
|
||||
using ui::computeTabStripLayout;
|
||||
using ui::computeTooltip;
|
||||
using ui::cueForDropClass;
|
||||
using ui::cursorForGesture;
|
||||
using ui::decideCardGesture;
|
||||
using ui::decideGesture;
|
||||
using ui::decideDropClass;
|
||||
using ui::formatBarsBeats;
|
||||
using ui::formatSecondsMs;
|
||||
using ui::hitTestActionBar;
|
||||
@@ -129,8 +133,13 @@ using ui::hitTestMenuButton;
|
||||
using ui::hitTestPruneButton;
|
||||
using ui::hitTestSlot;
|
||||
using ui::hitTestTabStrip;
|
||||
using ui::kCardNameScrimAlpha;
|
||||
using ui::kCardStripHeight;
|
||||
using ui::kCardStripPad;
|
||||
using ui::menuButtonReserve;
|
||||
using ui::modeSegmentEnabled;
|
||||
using ui::navigate;
|
||||
using ui::needsSurfaceProbe;
|
||||
using ui::roleColor;
|
||||
using ui::stripActionPrefix;
|
||||
using ui::tagButtonEnabled;
|
||||
@@ -291,6 +300,12 @@ struct PanelState {
|
||||
unsigned int hoverSinceTick = 0;
|
||||
bool tooltipShown = false;
|
||||
|
||||
// Polled on the OnTimer tick (REAPER exposes no transport-change callback). Feeds
|
||||
// modeSegmentEnabled (core/ui/footer_bar.h). Cached rather than read per paint AND
|
||||
// per click so the pixel the user saw and the click they made cannot disagree
|
||||
// within a tick.
|
||||
bool modeSwitchBlocked = false;
|
||||
|
||||
BankPanelFullHeight fullHeight = BankPanelFullHeight::Split;
|
||||
|
||||
// The named bank the banks region shows — distinct from the active/capture-target
|
||||
@@ -317,14 +332,9 @@ struct PanelState {
|
||||
CardGesture cardGesture = CardGesture::None;
|
||||
int dragTargetSlot = -1;
|
||||
|
||||
// While a single-capture drag is over REAPER's own UI, heading for a track's TCP FX
|
||||
// button: on release this adds a ReaSampler 9000 preloaded with the capture. Null
|
||||
// when the pointer is not over an FX button.
|
||||
MediaTrack* instrumentDropTrack = nullptr;
|
||||
|
||||
// Latched once an OsDrag hand-off resolves "cannot hand off" for this gesture — skips
|
||||
// re-running resolveDragPathsForOs (fs::exists per sample) each move. Cleared by resetDragState.
|
||||
bool dragOsHandoffBlocked = false;
|
||||
// NO out-of-client drop-target state is kept here on purpose (core/ui/CLAUDE.md: "the
|
||||
// drag-out law is per-move and stateless"). Do not reintroduce a remembered target or a
|
||||
// "blocked" latch.
|
||||
|
||||
// Authoritative tail setting lives in ReaSamplerSession, not here; panel reads it for
|
||||
// drawing and mutates via footer click / scroll-wheel. bankPanelTailSetting is the
|
||||
@@ -478,6 +488,9 @@ void startAudition(int idx);
|
||||
// panel_input.cpp — click/wheel/key routing, accelerator, new-content detection,
|
||||
// and the session-tail read/mutate helpers.
|
||||
TailSetting currentTail();
|
||||
// The registered activate action behind a footer mode segment, or 0 if unroutable.
|
||||
// Shared with panel_render.cpp so paint and click resolve the same id.
|
||||
int modeActivateCommandId(const std::string& modeId);
|
||||
void handleClick(int x, int y);
|
||||
bool handleWheel(int x, int y, int delta);
|
||||
void registerAccel();
|
||||
|
||||
@@ -11,11 +11,21 @@ decide membership or mode rules.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Never touches master or `B_MUTE`/`I_SOLO`.** The tool owns only visibility,
|
||||
`B_MAINSEND`, `I_FXEN`, and per-FX offline, on every managed leaf, tagged or
|
||||
untagged. User mute/solo survives every toggle untouched; the master track's
|
||||
visibility flags are never driven (the SDK forbids `B_SHOWINTCP`/`B_SHOWINMIXER`
|
||||
on master).
|
||||
- **Never touches master or `B_MUTE`; never LOSES solo.** The tool owns visibility,
|
||||
`B_MAINSEND`, `I_FXEN`, per-FX offline, and — on a real mode switch only —
|
||||
`I_SOLO`, on every managed leaf, tagged or untagged. `B_MUTE` is untouched
|
||||
absolutely. The master track is untouched absolutely: it is outside `GetTrack`'s
|
||||
index space, so it never enters the enumeration any of these writes iterate, and
|
||||
its visibility flags are never driven (the SDK forbids
|
||||
`B_SHOWINTCP`/`B_SHOWINMIXER` on master).
|
||||
- **Solo surfaces are disjoint per mode, cached not destroyed.** A real switch
|
||||
(target != active) reads every live track's raw `I_SOLO`, banks the non-zero
|
||||
values against the OUTGOING mode, clears them, and replays the incoming mode's
|
||||
banked values verbatim — solo-in-place and safe-solo variants included, never
|
||||
collapsed to a boolean. This is the same snapshot sense of non-destructive that
|
||||
park/restore already gives visibility and FX state: the user's solo is never
|
||||
lost, only parked with the mode it belongs to. A REAPPLY (target == active —
|
||||
tag/untag/show-both, project load) touches solo not at all.
|
||||
- **Parking a track** (inactive-mode leaf) drives `B_SHOWINTCP=0`, `B_SHOWINMIXER=0`
|
||||
(hide both panels), `B_MAINSEND=0` (out of mix), `I_FXEN=0` (FX bypassed), and
|
||||
`TrackFX_SetOffline(track, fx, true)` for each FX (reclaim CPU) — full CPU-park,
|
||||
@@ -72,7 +82,8 @@ applies the resulting lane state to live tracks.
|
||||
|
||||
## Modules
|
||||
|
||||
- `view` — Design View shell: snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN` + per-FX offline), restores from snapshot. **Never touches master or `B_MUTE`/`I_SOLO`.**
|
||||
- `view` — Design View shell: snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN` + per-FX offline), restores from snapshot. Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.**
|
||||
- `view_solo` — the `I_SOLO` read/write pair behind the per-mode solo surface, plus `clearTrackSolos`/`restoreTrackSolos`, the outgoing-clear and incoming-replay entry points `view` drives them through. Holds no policy: what to cache, clear, or replay is `core/view/solo_cache`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -83,3 +94,9 @@ applies the resulting lane state to live tracks.
|
||||
(fixed-lane mechanics, mode-aware capture placement, hidden-AND-silenced) are
|
||||
reflected in Invariants above; the membership/lane-ownership model concepts
|
||||
it also covers live in `core/view`'s Invariants.
|
||||
- REAPER's own undo restores live `I_SOLO` but not the model's solo cache or
|
||||
`activeModeId` — neither rolls back with a Ctrl-Z. An undo after a mode switch
|
||||
leaves the two out of step, and the next switch banks the undo-restored solos
|
||||
under whatever mode id is active at that point, not the one the user undid back
|
||||
to. Pre-existing: `snapshots_` already carries this same model-vs-undo split;
|
||||
the solo cache inherits it rather than introducing it. Not fixed here.
|
||||
|
||||
@@ -15,11 +15,14 @@
|
||||
|
||||
#include "shell/capture/item_read.h"
|
||||
#include "core/view/lane_keys.h"
|
||||
#include "core/view/solo_cache.h"
|
||||
#include "shell/capture/track_guid.h"
|
||||
#include "shell/view/view_solo.h"
|
||||
#include "core/view/view_tree.h"
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_CountTracks
|
||||
#define REAPERAPI_WANT_GetPlayStateEx
|
||||
#define REAPERAPI_WANT_GetTrack
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||
@@ -53,6 +56,10 @@ namespace {
|
||||
// I_FREEMODE value for fixed lanes. SDK: 0=normal, 1=free item positioning, 2=fixed lanes.
|
||||
constexpr int kFreeModeFixedLanes = 2;
|
||||
|
||||
// GetPlayStateEx bitmask. SDK: &1 playing, &2 paused, &4 recording — paused is
|
||||
// deliberately excluded, nothing is moving there.
|
||||
constexpr int kTransportMoving = 1 | 4;
|
||||
|
||||
// C_LANESCOLLAPSED=2: render a tool-split track like a normal single-lane
|
||||
// track showing only the playing lane (SDK: 1=collapsed, 2=hidden-lanes-exist
|
||||
// but displays as non-fixed-lane).
|
||||
@@ -377,11 +384,26 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan,
|
||||
|
||||
} // namespace
|
||||
|
||||
bool transportBlocksModeSwitch(ReaProject* proj) {
|
||||
return (GetPlayStateEx(proj) & kTransportMoving) != 0;
|
||||
}
|
||||
|
||||
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) {
|
||||
if (!model.modes().contains(targetModeId)) {
|
||||
return false; // reject before touching the project — no partial apply
|
||||
}
|
||||
|
||||
// The discriminator behind both halves of the contract in view.h. A gate placed
|
||||
// unconditionally here would break tagging and the project-load reapply during
|
||||
// playback; a solo round on every reapply would flicker the user's solo on every
|
||||
// membership edit.
|
||||
const std::string outgoingModeId = model.activeModeId();
|
||||
const bool realSwitch = targetModeId != outgoingModeId;
|
||||
|
||||
if (realSwitch && transportBlocksModeSwitch(proj)) {
|
||||
return false; // same fail-closed shape as the mode-exists guard above
|
||||
}
|
||||
|
||||
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
|
||||
std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
|
||||
FolderTree tree = buildFolderTree(entries);
|
||||
@@ -394,10 +416,22 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
||||
for (const auto& kv : handleByGuid) liveGuids.insert(kv.first);
|
||||
model.reconcile(liveGuids);
|
||||
|
||||
// Read before any write, and while activeModeId() still names the mode being left.
|
||||
const std::map<std::string, int> outgoingSolo =
|
||||
realSwitch ? view::soloedTracks(readTrackSolos(handleByGuid))
|
||||
: std::map<std::string, int>{};
|
||||
|
||||
TogglePlan plan = model.planToggle(tree, targetModeId);
|
||||
|
||||
Undo_BeginBlock2(proj);
|
||||
|
||||
// DISJOIN THE SOLO SURFACES. Both this clear and the replay after setActiveMode
|
||||
// ride the existing undo block — one mode toggle stays one Ctrl-Z.
|
||||
if (realSwitch) {
|
||||
model.soloCache().store(outgoingModeId, outgoingSolo);
|
||||
clearTrackSolos(handleByGuid, outgoingSolo);
|
||||
}
|
||||
|
||||
// PARK: snapshot before mutating, store into the model, then apply.
|
||||
for (const TrackPlan& tp : plan.park) {
|
||||
if (tp.flags.empty()) continue; // every op in a TrackPlan targets one track
|
||||
@@ -454,6 +488,17 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
|
||||
|
||||
model.setActiveMode(targetModeId);
|
||||
|
||||
// Replay + consume, before the single relayout below picks the change up. Dropped
|
||||
// against `visible` (computed above for parent visibility), not the park plan: a
|
||||
// folder parent can be hidden without being parked, and a hidden track must not
|
||||
// receive a replayed solo it carries no visible control to undo.
|
||||
if (realSwitch) {
|
||||
if (const std::map<std::string, int>* cached = model.soloCache().query(targetModeId)) {
|
||||
restoreTrackSolos(handleByGuid, *cached, liveGuids, visible);
|
||||
model.soloCache().clear(targetModeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Force REAPER to rebuild the TCP/MCP now rather than on the next user
|
||||
// interaction: TrackList_AdjustWindows(false) does the full relayout owed
|
||||
// when tracks appear/disappear; UpdateArrange() repaints.
|
||||
|
||||
+17
-5
@@ -3,7 +3,8 @@
|
||||
// ViewModeModel's pure planner, and applies the resulting flag / per-FX /
|
||||
// lane writes. The .cpp is the sole REAPER-facing TU here (CLAUDE.md contract:
|
||||
// only main.cpp defines the API pointers). See src/shell/view/CLAUDE.md for
|
||||
// the enforced invariants (never touch master/mute/solo, snapshot-based restore).
|
||||
// the enforced invariants (never touch master/mute, solo cached per mode and
|
||||
// never lost, snapshot-based restore).
|
||||
|
||||
#include <string>
|
||||
|
||||
@@ -14,10 +15,21 @@ class ReaProject;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Snapshots each about-to-park track's flags into `model`, runs planToggle,
|
||||
// applies park/restore writes plus parent visibility flags, then sets the
|
||||
// active mode. Wrapped in one Undo block. Returns false (no mutation) if
|
||||
// `targetModeId` isn't registered. `proj` == nullptr means the current project.
|
||||
// True while `proj`'s transport is playing or recording — the condition under which
|
||||
// applyMode refuses a real mode switch. Exposed so the panel can paint the footer's
|
||||
// [Arrange|Design] segments disabled BEFORE the click rather than only refusing on
|
||||
// it. `proj` is passed through to GetPlayStateEx as-is; the SDK documents proj=0
|
||||
// meaning "current project" for GetTrack but is silent on GetPlayStateEx, so
|
||||
// `nullptr` meaning the current project here is inferred by analogy, not confirmed.
|
||||
bool transportBlocksModeSwitch(ReaProject* proj);
|
||||
|
||||
// Snapshots each about-to-park track's flags into `model`, caches/clears the
|
||||
// outgoing mode's solo state and replays the incoming mode's, runs planToggle,
|
||||
// applies park/restore writes plus parent visibility flags, then sets the active
|
||||
// mode. Wrapped in one Undo block. Returns false (no mutation) if `targetModeId`
|
||||
// isn't registered, or if this is a real switch (target != active) while the
|
||||
// transport is running. A reapply (target == active) is never gated and never
|
||||
// touches solo. `proj` == nullptr means the current project.
|
||||
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj);
|
||||
|
||||
// Splits any track visible in more than one mode while carrying its own media
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// See view_solo.h. Compiled into the reaper_reasampler module; includes
|
||||
// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that).
|
||||
|
||||
#include "shell/view/view_solo.h"
|
||||
|
||||
#include <map>
|
||||
|
||||
#define REAPERAPI_MINIMAL
|
||||
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||
#include "reaper_plugin_functions.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
// SDK: int, 0=not soloed, 1=soloed, 2=soloed in place, 5=safe soloed,
|
||||
// 6=safe soloed in place. Documented under SetMediaTrackInfo_Value with no
|
||||
// read-only marker (unlike B_RECMON_IN_EFFECT in the same list), so it is settable.
|
||||
constexpr const char* kSoloParm = "I_SOLO";
|
||||
} // namespace
|
||||
|
||||
std::vector<view::TrackSolo> readTrackSolos(const TrackHandles& handles) {
|
||||
std::vector<view::TrackSolo> live;
|
||||
live.reserve(handles.size());
|
||||
for (const auto& [guid, tr] : handles) {
|
||||
if (!tr) continue;
|
||||
live.push_back(view::TrackSolo{
|
||||
guid, static_cast<int>(GetMediaTrackInfo_Value(tr, kSoloParm))});
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
void applySoloOps(const TrackHandles& handles, const std::vector<view::SoloOp>& ops) {
|
||||
if (ops.empty()) return; // the common case: nothing soloed, no project write
|
||||
|
||||
std::map<std::string, int> byGuid;
|
||||
for (const view::SoloOp& op : ops) byGuid[op.guid] = op.value;
|
||||
|
||||
for (const auto& [guid, tr] : handles) {
|
||||
if (!tr) continue;
|
||||
auto it = byGuid.find(guid);
|
||||
if (it == byGuid.end()) continue;
|
||||
SetMediaTrackInfo_Value(tr, kSoloParm, static_cast<double>(it->second));
|
||||
}
|
||||
}
|
||||
|
||||
void clearTrackSolos(const TrackHandles& handles, const std::map<std::string, int>& soloed) {
|
||||
std::vector<view::SoloOp> ops;
|
||||
ops.reserve(soloed.size());
|
||||
for (const auto& entry : soloed) ops.push_back(view::SoloOp{entry.first, 0});
|
||||
applySoloOps(handles, ops);
|
||||
}
|
||||
|
||||
void restoreTrackSolos(const TrackHandles& handles,
|
||||
const std::map<std::string, int>& cached,
|
||||
const std::set<std::string>& liveGuids,
|
||||
const std::set<std::string>& visibleGuids) {
|
||||
applySoloOps(handles, view::planSoloRestore(cached, liveGuids, visibleGuids));
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
// The REAPER read/write half of the per-mode solo surface. Every decision about what
|
||||
// to cache, clear, or replay is core/view/solo_cache; this pair only moves I_SOLO
|
||||
// between the live tracks and that pure plan.
|
||||
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "core/view/solo_cache.h"
|
||||
|
||||
// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header.
|
||||
class MediaTrack;
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The (GUID, handle) enumeration applyMode builds from GetTrack. GetTrack's index
|
||||
// space excludes the master track, so no solo read or write reachable through this
|
||||
// type can ever land on master.
|
||||
using TrackHandles = std::vector<std::pair<std::string, MediaTrack*>>;
|
||||
|
||||
std::vector<view::TrackSolo> readTrackSolos(const TrackHandles& handles);
|
||||
|
||||
// Applies each op to its live handle in ONE pass over `handles`; an op naming a GUID
|
||||
// absent from the enumeration is skipped (stale/deleted track).
|
||||
void applySoloOps(const TrackHandles& handles, const std::vector<view::SoloOp>& ops);
|
||||
|
||||
// The outgoing-mode clear: zeroes I_SOLO for every GUID in `soloed` (the just-banked
|
||||
// values applyMode is about to cache).
|
||||
void clearTrackSolos(const TrackHandles& handles, const std::map<std::string, int>& soloed);
|
||||
|
||||
// The incoming-mode replay: applies `cached` filtered through planSoloRestore's drop
|
||||
// rules (dead GUID, not visible in the incoming mode).
|
||||
void restoreTrackSolos(const TrackHandles& handles,
|
||||
const std::map<std::string, int>& cached,
|
||||
const std::set<std::string>& liveGuids,
|
||||
const std::set<std::string>& visibleGuids);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -7,6 +7,10 @@
|
||||
|
||||
#include "../src/core/version/app_version.h"
|
||||
|
||||
// REAPER-free header; pulled in for the ingest action's FOREVER-STABLE id suffixes, so
|
||||
// the composition assertions below check the strings that actually ship.
|
||||
#include "../src/shell/actions/ingest.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
@@ -151,6 +155,27 @@ static void testChannelQualifiedIdAndNameComposition() {
|
||||
}
|
||||
}
|
||||
|
||||
static void testMediaExplorerImportIdsAreDistinctAndChannelIsolated() {
|
||||
// The Media-Explorer import publishes into TWO action sections, and a custom_action
|
||||
// idStr must be unique across all sections — so the two entries carry two suffixes.
|
||||
// Both are FOREVER-STABLE per channel. Composed from the SHIPPED constants and checked
|
||||
// against spelled-out literals, so a suffix edit in ingest.h fails here.
|
||||
const std::string mainId = channelCommandId(kIngestImportMediaExplorerId);
|
||||
const std::string mxId = channelCommandId(kIngestImportMediaExplorerMxId);
|
||||
|
||||
if (isBeta()) {
|
||||
CHECK(mainId == "CEREBELLUM_REASAMPLER_BETA_INGEST_IMPORT_MEDIA_EXPLORER");
|
||||
CHECK(mxId == "CEREBELLUM_REASAMPLER_BETA_INGEST_IMPORT_MEDIA_EXPLORER_MX");
|
||||
CHECK(channelActionName("import Media Explorer file into selected track") ==
|
||||
"ReaSampler beta: import Media Explorer file into selected track");
|
||||
} else {
|
||||
CHECK(mainId == "CEREBELLUM_REASAMPLER_INGEST_IMPORT_MEDIA_EXPLORER");
|
||||
CHECK(mxId == "CEREBELLUM_REASAMPLER_INGEST_IMPORT_MEDIA_EXPLORER_MX");
|
||||
CHECK(channelActionName("import Media Explorer file into selected track") ==
|
||||
"ReaSampler: import Media Explorer file into selected track");
|
||||
}
|
||||
}
|
||||
|
||||
static void testStampClassifiesAsStampedOnOwnChannel() {
|
||||
// The V4 stamp-classifiability requirement: the value a channel WRITES (stampVersion())
|
||||
// must classify as Stamped when that same channel reads it back — on BOTH channels. A
|
||||
@@ -277,6 +302,7 @@ int main() {
|
||||
testVstIdentityStringsForkByChannel();
|
||||
testVstIdentityAndDataNamespaceShareOneChannel();
|
||||
testChannelQualifiedIdAndNameComposition();
|
||||
testMediaExplorerImportIdsAreDistinctAndChannelIsolated();
|
||||
testStampClassifiesAsStampedOnOwnChannel();
|
||||
testParseWellFormed();
|
||||
testParseRejectsMalformed();
|
||||
|
||||
@@ -489,6 +489,36 @@ static void testLegacyJsonDefaults() {
|
||||
}
|
||||
}
|
||||
|
||||
// A bank written before captures were named after their source track carries the literal
|
||||
// "item"/"track" label. Nothing migrates or relabels it: the label must survive load and
|
||||
// re-serialize byte-identically, so an old bank reads exactly as it did.
|
||||
static void testPreNamingSchemeLabelsSurviveUntouched() {
|
||||
const char* old =
|
||||
"{\"version\":1,\"samples\":["
|
||||
"{\"id\":\"cap-1700000000-1-item_1700000000-1.wav\","
|
||||
"\"displayName\":\"item\",\"relativePath\":\"reasampler_bank/item_1700000000-1.wav\","
|
||||
"\"contentHash\":\"h-old-a\"},"
|
||||
"{\"id\":\"cap-1700000000-2-track_1700000000-2.wav\","
|
||||
"\"displayName\":\"track\",\"relativePath\":\"reasampler_bank/track_1700000000-2.wav\","
|
||||
"\"contentHash\":\"h-old-b\"}]}";
|
||||
auto r = BankModel::deserialize(old);
|
||||
CHECK(r.has_value());
|
||||
if (!r) return;
|
||||
|
||||
const Sample* a = r->query("cap-1700000000-1-item_1700000000-1.wav");
|
||||
const Sample* b = r->query("cap-1700000000-2-track_1700000000-2.wav");
|
||||
CHECK(a && a->displayName == "item");
|
||||
CHECK(b && b->displayName == "track");
|
||||
|
||||
auto again = BankModel::deserialize(r->serialize());
|
||||
CHECK(again.has_value());
|
||||
CHECK(again && *again == *r);
|
||||
if (again) {
|
||||
const Sample* a2 = again->query("cap-1700000000-1-item_1700000000-1.wav");
|
||||
CHECK(a2 && a2->displayName == "item");
|
||||
}
|
||||
}
|
||||
|
||||
// S2 test case 4: boundary values for the seam fields are representable and
|
||||
// round-trip. rootNote 0 and 127 (the MIDI edges); loopStart == loopEnd (a valid
|
||||
// zero-length marker); a loop whose end sits at the file's last frame. Also asserts
|
||||
@@ -573,8 +603,26 @@ static void testSeamFieldsAdditiveInvariant() {
|
||||
CHECK(idx.query("id-z")->rootNote == 60); // move did not disturb seam fields
|
||||
}
|
||||
|
||||
// A collapsed capture is a 1-channel entry, and the JSON is the only thing carrying
|
||||
// that count across a project reload — the instrument's mono/stereo default reads it.
|
||||
static void testMonoChannelCountRoundTrip() {
|
||||
BankModel idx;
|
||||
Sample s = fullSample("mono");
|
||||
s.channelCount = 1;
|
||||
CHECK(idx.add(s) == AddResult::Added);
|
||||
|
||||
const std::string json = idx.serialize();
|
||||
CHECK(json.find("\"channelCount\":1") != std::string::npos);
|
||||
|
||||
auto back = BankModel::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back && back->query("id-mono") &&
|
||||
back->query("id-mono")->channelCount == 1);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFullFieldRoundTrip();
|
||||
testMonoChannelCountRoundTrip();
|
||||
testSerializeGoldenLiteral();
|
||||
testDedupByHash();
|
||||
testTierFilterAndMove();
|
||||
@@ -588,6 +636,7 @@ int main() {
|
||||
testIntegerOverflow();
|
||||
testEnumRangeValidation();
|
||||
testLegacyJsonDefaults();
|
||||
testPreNamingSchemeLabelsSurviveUntouched();
|
||||
testSeamFieldBoundaries();
|
||||
testSeamFieldsAdditiveInvariant();
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// Standalone tests for reasampler::capture_name — no REAPER, no framework. Covers the
|
||||
// name SHAPE (label vs file-stem base, multi-source marker, batch ordinal, discriminator)
|
||||
// and the awkward source names: empty, all-punctuation, non-ASCII, over-long, duplicate.
|
||||
//
|
||||
// The stem base is asserted through sanitizeStem here as well as raw, because the stem's
|
||||
// real contract is "survives the sanitizer as something filesystem-legal", not "equals
|
||||
// this string" — sanitizeStem is the function that has to hold, and it is capture_paths'.
|
||||
|
||||
#include "../src/core/capture/capture_name.h"
|
||||
#include "../src/core/capture/capture_paths.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::capture;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// A fixed stamp so every expectation below is a literal, not a re-derivation.
|
||||
static CaptureStamp stamp() { return CaptureStamp{8, 1, 14, 32}; }
|
||||
|
||||
static CaptureNameInputs inputsFor(std::vector<std::string> names,
|
||||
int ordinal = 0,
|
||||
const std::string& fallback = "item") {
|
||||
CaptureNameInputs in;
|
||||
in.sourceNames = std::move(names);
|
||||
in.stamp = stamp();
|
||||
in.ordinal = ordinal;
|
||||
in.fallback = fallback;
|
||||
return in;
|
||||
}
|
||||
|
||||
// --- the discriminator -------------------------------------------------------
|
||||
|
||||
static void testStampIsZeroPaddedMonthDayHourMinute() {
|
||||
CHECK(formatCaptureStamp(CaptureStamp{8, 1, 14, 32}) == "08-01 1432");
|
||||
CHECK(formatCaptureStamp(CaptureStamp{12, 25, 0, 5}) == "12-25 0005");
|
||||
}
|
||||
|
||||
static void testUnsetStampProducesNoDiscriminator() {
|
||||
// A failed clock read leaves the stamp zeroed; the label must degrade to the bare
|
||||
// name rather than render "00-00 0000".
|
||||
CHECK(formatCaptureStamp(CaptureStamp{}) == "");
|
||||
const CaptureNameInputs in{{"Bass"}, CaptureStamp{}, 0, "item"};
|
||||
CHECK(composeCaptureName(in).label == "Bass");
|
||||
}
|
||||
|
||||
// --- ordinary derivation ------------------------------------------------------
|
||||
|
||||
static void testOrdinaryNameLabelsAndFilesAfterTheTrack() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({"Bass"}));
|
||||
CHECK(n.label == "Bass 08-01 1432");
|
||||
CHECK(n.stemBase == "Bass");
|
||||
CHECK(sanitizeStem(n.stemBase) == "Bass");
|
||||
}
|
||||
|
||||
static void testTwoCapturesMinutesApartAreDistinguishable() {
|
||||
CaptureNameInputs a = inputsFor({"Bass"});
|
||||
CaptureNameInputs b = inputsFor({"Bass"});
|
||||
b.stamp.minute = 47;
|
||||
CHECK(composeCaptureName(a).label != composeCaptureName(b).label);
|
||||
CHECK(composeCaptureName(a).label == "Bass 08-01 1432");
|
||||
CHECK(composeCaptureName(b).label == "Bass 08-01 1447");
|
||||
}
|
||||
|
||||
static void testNameWithSpacesKeepsThemInTheLabelAndSanitizesInTheStem() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({"Lead Vox"}));
|
||||
CHECK(n.label == "Lead Vox 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "Lead_Vox");
|
||||
}
|
||||
|
||||
static void testSurroundingWhitespaceIsTrimmed() {
|
||||
// Untrimmed, this would file as "__Bass__" and read ragged on the card.
|
||||
const CaptureName n = composeCaptureName(inputsFor({" Bass "}));
|
||||
CHECK(n.label == "Bass 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "Bass");
|
||||
}
|
||||
|
||||
// --- awkward names ------------------------------------------------------------
|
||||
|
||||
static void testEmptyNameFallsBackToTheScopeLiteral() {
|
||||
// Unreachable in the DAW (GetTrackName answers "Track N" for an unnamed track), so
|
||||
// this pins the defensive path: the scope literal, never an empty label.
|
||||
const CaptureName n = composeCaptureName(inputsFor({""}, 0, "item"));
|
||||
CHECK(n.label == "item 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "item");
|
||||
}
|
||||
|
||||
static void testNoSourceAtAllFallsBackToTheScopeLiteral() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({}, 0, "track"));
|
||||
CHECK(n.label == "track 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "track");
|
||||
}
|
||||
|
||||
static void testEmptyNameAndEmptyFallbackStillYieldALegalStem() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({""}, 0, ""));
|
||||
CHECK(n.label == "capture 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "capture");
|
||||
}
|
||||
|
||||
static void testAllWhitespaceNameFallsBackToTheScopeLiteral() {
|
||||
// Distinct path from an empty name: trimmed() is what empties it, before truncation
|
||||
// ever runs.
|
||||
const CaptureName n = composeCaptureName(inputsFor({" "}, 0, "item"));
|
||||
CHECK(n.label == "item 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "item");
|
||||
}
|
||||
|
||||
static void testNameThatTruncatesToNothingFallsBackToCapture() {
|
||||
// Over-length and made entirely of a UTF-8 continuation byte (0x80-0xBF): not empty
|
||||
// pre-truncation, so it survives trimmed()/the fallback chain as a real name — but
|
||||
// truncateUtf8 backs off over continuation bytes and walks all the way to 0 (every byte
|
||||
// in the first kMaxSourceNameBytes is a continuation byte), which used to leave an
|
||||
// empty label.
|
||||
const std::string allContinuation(kMaxSourceNameBytes + 10, '\x80');
|
||||
const CaptureName n = composeCaptureName(inputsFor({allContinuation}));
|
||||
CHECK(n.label == "capture 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "capture");
|
||||
}
|
||||
|
||||
static void testUnnamedTrackUsesReaperTrackNConvention() {
|
||||
// What GetTrackName actually hands back for an unnamed track — the deterministic
|
||||
// fallback rides in as an ordinary name, no special case in the composer.
|
||||
const CaptureName n = composeCaptureName(inputsFor({"Track 3"}));
|
||||
CHECK(n.label == "Track 3 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "Track_3");
|
||||
}
|
||||
|
||||
static void testAllPunctuationNameKeepsTheLabelAndCollapsesTheStem() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({"***"}));
|
||||
CHECK(n.label == "*** 08-01 1432"); // the label is display-only; punctuation is fine
|
||||
CHECK(n.stemBase == "***");
|
||||
CHECK(sanitizeStem(n.stemBase) == "capture"); // nothing alnum survives
|
||||
}
|
||||
|
||||
static void testNonAsciiNameKeepsTheLabelAndCollapsesTheStem() {
|
||||
const std::string kana = "\xE3\x83\x99\xE3\x83\xBC\xE3\x82\xB9"; // UTF-8 "ベース"
|
||||
const CaptureName n = composeCaptureName(inputsFor({kana}));
|
||||
CHECK(n.label == kana + " 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "capture");
|
||||
}
|
||||
|
||||
static void testMixedAsciiAndNonAsciiKeepsTheAsciiPartInTheStem() {
|
||||
const std::string mixed = "Bass\xC3\xA9"; // "Bassé"
|
||||
const CaptureName n = composeCaptureName(inputsFor({mixed}));
|
||||
const std::string stem = sanitizeStem(n.stemBase);
|
||||
CHECK(stem.rfind("Bass", 0) == 0); // recognizable
|
||||
CHECK(stem != "capture"); // did not collapse
|
||||
}
|
||||
|
||||
static void testOverLongNameIsBoundedInBothLabelAndStem() {
|
||||
const std::string huge(400, 'x');
|
||||
const CaptureName n = composeCaptureName(inputsFor({huge}));
|
||||
CHECK(n.stemBase.size() == kMaxSourceNameBytes);
|
||||
CHECK(sanitizeStem(n.stemBase).size() == kMaxSourceNameBytes);
|
||||
// Label = bounded name + " MM-DD HHMM".
|
||||
CHECK(n.label.size() == kMaxSourceNameBytes + 11);
|
||||
}
|
||||
|
||||
static void testOverLongNonAsciiNameIsNotCutMidCharacter() {
|
||||
// 3-byte characters do not tile the 64-byte bound evenly, so a naive cut would leave
|
||||
// a truncated sequence in a label that goes on to be persisted as JSON.
|
||||
std::string kana;
|
||||
for (int i = 0; i < 60; ++i) kana += "\xE3\x83\x99"; // 180 bytes of "ベ"
|
||||
const CaptureName n = composeCaptureName(inputsFor({kana}));
|
||||
CHECK(n.stemBase.size() % 3 == 0);
|
||||
CHECK(n.stemBase.size() <= kMaxSourceNameBytes);
|
||||
CHECK(n.stemBase.size() > kMaxSourceNameBytes - 3); // took as much as fits
|
||||
}
|
||||
|
||||
static void testTwoTracksWithTheSameNameComposeIdentically() {
|
||||
// Deliberate: displayName is explicitly NOT unique, and stem uniqueness is
|
||||
// makeUniqueTag's job, not the composer's.
|
||||
const CaptureName a = composeCaptureName(inputsFor({"Bass"}));
|
||||
const CaptureName b = composeCaptureName(inputsFor({"Bass"}));
|
||||
CHECK(a.label == b.label);
|
||||
CHECK(a.stemBase == b.stemBase);
|
||||
}
|
||||
|
||||
// --- multi-source -------------------------------------------------------------
|
||||
|
||||
static void testMultiTrackSourceMarksTheExtraCount() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({"Bass", "Drums", "Keys"}));
|
||||
CHECK(n.label == "Bass +2 08-01 1432");
|
||||
CHECK(n.stemBase == "Bass+2");
|
||||
CHECK(sanitizeStem(n.stemBase) == "Bass_2");
|
||||
}
|
||||
|
||||
static void testMultiTrackSourceIgnoresUnnamedEntriesInTheCount() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({"Bass", ""}));
|
||||
CHECK(n.label == "Bass 08-01 1432"); // one real source, no marker
|
||||
}
|
||||
|
||||
static void testMultiTrackSourceNamesAfterTheFirstNamedTrack() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({"", "Drums", "Keys"}));
|
||||
CHECK(n.label == "Drums +1 08-01 1432");
|
||||
}
|
||||
|
||||
// --- batch ordinals -----------------------------------------------------------
|
||||
|
||||
static void testBatchOrdinalDistinguishesUnitsFromOneTrack() {
|
||||
const CaptureName a = composeCaptureName(inputsFor({"Bass"}, 1));
|
||||
const CaptureName b = composeCaptureName(inputsFor({"Bass"}, 2));
|
||||
CHECK(a.label == "Bass #1 08-01 1432");
|
||||
CHECK(b.label == "Bass #2 08-01 1432");
|
||||
CHECK(sanitizeStem(a.stemBase) == "Bass-1");
|
||||
CHECK(sanitizeStem(b.stemBase) == "Bass-2");
|
||||
}
|
||||
|
||||
static void testOrdinalZeroAddsNothing() {
|
||||
CHECK(composeCaptureName(inputsFor({"Bass"}, 0)).stemBase == "Bass");
|
||||
}
|
||||
|
||||
static void testOrdinalAndMultiSourceCompose() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({"Bass", "Drums"}, 3));
|
||||
CHECK(n.label == "Bass +1 #3 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "Bass_1-3");
|
||||
}
|
||||
|
||||
// --- stem legality across every awkward input ---------------------------------
|
||||
|
||||
static void testEveryAwkwardStemStaysFilesystemLegal() {
|
||||
const std::string kana = "\xE3\x83\x99\xE3\x83\xBC\xE3\x82\xB9";
|
||||
const std::vector<std::string> names = {
|
||||
"Bass", "", "***", kana, std::string(400, 'x'), "Lead Vox", "Track 3",
|
||||
"a/b\\c:d*e?f\"g<h>i|j",
|
||||
};
|
||||
for (const std::string& raw : names) {
|
||||
const std::string stem = sanitizeStem(composeCaptureName(inputsFor({raw})).stemBase);
|
||||
CHECK(!stem.empty());
|
||||
for (unsigned char c : stem) {
|
||||
const bool legal = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-';
|
||||
CHECK(legal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testStampIsZeroPaddedMonthDayHourMinute();
|
||||
testUnsetStampProducesNoDiscriminator();
|
||||
testOrdinaryNameLabelsAndFilesAfterTheTrack();
|
||||
testTwoCapturesMinutesApartAreDistinguishable();
|
||||
testNameWithSpacesKeepsThemInTheLabelAndSanitizesInTheStem();
|
||||
testSurroundingWhitespaceIsTrimmed();
|
||||
testEmptyNameFallsBackToTheScopeLiteral();
|
||||
testNoSourceAtAllFallsBackToTheScopeLiteral();
|
||||
testEmptyNameAndEmptyFallbackStillYieldALegalStem();
|
||||
testAllWhitespaceNameFallsBackToTheScopeLiteral();
|
||||
testNameThatTruncatesToNothingFallsBackToCapture();
|
||||
testUnnamedTrackUsesReaperTrackNConvention();
|
||||
testAllPunctuationNameKeepsTheLabelAndCollapsesTheStem();
|
||||
testNonAsciiNameKeepsTheLabelAndCollapsesTheStem();
|
||||
testMixedAsciiAndNonAsciiKeepsTheAsciiPartInTheStem();
|
||||
testOverLongNameIsBoundedInBothLabelAndStem();
|
||||
testOverLongNonAsciiNameIsNotCutMidCharacter();
|
||||
testTwoTracksWithTheSameNameComposeIdentically();
|
||||
testMultiTrackSourceMarksTheExtraCount();
|
||||
testMultiTrackSourceIgnoresUnnamedEntriesInTheCount();
|
||||
testMultiTrackSourceNamesAfterTheFirstNamedTrack();
|
||||
testBatchOrdinalDistinguishesUnitsFromOneTrack();
|
||||
testOrdinalZeroAddsNothing();
|
||||
testOrdinalAndMultiSourceCompose();
|
||||
testEveryAwkwardStemStaysFilesystemLegal();
|
||||
|
||||
if (g_fail == 0) std::printf("capture_name: all tests passed\n");
|
||||
else std::printf("capture_name: %d CHECK(s) FAILED\n", g_fail);
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
@@ -109,7 +109,51 @@ static void testSecondsMsNearWholeSecondCarry() {
|
||||
CHECK(r == "0.999" || r == "1.000");
|
||||
}
|
||||
|
||||
// --- the card's name strip ----------------------------------------------------
|
||||
|
||||
// The shipping cell size (panel_state.h's kGrid).
|
||||
static const Rect kCell{40, 100, 140, 84};
|
||||
|
||||
static void testNameStripSitsAcrossTheTopOfTheCell() {
|
||||
const Rect s = cardNameStrip(kCell);
|
||||
CHECK(s.x == kCell.x + kCardStripPad);
|
||||
CHECK(s.y == kCell.y + 1); // clear of the selection border
|
||||
CHECK(s.width == kCell.width - 2 * kCardStripPad);
|
||||
CHECK(s.height == kCardStripHeight);
|
||||
}
|
||||
|
||||
static void testNameStripNeverOverlapsTheLengthReadOut() {
|
||||
// The read-out occupies the bottom kCardStripHeight of the same cell.
|
||||
const Rect s = cardNameStrip(kCell);
|
||||
CHECK(s.bottom() <= kCell.bottom() - kCardStripHeight);
|
||||
}
|
||||
|
||||
static void testNameStripStaysInsideTheCell() {
|
||||
const Rect s = cardNameStrip(kCell);
|
||||
CHECK(s.x >= kCell.x);
|
||||
CHECK(s.right() <= kCell.right());
|
||||
CHECK(s.y >= kCell.y);
|
||||
CHECK(s.bottom() <= kCell.bottom());
|
||||
}
|
||||
|
||||
static void testNameStripSuppressedOnATooShortCell() {
|
||||
// Below three strip-heights the card would be text with a sliver of waveform — draw
|
||||
// no name rather than bury the thumbnail.
|
||||
CHECK(cardNameStrip(Rect{0, 0, 140, 3 * kCardStripHeight - 1}).empty());
|
||||
CHECK(!cardNameStrip(Rect{0, 0, 140, 3 * kCardStripHeight}).empty());
|
||||
}
|
||||
|
||||
static void testNameStripSuppressedOnATooNarrowCell() {
|
||||
CHECK(cardNameStrip(Rect{0, 0, 2 * kCardStripPad, 84}).empty());
|
||||
CHECK(cardNameStrip(Rect{0, 0, 0, 84}).empty());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testNameStripSitsAcrossTheTopOfTheCell();
|
||||
testNameStripNeverOverlapsTheLengthReadOut();
|
||||
testNameStripStaysInsideTheCell();
|
||||
testNameStripSuppressedOnATooShortCell();
|
||||
testNameStripSuppressedOnATooNarrowCell();
|
||||
testZeroLengthIsBarOneOrigin();
|
||||
testSubBeat();
|
||||
testWholeBeatWithinBar();
|
||||
|
||||
+270
-111
@@ -1,16 +1,18 @@
|
||||
// Standalone tests for reasampler::drag_out — no REAPER, no test framework. Same fast loop
|
||||
// as the sibling pure tests (mode_switch et al.): assert the gesture-
|
||||
// boundary decision and the path-list assembly directly.
|
||||
// Standalone tests for reasampler::ui::drag_out — no REAPER, no test framework. Same fast loop
|
||||
// as the sibling pure tests: assert the gesture law and the path-list assembly directly.
|
||||
//
|
||||
// Covers (M11 drag-out brief §test cases):
|
||||
// * Gesture boundary: inside-panel drag stays Internal; leaving the client area with
|
||||
// armed samples -> OsDrag; no armed samples (or not dragging) -> None; half-open edge
|
||||
// behavior; re-entry back inside returns to Internal (position-only decision).
|
||||
// * Path-list assembly: single, multi, dedupe (cross-bank copy case), skip-missing,
|
||||
// skip-unresolved, empty selection, order preservation, mixed tallies.
|
||||
// Covers:
|
||||
// * The full class matrix: every ReaperSurface x {single, multi} x {track, no track}, inside
|
||||
// and outside the client rect, plus the half-open edge and a non-zero panel origin.
|
||||
// * Reversibility and speed-independence, stated as properties: a class transition sequence
|
||||
// resolves the same forwards and backwards, and one unresolvable evaluation cannot change
|
||||
// any later evaluation's outcome.
|
||||
// * Exhaustiveness: no surface resolves to a do-nothing class, and every class has a cue.
|
||||
// * Path-list assembly + OS hand-off ordering (unchanged from before this law).
|
||||
|
||||
#include "../src/core/ui/drag_out.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -22,115 +24,261 @@ static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- Gesture boundary ---------------------------------------------------------
|
||||
// --- Fixtures -----------------------------------------------------------------
|
||||
|
||||
static const PanelClientRect kPanel{0, 0, 400, 300};
|
||||
|
||||
// A drag with samples, pointer well inside the client rect -> the existing internal drag
|
||||
// (invariant #4: inside-panel drag stays internal, unchanged).
|
||||
static void testInsidePanelStaysInternal() {
|
||||
DragState s{/*dragging=*/true, /*hasArmedSamples=*/true};
|
||||
CHECK(decideGesture(200, 150, kPanel, s) == DragGesture::Internal);
|
||||
CHECK(decideGesture(0, 0, kPanel, s) == DragGesture::Internal); // top-left corner
|
||||
CHECK(decideGesture(399, 299, kPanel, s) == DragGesture::Internal); // last inside px
|
||||
// A live single-card drag over `surface`, with a track resolved unless stated otherwise.
|
||||
static DropContext ctx(ReaperSurface surface, bool single = true, bool haveTrack = true) {
|
||||
DropContext c;
|
||||
c.drag = DragState{/*dragging=*/true, /*hasArmedSamples=*/true};
|
||||
c.singlePayload = single;
|
||||
c.surface = surface;
|
||||
c.haveTrack = haveTrack;
|
||||
return c;
|
||||
}
|
||||
|
||||
// A drag with samples whose pointer has left the client rect (any edge) -> OS drag.
|
||||
static void testLeavingClientAreaIsOsDrag() {
|
||||
DragState s{/*dragging=*/true, /*hasArmedSamples=*/true};
|
||||
CHECK(decideGesture(-1, 150, kPanel, s) == DragGesture::OsDrag); // left of panel
|
||||
CHECK(decideGesture(400, 150, kPanel, s) == DragGesture::OsDrag); // right edge (x+w)
|
||||
CHECK(decideGesture(200, -5, kPanel, s) == DragGesture::OsDrag); // above
|
||||
CHECK(decideGesture(200, 300, kPanel, s) == DragGesture::OsDrag); // below (y+h)
|
||||
CHECK(decideGesture(1000, 1000, kPanel, s) == DragGesture::OsDrag);// far outside
|
||||
// Every surface the law enumerates, so the matrix tests iterate rather than list.
|
||||
static const ReaperSurface kAllSurfaces[] = {
|
||||
ReaperSurface::OffReaper, ReaperSurface::TrackPanel, ReaperSurface::FxSurface,
|
||||
ReaperSurface::FxEmbed, ReaperSurface::Arrange, ReaperSurface::Other,
|
||||
};
|
||||
|
||||
// Pins kAllSurfaces against ReaperSurface::Count so a 7th surface added to the enum without a
|
||||
// matching entry here fails the BUILD, not just a silently-incomplete matrix — the compiler
|
||||
// alone does not enforce this (no -Wswitch/-Wall or /W4 anywhere in the build; see
|
||||
// panel_drag.cpp's onLBtnUp for the same caveat on DropClass).
|
||||
static_assert(sizeof(kAllSurfaces) / sizeof(kAllSurfaces[0]) ==
|
||||
static_cast<std::size_t>(ReaperSurface::Count),
|
||||
"kAllSurfaces must list exactly the surfaces below ReaperSurface::Count");
|
||||
|
||||
// A point comfortably outside the panel client rect.
|
||||
static const int kOutX = 500, kOutY = 150;
|
||||
|
||||
// --- Matrix: inside the client -------------------------------------------------
|
||||
|
||||
// Inside the client the drag is the bank-to-bank drag, whatever REAPER reports underneath and
|
||||
// whatever the payload size — the internal path must never be reinterpreted as an FX add or a
|
||||
// timeline insert. This is the bank-to-bank regression floor.
|
||||
static void testInsideClientIsAlwaysInternal() {
|
||||
for (ReaperSurface s : kAllSurfaces) {
|
||||
for (bool single : {true, false}) {
|
||||
CHECK(decideDropClass(200, 150, kPanel, ctx(s, single)) == DropClass::Internal);
|
||||
CHECK(decideDropClass(0, 0, kPanel, ctx(s, single)) == DropClass::Internal);
|
||||
CHECK(decideDropClass(399, 299, kPanel, ctx(s, single)) == DropClass::Internal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The half-open boundary: x+width and y+height are OUTSIDE (OsDrag), the pixel just inside
|
||||
// is Internal — matches the panel's other hit-tests so the edge is claimed consistently.
|
||||
// The half-open boundary: x+width and y+height are OUTSIDE, the pixel just inside is Internal —
|
||||
// matches the panel's other hit-tests so the edge is claimed consistently.
|
||||
static void testBoundaryHalfOpen() {
|
||||
DragState s{true, true};
|
||||
CHECK(decideGesture(399, 150, kPanel, s) == DragGesture::Internal);
|
||||
CHECK(decideGesture(400, 150, kPanel, s) == DragGesture::OsDrag);
|
||||
CHECK(decideGesture(200, 299, kPanel, s) == DragGesture::Internal);
|
||||
CHECK(decideGesture(200, 300, kPanel, s) == DragGesture::OsDrag);
|
||||
const DropContext off = ctx(ReaperSurface::OffReaper);
|
||||
CHECK(decideDropClass(399, 150, kPanel, off) == DropClass::Internal);
|
||||
CHECK(decideDropClass(400, 150, kPanel, off) == DropClass::OsHandoff);
|
||||
CHECK(decideDropClass(200, 299, kPanel, off) == DropClass::Internal);
|
||||
CHECK(decideDropClass(200, 300, kPanel, off) == DropClass::OsHandoff);
|
||||
}
|
||||
|
||||
// No armed samples -> None regardless of position (an empty-payload drag never goes to the
|
||||
// OS). Not dragging -> None even with samples (the shell asks only mid-drag, but the guard
|
||||
// is explicit).
|
||||
static void testNoDragOrNoSamplesIsNone() {
|
||||
CHECK(decideGesture(1000, 1000, kPanel, DragState{true, false}) == DragGesture::None);
|
||||
CHECK(decideGesture(200, 150, kPanel, DragState{true, false}) == DragGesture::None);
|
||||
CHECK(decideGesture(1000, 1000, kPanel, DragState{false, true}) == DragGesture::None);
|
||||
CHECK(decideGesture(200, 150, kPanel, DragState{false, false}) == DragGesture::None);
|
||||
}
|
||||
|
||||
// Re-entry: the decision is position-only, so a pointer that left (OsDrag) and came back
|
||||
// inside reads Internal again. (The shell, having handed off to the modal OS loop, simply
|
||||
// stops asking — but the pure function must not be stateful.)
|
||||
static void testReentryReturnsInternal() {
|
||||
DragState s{true, true};
|
||||
CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::OsDrag); // left
|
||||
CHECK(decideGesture(200, 150, kPanel, s) == DragGesture::Internal); // re-entered
|
||||
}
|
||||
|
||||
// A non-zero panel origin (the client rect need not sit at 0,0) — the boundary tracks the
|
||||
// rect, not the absolute axes.
|
||||
// A non-zero panel origin — the boundary tracks the rect, not the absolute axes.
|
||||
static void testOffsetPanelRect() {
|
||||
PanelClientRect p{50, 20, 100, 80}; // spans x[50,150) y[20,100)
|
||||
DragState s{true, true};
|
||||
CHECK(decideGesture(100, 60, p, s) == DragGesture::Internal);
|
||||
CHECK(decideGesture(49, 60, p, s) == DragGesture::OsDrag); // just left of origin
|
||||
CHECK(decideGesture(150, 60, p, s) == DragGesture::OsDrag); // x+width
|
||||
CHECK(decideGesture(100, 19, p, s) == DragGesture::OsDrag); // just above origin
|
||||
const PanelClientRect p{50, 20, 100, 80}; // spans x[50,150) y[20,100)
|
||||
const DropContext off = ctx(ReaperSurface::OffReaper);
|
||||
CHECK(decideDropClass(100, 60, p, off) == DropClass::Internal);
|
||||
CHECK(decideDropClass(49, 60, p, off) == DropClass::OsHandoff);
|
||||
CHECK(decideDropClass(150, 60, p, off) == DropClass::OsHandoff);
|
||||
CHECK(decideDropClass(100, 19, p, off) == DropClass::OsHandoff);
|
||||
}
|
||||
|
||||
// --- S17 InstrumentDrop gesture (single-capture over REAPER UI) ---------------
|
||||
//
|
||||
// M11 REGRESSION GUARD (load-bearing): every M11 case above uses DragState{true, true},
|
||||
// which leaves singleCapture=overReaperUi=false — so an M11-era payload outside the client
|
||||
// rect still decides OsDrag exactly as before. The tests above ARE the M11 non-regression
|
||||
// proof; these add the new middle case.
|
||||
// --- Matrix: outside the client, single card -----------------------------------
|
||||
|
||||
// A SINGLE-capture drag that has left the panel but is still over REAPER's own UI is an
|
||||
// instrument drop (heading for a track's FX button), NOT an OS drag.
|
||||
static void testSingleCaptureOverReaperUiIsInstrumentDrop() {
|
||||
DragState s{/*dragging=*/true, /*hasArmedSamples=*/true,
|
||||
/*singleCapture=*/true, /*overReaperUi=*/true};
|
||||
CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::InstrumentDrop); // right of panel
|
||||
CHECK(decideGesture(-5, 150, kPanel, s) == DragGesture::InstrumentDrop); // left of panel
|
||||
CHECK(decideGesture(200, 400, kPanel, s) == DragGesture::InstrumentDrop); // below
|
||||
// A single card over a track's panel loads the instrument — the WHOLE panel, which is the
|
||||
// root-cause fix: a TCP too narrow to draw the FX button used to yield a cue-less no-op.
|
||||
static void testSingleOverTrackPanelIsInstrumentDrop() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::TrackPanel)) ==
|
||||
DropClass::InstrumentDrop);
|
||||
CHECK(decideDropClass(-5, kOutY, kPanel, ctx(ReaperSurface::TrackPanel)) ==
|
||||
DropClass::InstrumentDrop);
|
||||
CHECK(decideDropClass(200, 400, kPanel, ctx(ReaperSurface::TrackPanel)) ==
|
||||
DropClass::InstrumentDrop);
|
||||
}
|
||||
|
||||
// InstrumentDrop is an OUTSIDE-only refinement: the same single-capture state INSIDE the
|
||||
// client rect is still the unchanged Internal bank-to-bank drag (invariant #4).
|
||||
static void testSingleCaptureInsidePanelStaysInternal() {
|
||||
DragState s{true, true, /*singleCapture=*/true, /*overReaperUi=*/true};
|
||||
CHECK(decideGesture(200, 150, kPanel, s) == DragGesture::Internal);
|
||||
// The FX chain / floating-FX windows keep their existing outcome.
|
||||
static void testSingleOverFxSurfaceIsInstrumentDrop() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface)) ==
|
||||
DropClass::InstrumentDrop);
|
||||
}
|
||||
|
||||
// A single-capture drag that has left REAPER ENTIRELY (overReaperUi=false) falls through to
|
||||
// OsDrag — the M11 OS drag-out to Explorer/another DAW, unchanged. This is the boundary
|
||||
// refinement's other half: leaving the client rect no longer immediately means OS-bound.
|
||||
static void testSingleCaptureOffReaperIsOsDrag() {
|
||||
DragState s{true, true, /*singleCapture=*/true, /*overReaperUi=*/false};
|
||||
CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::OsDrag);
|
||||
// The embed strip is where an instance ALREADY draws: dropping there must not stack a second,
|
||||
// so it refuses rather than instantiating.
|
||||
static void testSingleOverFxEmbedRefuses() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxEmbed)) ==
|
||||
DropClass::Refuse);
|
||||
}
|
||||
|
||||
// A MULTI-capture drag over REAPER's UI is REJECTED for InstrumentDrop (the S17 open-question
|
||||
// lean): it is NOT a single instrument placement, so it falls through to OsDrag even while
|
||||
// over REAPER's UI — the multi-file drag-out is the natural gesture for a multi payload.
|
||||
static void testMultiCaptureOverReaperUiIsOsDrag() {
|
||||
DragState s{true, true, /*singleCapture=*/false, /*overReaperUi=*/true};
|
||||
CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::OsDrag);
|
||||
// THE HEADLINE DEFECT: a single card over the arrange places a timeline item. It used to lock
|
||||
// to InstrumentDrop on the first move outside the client and then release into nothing.
|
||||
static void testSingleOverArrangeIsArrangeInsert() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::Arrange)) ==
|
||||
DropClass::ArrangeInsert);
|
||||
}
|
||||
|
||||
// Not-dragging / no-armed-samples still short-circuits to None regardless of the S17 fields.
|
||||
static void testS17FieldsIgnoredWhenNotDragging() {
|
||||
CHECK(decideGesture(500, 150, kPanel,
|
||||
DragState{false, true, true, true}) == DragGesture::None);
|
||||
CHECK(decideGesture(500, 150, kPanel,
|
||||
DragState{true, false, true, true}) == DragGesture::None);
|
||||
// Ruler / transport / docker chrome / any token REAPER adds later: a defined refusal.
|
||||
static void testSingleOverOtherReaperUiRefuses() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::Other)) == DropClass::Refuse);
|
||||
}
|
||||
|
||||
// Off REAPER entirely -> the OS drag-out, the one irreversible transition.
|
||||
static void testSingleOffReaperIsOsHandoff() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::OffReaper)) ==
|
||||
DropClass::OsHandoff);
|
||||
}
|
||||
|
||||
// --- Matrix: outside the client, multi card ------------------------------------
|
||||
|
||||
// A multi payload names no single instrument, so both instrument surfaces refuse — a DEFINED
|
||||
// outcome with a cue, where the old law silently took the OS path from these surfaces.
|
||||
static void testMultiOverInstrumentSurfacesRefuses() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::TrackPanel, false)) ==
|
||||
DropClass::Refuse);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface, false)) ==
|
||||
DropClass::Refuse);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxEmbed, false)) ==
|
||||
DropClass::Refuse);
|
||||
}
|
||||
|
||||
// Multi over the arrange still places — one item per capture; the shell lays them out.
|
||||
static void testMultiOverArrangeIsArrangeInsert() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::Arrange, false)) ==
|
||||
DropClass::ArrangeInsert);
|
||||
}
|
||||
|
||||
// Multi off REAPER is the classic multi-file drag-out, unchanged.
|
||||
static void testMultiOffReaperIsOsHandoff() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::OffReaper, false)) ==
|
||||
DropClass::OsHandoff);
|
||||
}
|
||||
|
||||
static void testMultiOverOtherReaperUiRefuses() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::Other, false)) ==
|
||||
DropClass::Refuse);
|
||||
}
|
||||
|
||||
// --- The documented null-track / non-empty-info case ---------------------------
|
||||
|
||||
// GetThingFromPoint "may return NULL with valid info string to indicate non-track thing". Both
|
||||
// outcomes that need a track therefore refuse instead of dereferencing nothing: over the arrange
|
||||
// that is the empty region below the last track, and over a track panel it is a surface we
|
||||
// cannot attribute.
|
||||
static void testNullTrackWithSurfaceRefuses() {
|
||||
for (ReaperSurface s : {ReaperSurface::TrackPanel, ReaperSurface::FxSurface,
|
||||
ReaperSurface::Arrange}) {
|
||||
for (bool single : {true, false}) {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel,
|
||||
ctx(s, single, /*haveTrack=*/false)) == DropClass::Refuse);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A track under the pointer never turns OffReaper into a REAPER-internal outcome: OffReaper is
|
||||
// the shell's "the info string was empty and there was no track" verdict, and the law trusts it.
|
||||
static void testOffReaperIgnoresTrackFlag() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel,
|
||||
ctx(ReaperSurface::OffReaper, true, false)) == DropClass::OsHandoff);
|
||||
}
|
||||
|
||||
// --- Not-a-drag ----------------------------------------------------------------
|
||||
|
||||
// No armed samples, or not dragging -> None regardless of position or surface.
|
||||
static void testNoDragOrNoSamplesIsNone() {
|
||||
for (ReaperSurface s : kAllSurfaces) {
|
||||
DropContext c = ctx(s);
|
||||
c.drag = DragState{/*dragging=*/true, /*hasArmedSamples=*/false};
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, c) == DropClass::None);
|
||||
CHECK(decideDropClass(200, 150, kPanel, c) == DropClass::None);
|
||||
|
||||
c.drag = DragState{/*dragging=*/false, /*hasArmedSamples=*/true};
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, c) == DropClass::None);
|
||||
CHECK(decideDropClass(200, 150, kPanel, c) == DropClass::None);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reversibility, speed-independence, exhaustiveness -------------------------
|
||||
|
||||
// The transition sequence from the acceptance criteria: client -> arrange -> FX window ->
|
||||
// arrange -> client. Every step resolves on its own terms, and the return leg reproduces the
|
||||
// outgoing leg exactly — the instrument drop is still available after crossing the arrange, and
|
||||
// re-entering the client resumes the internal drag.
|
||||
static void testClassTransitionsAreReversible() {
|
||||
const DropContext arrange = ctx(ReaperSurface::Arrange);
|
||||
const DropContext fx = ctx(ReaperSurface::FxSurface);
|
||||
const DropContext inside = ctx(ReaperSurface::Other); // surface is ignored inside
|
||||
|
||||
CHECK(decideDropClass(200, 150, kPanel, inside) == DropClass::Internal);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, arrange) == DropClass::ArrangeInsert);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, fx) == DropClass::InstrumentDrop);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, arrange) == DropClass::ArrangeInsert);
|
||||
CHECK(decideDropClass(200, 150, kPanel, inside) == DropClass::Internal);
|
||||
}
|
||||
|
||||
// One unresolvable evaluation (a surface with no track, which refuses) followed by a resolvable
|
||||
// one: the second resolves exactly as it would have on its own. This is the property the retired
|
||||
// drag-lifetime "blocked" latch violated.
|
||||
static void testUnresolvableEvaluationDoesNotAffectTheNext() {
|
||||
const DropContext trackless = ctx(ReaperSurface::Arrange, true, /*haveTrack=*/false);
|
||||
const DropContext resolvable = ctx(ReaperSurface::Arrange);
|
||||
const DropClass standalone = decideDropClass(kOutX, kOutY, kPanel, resolvable);
|
||||
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, trackless) == DropClass::Refuse);
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, resolvable) == standalone);
|
||||
CHECK(standalone == DropClass::ArrangeInsert);
|
||||
|
||||
// And in the other order — the refusal is equally uninfluenced by what preceded it.
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, trackless) == DropClass::Refuse);
|
||||
}
|
||||
|
||||
// Drag speed only changes WHICH intermediate points get evaluated. Since the class is a function
|
||||
// of the current point and context alone, a "fast flick" (one evaluation at the release point)
|
||||
// and a "slow drag" (many evaluations ending at the same point) agree at that point — with the
|
||||
// intermediate surfaces deliberately chosen to disagree with the destination.
|
||||
static void testDragSpeedCannotChangeTheOutcome() {
|
||||
const DropContext destination = ctx(ReaperSurface::TrackPanel);
|
||||
const DropClass flick = decideDropClass(kOutX, kOutY, kPanel, destination);
|
||||
|
||||
// The slow path crosses everything else first.
|
||||
for (ReaperSurface s : kAllSurfaces) {
|
||||
(void)decideDropClass(kOutX - 10, kOutY, kPanel, ctx(s));
|
||||
(void)decideDropClass(200, 150, kPanel, ctx(s)); // and back through the client
|
||||
}
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, destination) == flick);
|
||||
CHECK(flick == DropClass::InstrumentDrop);
|
||||
}
|
||||
|
||||
// EXHAUSTIVENESS (acceptance criterion 7, structural rather than spot-checked): for a live drag
|
||||
// outside the client, no surface x payload x track combination resolves to None — i.e. there is
|
||||
// no cell whose release does nothing without having said so. None is reachable only from a dead
|
||||
// drag, which is asserted separately above.
|
||||
static void testNoLiveOutsideCombinationResolvesToNone() {
|
||||
for (ReaperSurface s : kAllSurfaces) {
|
||||
for (bool single : {true, false}) {
|
||||
for (bool haveTrack : {true, false}) {
|
||||
const DropClass c = decideDropClass(kOutX, kOutY, kPanel, ctx(s, single, haveTrack));
|
||||
CHECK(c != DropClass::None);
|
||||
CHECK(c != DropClass::Internal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every class carries a cue, and only the two the shell deliberately does not draw map to a
|
||||
// no-cursor cue — so "will this work" is answerable before release for every resolvable class.
|
||||
static void testEveryClassHasACue() {
|
||||
CHECK(cueForDropClass(DropClass::Internal) == DropCue::Internal);
|
||||
CHECK(cueForDropClass(DropClass::InstrumentDrop) == DropCue::Instrument);
|
||||
CHECK(cueForDropClass(DropClass::ArrangeInsert) == DropCue::ArrangeInsert);
|
||||
CHECK(cueForDropClass(DropClass::Refuse) == DropCue::Refuse);
|
||||
CHECK(cueForDropClass(DropClass::OsHandoff) == DropCue::OsOwned);
|
||||
CHECK(cueForDropClass(DropClass::None) == DropCue::None);
|
||||
}
|
||||
|
||||
// --- Path-list assembly -------------------------------------------------------
|
||||
@@ -158,7 +306,7 @@ static void testMultiPreservesOrder() {
|
||||
}
|
||||
|
||||
// Two index entries resolving to the SAME file (the cross-bank copy case — one file, two
|
||||
// entries) yield ONE CF_HDROP path; the extra is counted, first occurrence wins.
|
||||
// entries) yield ONE path; the extra is counted, first occurrence wins.
|
||||
static void testDedupeSamePath() {
|
||||
PathList l = assemblePathList({ok("x.wav"), ok("y.wav"), ok("x.wav")});
|
||||
CHECK(l.paths.size() == 2);
|
||||
@@ -167,8 +315,7 @@ static void testDedupeSamePath() {
|
||||
CHECK(l.skippedDuplicate == 1);
|
||||
}
|
||||
|
||||
// A stale index entry (file gone from disk) is skipped — never a dangling path on the OS
|
||||
// clipboard.
|
||||
// A stale index entry (file gone from disk) is skipped — never a dangling path handed onward.
|
||||
static void testSkipMissing() {
|
||||
PathList l = assemblePathList({ok("a.wav"), missing("gone.wav"), ok("b.wav")});
|
||||
CHECK(l.paths.size() == 2);
|
||||
@@ -227,9 +374,8 @@ static void testMixedTallies() {
|
||||
// (release capture, clear drag state) BEFORE checking whether the payload had resolved to any
|
||||
// on-disk file. For an unresolvable payload, initiateDragOut is never reached — no OS drop
|
||||
// happens at all — but the teardown ran anyway, so the drag just silently stopped mid-gesture
|
||||
// with no highlight and no drop. The user has to press and start an entirely new drag; retrying
|
||||
// the SAME stale selection resolves to the same empty payload and fails identically. These pin
|
||||
// the fix: the two side effects (teardown, hand-off) are one decision.
|
||||
// with no highlight and no drop. These pin the fix: the two side effects (teardown, hand-off)
|
||||
// are one decision.
|
||||
|
||||
// Nothing draggable -> hand off nothing AND keep the internal drag alive.
|
||||
static void testEmptyPathsHandsOffNothingAndKeepsInternalDrag() {
|
||||
@@ -254,18 +400,31 @@ static void testAllSkippedSelectionKeepsInternalDrag() {
|
||||
}
|
||||
|
||||
int main() {
|
||||
testInsidePanelStaysInternal();
|
||||
testLeavingClientAreaIsOsDrag();
|
||||
testInsideClientIsAlwaysInternal();
|
||||
testBoundaryHalfOpen();
|
||||
testNoDragOrNoSamplesIsNone();
|
||||
testReentryReturnsInternal();
|
||||
testOffsetPanelRect();
|
||||
|
||||
testSingleCaptureOverReaperUiIsInstrumentDrop();
|
||||
testSingleCaptureInsidePanelStaysInternal();
|
||||
testSingleCaptureOffReaperIsOsDrag();
|
||||
testMultiCaptureOverReaperUiIsOsDrag();
|
||||
testS17FieldsIgnoredWhenNotDragging();
|
||||
testSingleOverTrackPanelIsInstrumentDrop();
|
||||
testSingleOverFxSurfaceIsInstrumentDrop();
|
||||
testSingleOverFxEmbedRefuses();
|
||||
testSingleOverArrangeIsArrangeInsert();
|
||||
testSingleOverOtherReaperUiRefuses();
|
||||
testSingleOffReaperIsOsHandoff();
|
||||
|
||||
testMultiOverInstrumentSurfacesRefuses();
|
||||
testMultiOverArrangeIsArrangeInsert();
|
||||
testMultiOffReaperIsOsHandoff();
|
||||
testMultiOverOtherReaperUiRefuses();
|
||||
|
||||
testNullTrackWithSurfaceRefuses();
|
||||
testOffReaperIgnoresTrackFlag();
|
||||
testNoDragOrNoSamplesIsNone();
|
||||
|
||||
testClassTransitionsAreReversible();
|
||||
testUnresolvableEvaluationDoesNotAffectTheNext();
|
||||
testDragSpeedCannotChangeTheOutcome();
|
||||
testNoLiveOutsideCombinationResolvesToNone();
|
||||
testEveryClassHasACue();
|
||||
|
||||
testSinglePath();
|
||||
testMultiPreservesOrder();
|
||||
|
||||
@@ -228,6 +228,37 @@ static void testResizeSweepNoOverlap() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mode-segment enablement (the playback gate's visible half) ---------------
|
||||
|
||||
static void testModeSegmentsAreLiveWithTheTransportStopped() {
|
||||
CHECK(modeSegmentEnabled(/*isActiveSegment=*/false, /*transportRunning=*/false, /*routable=*/true));
|
||||
CHECK(modeSegmentEnabled(/*isActiveSegment=*/true, /*transportRunning=*/false, /*routable=*/true));
|
||||
}
|
||||
|
||||
static void testInactiveSegmentGoesDeadWhileTheTransportRuns() {
|
||||
// The one that would fire a real switch — refused while playing/recording, so it
|
||||
// must read dead rather than invite a click that silently does nothing.
|
||||
CHECK(!modeSegmentEnabled(/*isActiveSegment=*/false, /*transportRunning=*/true, /*routable=*/true));
|
||||
}
|
||||
|
||||
static void testActiveSegmentStaysLiveWhileTheTransportRuns() {
|
||||
// Clicking the mode you are already in is a reapply, which is never gated;
|
||||
// dimming it would read as "this mode is unavailable".
|
||||
CHECK(modeSegmentEnabled(/*isActiveSegment=*/true, /*transportRunning=*/true, /*routable=*/false));
|
||||
}
|
||||
|
||||
static void testInactiveSegmentGoesDeadWhenUnroutable() {
|
||||
// A third registered mode with no seeded command id (the model is N-mode, the UI
|
||||
// ships two ids) must read dead, not live-but-silently-inert on click.
|
||||
CHECK(!modeSegmentEnabled(/*isActiveSegment=*/false, /*transportRunning=*/false, /*routable=*/false));
|
||||
}
|
||||
|
||||
static void testActiveSegmentStaysLiveEvenWhenUnroutable() {
|
||||
// The active segment always fires a reapply through its own already-resolved id in
|
||||
// practice, but the predicate's active bypass does not consult routable either way.
|
||||
CHECK(modeSegmentEnabled(/*isActiveSegment=*/true, /*transportRunning=*/false, /*routable=*/false));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testWideFooterAllPlaced();
|
||||
testOffsetFooterAnchorsLeft();
|
||||
@@ -241,6 +272,11 @@ int main() {
|
||||
testHitEdgesAndOutside();
|
||||
testSuppressedClaimsNothing();
|
||||
testResizeSweepNoOverlap();
|
||||
testModeSegmentsAreLiveWithTheTransportStopped();
|
||||
testInactiveSegmentGoesDeadWhileTheTransportRuns();
|
||||
testActiveSegmentStaysLiveWhileTheTransportRuns();
|
||||
testInactiveSegmentGoesDeadWhenUnroutable();
|
||||
testActiveSegmentStaysLiveEvenWhenUnroutable();
|
||||
|
||||
if (g_fail == 0) std::printf("footer_bar: all tests passed\n");
|
||||
else std::printf("footer_bar: %d CHECK(s) FAILED\n", g_fail);
|
||||
|
||||
@@ -177,68 +177,98 @@ static void testBadClassIdRejected() {
|
||||
CHECK(!buildVstPresetBytes(std::string(32, 'A'), state).empty());
|
||||
}
|
||||
|
||||
// --- FX-hotspot classification (S-VIEW-BUG-1 / S-GA-DropFX) -------------------
|
||||
// --- Surface classification ---------------------------------------------------
|
||||
//
|
||||
// THE RULE (prefix-based — see instrument_drop.h): "fx_*" names the FX-chain / floating-FX
|
||||
// windows; "tcp.fx*" / "mcp.fx*" name the TCP/MCP FX button and its sibling FX sub-elements.
|
||||
// The SDK warns GetThingFromPoint "may append additional information", so exact-token
|
||||
// matching (the previous, DAW-falsified predicate) is wrong; the prefix family is the
|
||||
// documented-adjacent surface. Bare "tcp"/"mcp" and non-FX sub-elements are NOT hotspots.
|
||||
// THE RULE (prefix-based — see instrument_drop.h): the SDK warns GetThingFromPoint "may append
|
||||
// additional information", so exact-token matching (a previous, DAW-falsified predicate) is
|
||||
// wrong. Ordering is load-bearing: the embed strip is matched BEFORE the track panel, and the
|
||||
// track panel now claims the WHOLE "tcp*"/"mcp*" family rather than just its FX sub-elements.
|
||||
|
||||
// The TCP/MCP FX-button family arms an instrument drop — including sibling FX sub-elements
|
||||
// and tokens with appended information.
|
||||
static void testTcpMcpFxFamilyIsHotspot() {
|
||||
CHECK(infoNamesFxHotspot("tcp.fx")); // TCP FX button (WALTER element name)
|
||||
CHECK(infoNamesFxHotspot("mcp.fx")); // MCP FX button
|
||||
CHECK(infoNamesFxHotspot("tcp.fxbyp")); // FX bypass — sibling FX element
|
||||
CHECK(infoNamesFxHotspot("tcp.fxparm")); // FX param knob area — sibling FX element
|
||||
CHECK(infoNamesFxHotspot("mcp.fxlist")); // MCP FX insert list
|
||||
CHECK(infoNamesFxHotspot("tcp.fx.1")); // appended info (SDK: "may append...")
|
||||
CHECK(infoNamesFxHotspot("tcp.fx extra")); // appended info, arbitrary form
|
||||
using ui::ReaperSurface;
|
||||
|
||||
static ReaperSurface onTrack(const std::string& info) {
|
||||
return classifyReaperSurface(info, /*haveTrack=*/true);
|
||||
}
|
||||
|
||||
// The FX chain / floating-FX windows (the surfaces the ORIGINAL predicate matched) still
|
||||
// classify as hotspots — the fix does not regress the "fx_" surface.
|
||||
static void testFxWindowStillHotspot() {
|
||||
CHECK(infoNamesFxHotspot("fx_chain")); // FX chain window
|
||||
CHECK(infoNamesFxHotspot("fx_0")); // first FX, floating
|
||||
CHECK(infoNamesFxHotspot("fx_12")); // arbitrary floating-FX index
|
||||
// The FX chain / floating-FX windows.
|
||||
static void testFxWindowIsFxSurface() {
|
||||
CHECK(onTrack("fx_chain") == ReaperSurface::FxSurface);
|
||||
CHECK(onTrack("fx_0") == ReaperSurface::FxSurface);
|
||||
CHECK(onTrack("fx_12") == ReaperSurface::FxSurface);
|
||||
}
|
||||
|
||||
// Non-FX surfaces are NOT hotspots — a drop here is not an instrument drop (it would fall
|
||||
// through to the OS drag / no-op). This includes the bare TCP/MCP tokens and all non-FX
|
||||
// "tcp.*"/"mcp.*" sub-elements (e.g. mute button, volume fader, track name, meter).
|
||||
static void testNonFxSurfacesAreNotHotspot() {
|
||||
CHECK(!infoNamesFxHotspot("tcp")); // bare track control panel — NOT an FX hotspot
|
||||
CHECK(!infoNamesFxHotspot("mcp")); // bare mixer control panel — NOT an FX hotspot
|
||||
CHECK(!infoNamesFxHotspot("tcp.mute")); // mute button — track panel, not FX
|
||||
CHECK(!infoNamesFxHotspot("tcp.vol")); // volume fader — track panel, not FX
|
||||
CHECK(!infoNamesFxHotspot("tcp.f")); // truncated non-FX token — prefix must be whole
|
||||
CHECK(!infoNamesFxHotspot("arrange"));
|
||||
CHECK(!infoNamesFxHotspot("spacer_0"));
|
||||
CHECK(!infoNamesFxHotspot("")); // pointer over nothing REAPER classifies
|
||||
CHECK(!infoNamesFxHotspot("trans")); // transport
|
||||
CHECK(!infoNamesFxHotspot("envcp")); // envelope control panel — a track thing, not FX
|
||||
// The FX-button family within the TCP/MCP — the surface the glyph-only rule used to be limited
|
||||
// to, still an instrument surface (as TrackPanel, which resolves identically).
|
||||
static void testTcpMcpFxFamilyIsTrackPanel() {
|
||||
CHECK(onTrack("tcp.fx") == ReaperSurface::TrackPanel);
|
||||
CHECK(onTrack("mcp.fx") == ReaperSurface::TrackPanel);
|
||||
CHECK(onTrack("tcp.fxbyp") == ReaperSurface::TrackPanel);
|
||||
CHECK(onTrack("tcp.fxparm") == ReaperSurface::TrackPanel);
|
||||
CHECK(onTrack("mcp.fxlist") == ReaperSurface::TrackPanel);
|
||||
CHECK(onTrack("tcp.fx.1") == ReaperSurface::TrackPanel); // appended info
|
||||
CHECK(onTrack("tcp.fx extra") == ReaperSurface::TrackPanel); // appended info, arbitrary form
|
||||
}
|
||||
|
||||
// The embed-strip sub-element is NOT a hotspot. "tcp.fxembed" / "mcp.fxembed" is the surface
|
||||
// where a ReaSampler 9000 instance draws inline in the TCP/MCP via IReaperUIEmbedInterface.
|
||||
// Dropping a card there must NOT add a SECOND instance on top of the existing embed — the
|
||||
// drop should be ignored (no instrument drop), even though the token starts with "tcp.fx".
|
||||
// This documents the explicit exclusion in infoNamesFxHotspot and would catch a regression if
|
||||
// the exclude guard were accidentally removed.
|
||||
static void testEmbedStripIsNotHotspot() {
|
||||
CHECK(!infoNamesFxHotspot("tcp.fxembed")); // TCP embed strip — existing instance's surface
|
||||
CHECK(!infoNamesFxHotspot("mcp.fxembed")); // MCP embed strip — existing instance's surface
|
||||
// With hypothetically appended info (SDK "may append") — still excluded.
|
||||
CHECK(!infoNamesFxHotspot("tcp.fxembed.1"));
|
||||
CHECK(!infoNamesFxHotspot("mcp.fxembed extra"));
|
||||
// THE ROOT-CAUSE FIX: the bare panel token and every non-FX sub-element are now the instrument
|
||||
// hotspot too. A TCP too narrow to draw the FX button reports "tcp", which under the old
|
||||
// glyph-only rule produced a cue-less no-op.
|
||||
static void testWholeTrackPanelIsTheHotspot() {
|
||||
CHECK(onTrack("tcp") == ReaperSurface::TrackPanel); // bare track control panel
|
||||
CHECK(onTrack("mcp") == ReaperSurface::TrackPanel); // bare mixer control panel
|
||||
CHECK(onTrack("tcp.mute") == ReaperSurface::TrackPanel); // mute button
|
||||
CHECK(onTrack("tcp.vol") == ReaperSurface::TrackPanel); // volume fader
|
||||
CHECK(onTrack("tcp.meter") == ReaperSurface::TrackPanel); // meter
|
||||
CHECK(onTrack("tcp.f") == ReaperSurface::TrackPanel); // truncated token — still the panel
|
||||
}
|
||||
|
||||
// The embed strip is where a ReaSampler 9000 instance already draws inline via
|
||||
// IReaperUIEmbedInterface. It must NOT resolve to a hotspot — a drop there would stack a second
|
||||
// instance on the first. Matched before the "tcp"/"mcp" rule, so widening the panel hotspot
|
||||
// cannot swallow it; do not reorder these two checks in the classifier.
|
||||
static void testEmbedStripIsItsOwnSurface() {
|
||||
CHECK(onTrack("tcp.fxembed") == ReaperSurface::FxEmbed);
|
||||
CHECK(onTrack("mcp.fxembed") == ReaperSurface::FxEmbed);
|
||||
CHECK(onTrack("tcp.fxembed.1") == ReaperSurface::FxEmbed);
|
||||
CHECK(onTrack("mcp.fxembed extra") == ReaperSurface::FxEmbed);
|
||||
}
|
||||
|
||||
// The arrange, including a token with appended information.
|
||||
static void testArrangeIsArrange() {
|
||||
CHECK(onTrack("arrange") == ReaperSurface::Arrange);
|
||||
CHECK(onTrack("arrange extra") == ReaperSurface::Arrange);
|
||||
}
|
||||
|
||||
// Anything else REAPER names is Other — a defined refusal, never a guessed outcome. Includes
|
||||
// tokens REAPER may add in future versions.
|
||||
static void testUnnamedReaperSurfacesAreOther() {
|
||||
CHECK(onTrack("spacer_0") == ReaperSurface::Other);
|
||||
CHECK(onTrack("trans") == ReaperSurface::Other);
|
||||
CHECK(onTrack("envcp") == ReaperSurface::Other);
|
||||
CHECK(onTrack("ruler") == ReaperSurface::Other);
|
||||
CHECK(onTrack("something_reaper_adds_in_2030") == ReaperSurface::Other);
|
||||
}
|
||||
|
||||
// The empty info string splits on whether a track came back with it. No track means the pointer
|
||||
// has left REAPER (the OS hand-off's trigger); a track with no info means we are over REAPER on
|
||||
// a surface we cannot name, which must refuse rather than be treated as off-REAPER.
|
||||
static void testEmptyInfoSplitsOnTrackPresence() {
|
||||
CHECK(classifyReaperSurface("", /*haveTrack=*/false) == ReaperSurface::OffReaper);
|
||||
CHECK(classifyReaperSurface("", /*haveTrack=*/true) == ReaperSurface::Other);
|
||||
}
|
||||
|
||||
// The SDK's documented null-track-with-valid-info case: the surface is read from the string
|
||||
// alone, so the classifier reports it faithfully and the gesture law decides what a missing
|
||||
// track means for that surface.
|
||||
static void testNullTrackStillClassifiesTheSurface() {
|
||||
CHECK(classifyReaperSurface("arrange", false) == ReaperSurface::Arrange);
|
||||
CHECK(classifyReaperSurface("tcp", false) == ReaperSurface::TrackPanel);
|
||||
CHECK(classifyReaperSurface("fx_chain", false) == ReaperSurface::FxSurface);
|
||||
}
|
||||
|
||||
// Do not reintroduce a per-surface "capture carries" loop test: buildInstrumentDropPreset takes
|
||||
// only sampleId (proven by testPresetRoundTripsThroughInstrumentReader), and per-surface hotspot
|
||||
// coverage already exists (testTcpMcpFxFamilyIsHotspot / testFxWindowStillHotspot) — a loop with
|
||||
// an identical body per surface string can't distinguish them.
|
||||
// only sampleId (proven by testPresetRoundTripsThroughInstrumentReader), and per-surface
|
||||
// coverage already exists above — a loop with an identical body per surface string can't
|
||||
// distinguish them.
|
||||
|
||||
// --- All-or-nothing rollback --------------------------------------------------
|
||||
|
||||
@@ -288,10 +318,14 @@ int main() {
|
||||
testDeterministic();
|
||||
testBadClassIdRejected();
|
||||
|
||||
testTcpMcpFxFamilyIsHotspot();
|
||||
testFxWindowStillHotspot();
|
||||
testNonFxSurfacesAreNotHotspot();
|
||||
testEmbedStripIsNotHotspot();
|
||||
testFxWindowIsFxSurface();
|
||||
testTcpMcpFxFamilyIsTrackPanel();
|
||||
testWholeTrackPanelIsTheHotspot();
|
||||
testEmbedStripIsItsOwnSurface();
|
||||
testArrangeIsArrange();
|
||||
testUnnamedReaperSurfacesAreOther();
|
||||
testEmptyInfoSplitsOnTrackPresence();
|
||||
testNullTrackStillClassifiesTheSurface();
|
||||
|
||||
testAddFailureLeavesNothingToRollBack();
|
||||
testPresetFailureRollsBackTheCreatedIndex();
|
||||
|
||||
@@ -203,14 +203,114 @@ static void testRazorUnionBounds() {
|
||||
// --- sourceModeForScope: scope -> render source mode -------------------------
|
||||
|
||||
static void testScopeSourceModes() {
|
||||
// Each scope drives a distinct render source. Item -> items, Track -> tracks.
|
||||
// (There is no master scope — to capture the master you render a track.) These
|
||||
// feed renderSettingsFor and must be supported.
|
||||
CHECK(sourceModeForScope(CaptureScope::Item) == SourceMode::SelectedItems);
|
||||
CHECK(sourceModeForScope(CaptureScope::Track) == SourceMode::SelectedTracks);
|
||||
// Track scope always renders its selected tracks (there is no master scope — to
|
||||
// capture the master you render a track), whatever the window.
|
||||
CHECK(sourceModeForScope(CaptureScope::Track, true) == SourceMode::SelectedTracks);
|
||||
CHECK(sourceModeForScope(CaptureScope::Track, false) == SourceMode::SelectedTracks);
|
||||
// Item scope renders the selected items only when their extent already prints
|
||||
// the requested window.
|
||||
CHECK(sourceModeForScope(CaptureScope::Item, true) == SourceMode::SelectedItems);
|
||||
// Every scope's source mode is an offline-supported render source.
|
||||
CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Item), 1.0).supported);
|
||||
CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Track), 1.0).supported);
|
||||
CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Item, true), 1.0).supported);
|
||||
CHECK(renderSettingsFor(sourceModeForScope(CaptureScope::Track, true), 1.0).supported);
|
||||
}
|
||||
|
||||
static void testRangedItemScopeRendersTimeBounded() {
|
||||
// The widening defect pinned at the bit level. A window the item extent does NOT
|
||||
// print must never reach the &32 selected-items source: that source is INFERRED
|
||||
// (unverified — src/core/capture/CLAUDE.md §Gotchas) to take its bounds from the
|
||||
// item extents, so RENDER_STARTPOS/ENDPOS cannot narrow it and the capture widens
|
||||
// to the whole item. The ranged item capture renders through the selected-tracks
|
||||
// source instead, which IS time-bounded.
|
||||
const SourceMode ranged = sourceModeForScope(CaptureScope::Item, false);
|
||||
CHECK(ranged == SourceMode::SelectedTracks);
|
||||
|
||||
const RenderSettingsChoice c = renderSettingsFor(ranged, 1.0);
|
||||
CHECK(c.supported);
|
||||
CHECK((c.settings & kRenderSelItems) == 0); // the widening bit is absent
|
||||
CHECK((c.settings & kRenderSingleFile) == 0); // and its single-file companion
|
||||
CHECK(c.settings == kRenderSelTracksViaMaster);
|
||||
|
||||
// The regression floor, at the same resolution: an item capture whose window IS
|
||||
// the item extent still renders through &32 | single-file, unchanged.
|
||||
const RenderSettingsChoice floorCase =
|
||||
renderSettingsFor(sourceModeForScope(CaptureScope::Item, true), 1.0);
|
||||
CHECK((floorCase.settings & kRenderSelItems) != 0);
|
||||
CHECK((floorCase.settings & kRenderSingleFile) != 0);
|
||||
|
||||
// FX scope is orthogonal to the re-source: a ranged item capture still hears
|
||||
// take/item FX only (this is what makes the swap safe).
|
||||
CHECK(fxBypassPlanFor(CaptureScope::Item).bypassSelfFx);
|
||||
}
|
||||
|
||||
static void testMultiTrackStemRenderIsNamedForRefusal() {
|
||||
// The one shape that cannot land: a selected-tracks render over more than one
|
||||
// track. That source is INFERRED to render one file per track with no single-file
|
||||
// bit available (unverified; see src/shell/capture/CLAUDE.md §Gotchas for what that
|
||||
// inference rests on), so N stems would collapse onto one render pattern and one
|
||||
// track's audio would land as a successful capture.
|
||||
CHECK(isMultiTrackStemRender(SourceMode::SelectedTracks, 2));
|
||||
CHECK(isMultiTrackStemRender(SourceMode::SelectedTracks, 7));
|
||||
|
||||
// One track is the common case for BOTH scopes — it must still render. This is the
|
||||
// regression floor for the plain single-track track capture.
|
||||
CHECK(!isMultiTrackStemRender(SourceMode::SelectedTracks, 1));
|
||||
CHECK(!isMultiTrackStemRender(SourceMode::SelectedTracks, 0));
|
||||
|
||||
// A full-extent item capture keeps the selected-items source, whose single-file
|
||||
// bit already sums a multi-track item selection into one file.
|
||||
CHECK(!isMultiTrackStemRender(SourceMode::SelectedItems, 3));
|
||||
// Razor's single-file bit does the same; master mix is one file by definition.
|
||||
CHECK(!isMultiTrackStemRender(SourceMode::RazorArea, 3));
|
||||
CHECK(!isMultiTrackStemRender(SourceMode::MasterMix, 3));
|
||||
// Realtime never reaches the offline render at all (sends sum into one temp track).
|
||||
CHECK(!isMultiTrackStemRender(SourceMode::Realtime, 3));
|
||||
|
||||
// The predicate is reachable from the mappings it guards: BOTH the ranged item
|
||||
// capture's source mode and the track scope's resolve to the one it names, while a
|
||||
// full-extent item capture does not.
|
||||
CHECK(isMultiTrackStemRender(sourceModeForScope(CaptureScope::Item, false), 2));
|
||||
CHECK(isMultiTrackStemRender(sourceModeForScope(CaptureScope::Track, false), 2));
|
||||
CHECK(isMultiTrackStemRender(sourceModeForScope(CaptureScope::Track, true), 2));
|
||||
CHECK(!isMultiTrackStemRender(sourceModeForScope(CaptureScope::Item, true), 2));
|
||||
}
|
||||
|
||||
static void testRefusalMessagesAreSiblingsWithDistinctExits() {
|
||||
const std::string item = multiTrackRefusalMessage(CaptureScope::Item);
|
||||
const std::string track = multiTrackRefusalMessage(CaptureScope::Track);
|
||||
|
||||
// Both name the same reason — a shape that cannot land as one file — so a user who
|
||||
// hits the mistake in either scope reads one story, not two.
|
||||
CHECK(item.find("single file") != std::string::npos);
|
||||
CHECK(track.find("single file") != std::string::npos);
|
||||
|
||||
// And both name a way out. The shared one is "one track at a time"; each scope then
|
||||
// adds the exit only it has (widen the range / capture the folder).
|
||||
CHECK(item.find("one track's items at a time") != std::string::npos);
|
||||
CHECK(item.find("range match the items' extent") != std::string::npos);
|
||||
CHECK(track.find("one track at a time") != std::string::npos);
|
||||
CHECK(track.find("folder") != std::string::npos);
|
||||
|
||||
// Distinct texts: the track message must not be the item message's wording about
|
||||
// items and ranges, which would misdescribe what the user actually did.
|
||||
CHECK(item != track);
|
||||
CHECK(track.find("selected items") == std::string::npos);
|
||||
}
|
||||
|
||||
// Golden literals: docs/verify-track-scope-multitrack.md §2 quotes the track message as
|
||||
// an exact console match. A substring check alone leaves that doc free to drift from
|
||||
// whatever ships, so pin both strings byte-for-byte here.
|
||||
static void testRefusalMessagesMatchGoldenLiterals() {
|
||||
CHECK(multiTrackRefusalMessage(CaptureScope::Item) ==
|
||||
"This range is narrower than the selected items, so it renders "
|
||||
"through their tracks -- and those items span more than one track, "
|
||||
"which this shape cannot land as a single file. Capture one track's "
|
||||
"items at a time, or make the range match the items' extent.");
|
||||
CHECK(multiTrackRefusalMessage(CaptureScope::Track) ==
|
||||
"A track capture renders the selected tracks through the master, "
|
||||
"and more than one track cannot land as a single file. Capture one "
|
||||
"track at a time, or route them into a folder/bus track and capture "
|
||||
"that (a folder's own output is its children summed).");
|
||||
}
|
||||
|
||||
// --- inferRangeSource: razor-else-time (orthogonal to scope) -----------------
|
||||
@@ -262,8 +362,10 @@ static void testTableHasBothScopes() {
|
||||
// double-prefix bug, so assert its ABSENCE.
|
||||
CHECK(suffix.rfind("CEREBELLUM_REASAMPLER_", 0) != 0);
|
||||
CHECK(ids.insert(suffix).second); // false if duplicate
|
||||
// Every scope resolves to a supported offline source.
|
||||
CHECK(renderSettingsFor(sourceModeForScope(def.scope), 1.0).supported);
|
||||
// Every scope resolves to a supported offline source, on BOTH the
|
||||
// extent-prints-the-window path and the time-bounded one.
|
||||
CHECK(renderSettingsFor(sourceModeForScope(def.scope, true), 1.0).supported);
|
||||
CHECK(renderSettingsFor(sourceModeForScope(def.scope, false), 1.0).supported);
|
||||
|
||||
if (def.scope == CaptureScope::Item) ++item;
|
||||
if (def.scope == CaptureScope::Track) ++track;
|
||||
@@ -307,6 +409,10 @@ int main() {
|
||||
testParseEmptyAndMalformed();
|
||||
testRazorUnionBounds();
|
||||
testScopeSourceModes();
|
||||
testRangedItemScopeRendersTimeBounded();
|
||||
testMultiTrackStemRenderIsNamedForRefusal();
|
||||
testRefusalMessagesAreSiblingsWithDistinctExits();
|
||||
testRefusalMessagesMatchGoldenLiterals();
|
||||
testRangeInference();
|
||||
testItemScopeBypassesEverythingButTake();
|
||||
testTrackScopeKeepsSelfBypassesAncestorsAndMaster();
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Standalone tests for reasampler::render_window — no REAPER, no framework.
|
||||
// Covers the bounds-equality number (a window's exact frame count at the project
|
||||
// rate) and the predicate that decides whether REAPER's selected-items render
|
||||
// source can express a requested window at all.
|
||||
|
||||
#include "../src/core/capture/render_window.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler::capture;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- frameCountFor: the bounds equality, stated as a number ------------------
|
||||
|
||||
static void testFrameCountIsExactNotRounded() {
|
||||
// A 1.5 s window at 48 kHz is exactly 72000 frames — the number a capture of
|
||||
// that range must produce. No rounding slack in either direction.
|
||||
CHECK(frameCountFor(2.0, 3.5, 48000) == 72000);
|
||||
// The same duration at a different offset still counts the same frames when
|
||||
// both edges are frame-aligned.
|
||||
CHECK(frameCountFor(10.0, 11.5, 48000) == 72000);
|
||||
// 44.1 kHz: 0.5 s = 22050 frames.
|
||||
CHECK(frameCountFor(1.0, 1.5, 44100) == 22050);
|
||||
}
|
||||
|
||||
static void testFrameCountIsADifferenceOfIndicesNotADuration() {
|
||||
// Both edges land mid-frame at 100 Hz (0.005 s = half a frame). Rounding the
|
||||
// DURATION would give 1 frame; rounding each EDGE gives 0.005 -> frame 1 and
|
||||
// 0.015 -> frame 2, i.e. 1 frame. Shift the window so the edges round apart
|
||||
// and the count changes — the property that makes this a window, not a length.
|
||||
CHECK(frameCountFor(0.005, 0.015, 100) == 1);
|
||||
CHECK(frameCountFor(0.004, 0.016, 100) == 2);
|
||||
}
|
||||
|
||||
static void testFrameCountRefusesEmptyInvertedAndUnknownRate() {
|
||||
CHECK(frameCountFor(3.0, 3.0, 48000) == 0); // empty
|
||||
CHECK(frameCountFor(3.0, 1.0, 48000) == 0); // inverted
|
||||
CHECK(frameCountFor(1.0, 2.0, 0) == 0); // rate unknown
|
||||
CHECK(frameCountFor(1.0, 2.0, -1) == 0); // rate nonsensical
|
||||
}
|
||||
|
||||
// --- itemExtentPrintsWindow: can the selected-items source express this? -----
|
||||
|
||||
static void testRangeInsideItemCannotBeExpressed() {
|
||||
// The defect this whole module exists for: a 1 s selection inside a 30 s item.
|
||||
// The selected-items source would print the item's 30 s, not the 1 s asked for,
|
||||
// so the capture must NOT take that path.
|
||||
CHECK(!itemExtentPrintsWindow(5.0, 6.0, /*item*/ 0.0, 30.0, 48000));
|
||||
}
|
||||
|
||||
static void testRangeWiderThanItemCannotBeExpressedEither() {
|
||||
// The same violation in the other direction: a 10 s selection over a 6 s item
|
||||
// would print 6 s. Under-printing is a bounds violation exactly as much as
|
||||
// over-printing is.
|
||||
CHECK(!itemExtentPrintsWindow(0.0, 10.0, /*item*/ 2.0, 8.0, 48000));
|
||||
}
|
||||
|
||||
static void testEachEdgeAloneDisqualifies() {
|
||||
// Matching start, drifting end.
|
||||
CHECK(!itemExtentPrintsWindow(2.0, 8.0, 2.0, 9.0, 48000));
|
||||
// Matching end, drifting start.
|
||||
CHECK(!itemExtentPrintsWindow(2.0, 8.0, 1.0, 8.0, 48000));
|
||||
}
|
||||
|
||||
static void testExtentEqualToWindowIsExpressible() {
|
||||
// The regression floor: a capture whose range IS the item's extent keeps the
|
||||
// selected-items render, byte-identical to what it produces today.
|
||||
CHECK(itemExtentPrintsWindow(2.0, 8.0, 2.0, 8.0, 48000));
|
||||
}
|
||||
|
||||
static void testSubFrameDriftStillPrintsTheSameFrames() {
|
||||
// A time selection snapped a fraction of a sample off the item edge prints the
|
||||
// identical frames, so it must NOT be pushed onto the time-bounded path — that
|
||||
// would swap the render mechanism under a capture that was already exact.
|
||||
const double eighthOfAFrameAt48k = 1.0 / (48000.0 * 8.0);
|
||||
CHECK(itemExtentPrintsWindow(2.0 + eighthOfAFrameAt48k, 8.0 - eighthOfAFrameAt48k,
|
||||
2.0, 8.0, 48000));
|
||||
// A full frame of drift is a real difference and must disqualify.
|
||||
const double oneFrameAt48k = 1.0 / 48000.0;
|
||||
CHECK(!itemExtentPrintsWindow(2.0 + oneFrameAt48k, 8.0, 2.0, 8.0, 48000));
|
||||
}
|
||||
|
||||
static void testUnknownRateFallsBackToExactEquality() {
|
||||
// With no project rate there is no frame grid to compare on. Exact equality
|
||||
// still recognizes the regression floor...
|
||||
CHECK(itemExtentPrintsWindow(2.0, 8.0, 2.0, 8.0, 0));
|
||||
// ...and anything else takes the time-bounded render, which honors the request
|
||||
// whatever the rate turns out to be.
|
||||
const double eighthOfAFrameAt48k = 1.0 / (48000.0 * 8.0);
|
||||
CHECK(!itemExtentPrintsWindow(2.0 + eighthOfAFrameAt48k, 8.0, 2.0, 8.0, 0));
|
||||
CHECK(!itemExtentPrintsWindow(5.0, 6.0, 0.0, 30.0, 0));
|
||||
}
|
||||
|
||||
static void testMultiItemUnionExtent() {
|
||||
// Two items spanning 1..4 and 6..9 present a 1..9 union extent to the render.
|
||||
// A selection over the whole union is expressible; one over only the first
|
||||
// item's half is not.
|
||||
CHECK(itemExtentPrintsWindow(1.0, 9.0, 1.0, 9.0, 48000));
|
||||
CHECK(!itemExtentPrintsWindow(1.0, 4.0, 1.0, 9.0, 48000));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFrameCountIsExactNotRounded();
|
||||
testFrameCountIsADifferenceOfIndicesNotADuration();
|
||||
testFrameCountRefusesEmptyInvertedAndUnknownRate();
|
||||
testRangeInsideItemCannotBeExpressed();
|
||||
testRangeWiderThanItemCannotBeExpressedEither();
|
||||
testEachEdgeAloneDisqualifies();
|
||||
testExtentEqualToWindowIsExpressible();
|
||||
testSubFrameDriftStillPrintsTheSameFrames();
|
||||
testUnknownRateFallsBackToExactEquality();
|
||||
testMultiItemUnionExtent();
|
||||
|
||||
if (g_fail) { std::printf("%d check(s) FAILED\n", g_fail); return 1; }
|
||||
std::printf("render_window: all checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Standalone tests for reasampler::view::solo_cache — no REAPER, no test framework.
|
||||
//
|
||||
// Covers: the soloed-subset filter (zeros and empty GUIDs dropped, raw values kept),
|
||||
// the cache's store/query/clear lifecycle including the empty-set-removes rule, GUID
|
||||
// pruning on reconcile, and the restore plan's two drop rules (dead GUID, not visible
|
||||
// in the incoming mode — covering both a parked leaf and a derived-invisible parent).
|
||||
|
||||
#include "../src/core/view/solo_cache.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler::view;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- soloedTracks: the filter ------------------------------------------------
|
||||
|
||||
static void testUnsoloedProjectYieldsNothingToCache() {
|
||||
const std::map<std::string, int> soloed =
|
||||
soloedTracks({{"{A}", 0}, {"{B}", 0}, {"{C}", 0}});
|
||||
CHECK(soloed.empty());
|
||||
}
|
||||
|
||||
static void testSoloedTracksKeepsRawValueNotABoolean() {
|
||||
const std::map<std::string, int> soloed =
|
||||
soloedTracks({{"{A}", 1}, {"{B}", 0}, {"{C}", 2}, {"{D}", 6}});
|
||||
|
||||
CHECK(soloed.size() == 3);
|
||||
CHECK(soloed.at("{A}") == 1); // plain solo
|
||||
CHECK(soloed.at("{C}") == 2); // solo-in-place survives
|
||||
CHECK(soloed.at("{D}") == 6); // safe solo-in-place survives
|
||||
CHECK(soloed.count("{B}") == 0);
|
||||
}
|
||||
|
||||
static void testSoloedTracksDropsEmptyGuids() {
|
||||
const std::map<std::string, int> soloed = soloedTracks({{"", 1}, {"{A}", 1}});
|
||||
CHECK(soloed.size() == 1);
|
||||
CHECK(soloed.count("{A}") == 1);
|
||||
}
|
||||
|
||||
// --- SoloCache: store / query / clear ----------------------------------------
|
||||
|
||||
static void testStoreThenQueryReturnsTheStoredSet() {
|
||||
SoloCache cache;
|
||||
CHECK(cache.store("arrange", {{"{A}", 1}, {"{B}", 2}}));
|
||||
|
||||
const std::map<std::string, int>* got = cache.query("arrange");
|
||||
CHECK(got != nullptr);
|
||||
CHECK(got->size() == 2);
|
||||
CHECK(got->at("{A}") == 1);
|
||||
CHECK(got->at("{B}") == 2);
|
||||
CHECK(cache.query("design") == nullptr);
|
||||
}
|
||||
|
||||
static void testStoringAnEmptySetRemovesTheModeEntry() {
|
||||
SoloCache cache;
|
||||
cache.store("arrange", {{"{A}", 1}});
|
||||
CHECK(cache.query("arrange") != nullptr);
|
||||
|
||||
// Unsoloing everything and switching away must leave no record behind — an
|
||||
// empty record would not survive the serialize round trip.
|
||||
CHECK(cache.store("arrange", {}));
|
||||
CHECK(cache.query("arrange") == nullptr);
|
||||
CHECK(cache.empty());
|
||||
}
|
||||
|
||||
static void testStoreReplacesRatherThanMerges() {
|
||||
SoloCache cache;
|
||||
cache.store("design", {{"{A}", 1}, {"{B}", 1}});
|
||||
cache.store("design", {{"{C}", 2}});
|
||||
|
||||
const std::map<std::string, int>* got = cache.query("design");
|
||||
CHECK(got != nullptr);
|
||||
CHECK(got->size() == 1);
|
||||
CHECK(got->count("{C}") == 1);
|
||||
}
|
||||
|
||||
static void testStoreRejectsAnEmptyModeId() {
|
||||
SoloCache cache;
|
||||
CHECK(!cache.store("", {{"{A}", 1}}));
|
||||
CHECK(cache.empty());
|
||||
}
|
||||
|
||||
static void testClearConsumesOnlyTheNamedMode() {
|
||||
SoloCache cache;
|
||||
cache.store("arrange", {{"{A}", 1}});
|
||||
cache.store("design", {{"{B}", 1}});
|
||||
|
||||
CHECK(cache.clear("arrange"));
|
||||
CHECK(cache.query("arrange") == nullptr);
|
||||
CHECK(cache.query("design") != nullptr);
|
||||
CHECK(!cache.clear("arrange")); // already consumed
|
||||
}
|
||||
|
||||
// --- SoloCache::reconcile ----------------------------------------------------
|
||||
|
||||
static void testReconcileDropsDeadGuidsAcrossEveryMode() {
|
||||
SoloCache cache;
|
||||
cache.store("arrange", {{"{LIVE}", 1}, {"{DEAD}", 2}});
|
||||
cache.store("design", {{"{DEAD}", 1}});
|
||||
|
||||
CHECK(cache.reconcile({"{LIVE}"}) == 2);
|
||||
|
||||
const std::map<std::string, int>* arrange = cache.query("arrange");
|
||||
CHECK(arrange != nullptr);
|
||||
CHECK(arrange->size() == 1);
|
||||
CHECK(arrange->count("{LIVE}") == 1);
|
||||
// The design entry lost its only track, so the mode record goes with it.
|
||||
CHECK(cache.query("design") == nullptr);
|
||||
}
|
||||
|
||||
static void testReconcileWithEveryGuidLiveRemovesNothing() {
|
||||
SoloCache cache;
|
||||
cache.store("arrange", {{"{A}", 1}, {"{B}", 5}});
|
||||
CHECK(cache.reconcile({"{A}", "{B}", "{UNRELATED}"}) == 0);
|
||||
CHECK(cache.query("arrange")->size() == 2);
|
||||
}
|
||||
|
||||
// --- planSoloRestore ---------------------------------------------------------
|
||||
|
||||
static void testRestorePlanReplaysEveryLiveVisibleEntryVerbatim() {
|
||||
const std::vector<SoloOp> ops =
|
||||
planSoloRestore({{"{A}", 1}, {"{B}", 2}}, {"{A}", "{B}"}, {"{A}", "{B}"});
|
||||
|
||||
CHECK(ops.size() == 2);
|
||||
CHECK(ops[0] == (SoloOp{"{A}", 1}));
|
||||
CHECK(ops[1] == (SoloOp{"{B}", 2}));
|
||||
}
|
||||
|
||||
static void testRestorePlanSkipsAGuidThatNoLongerExists() {
|
||||
const std::vector<SoloOp> ops =
|
||||
planSoloRestore({{"{GONE}", 1}, {"{HERE}", 1}}, {"{HERE}"}, {"{HERE}"});
|
||||
|
||||
CHECK(ops.size() == 1);
|
||||
CHECK(ops[0].guid == "{HERE}");
|
||||
}
|
||||
|
||||
static void testRestorePlanSkipsAParkedLeafNotVisibleInTheIncomingMode() {
|
||||
// Soloing a parked track would silence the mix while contributing nothing
|
||||
// audible, and the track is hidden, so the user could not undo it.
|
||||
const std::vector<SoloOp> ops =
|
||||
planSoloRestore({{"{PARKED}", 1}, {"{VISIBLE}", 2}}, {"{PARKED}", "{VISIBLE}"},
|
||||
{"{VISIBLE}"});
|
||||
|
||||
CHECK(ops.size() == 1);
|
||||
CHECK(ops[0] == (SoloOp{"{VISIBLE}", 2}));
|
||||
}
|
||||
|
||||
static void testRestorePlanSkipsAFolderParentHiddenInTheIncomingMode() {
|
||||
// The parent is never parked (parents are never parked — see planToggle), but a
|
||||
// derived-invisible parent is hidden all the same: replaying its cached solo
|
||||
// would silence the mix onto a track the user cannot see or unsolo. `visibleGuids`
|
||||
// (not the park plan) is the drop set precisely so this case is caught too.
|
||||
const std::vector<SoloOp> ops =
|
||||
planSoloRestore({{"{HIDDEN_PARENT}", 1}}, {"{HIDDEN_PARENT}"}, {});
|
||||
|
||||
CHECK(ops.empty());
|
||||
}
|
||||
|
||||
static void testRestorePlanOfAnEmptyCacheWritesNothing() {
|
||||
CHECK(planSoloRestore({}, {"{A}"}, {}).empty());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testUnsoloedProjectYieldsNothingToCache();
|
||||
testSoloedTracksKeepsRawValueNotABoolean();
|
||||
testSoloedTracksDropsEmptyGuids();
|
||||
|
||||
testStoreThenQueryReturnsTheStoredSet();
|
||||
testStoringAnEmptySetRemovesTheModeEntry();
|
||||
testStoreReplacesRatherThanMerges();
|
||||
testStoreRejectsAnEmptyModeId();
|
||||
testClearConsumesOnlyTheNamedMode();
|
||||
|
||||
testReconcileDropsDeadGuidsAcrossEveryMode();
|
||||
testReconcileWithEveryGuidLiveRemovesNothing();
|
||||
|
||||
testRestorePlanReplaysEveryLiveVisibleEntryVerbatim();
|
||||
testRestorePlanSkipsAGuidThatNoLongerExists();
|
||||
testRestorePlanSkipsAParkedLeafNotVisibleInTheIncomingMode();
|
||||
testRestorePlanSkipsAFolderParentHiddenInTheIncomingMode();
|
||||
testRestorePlanOfAnEmptyCacheWritesNothing();
|
||||
|
||||
if (g_fail == 0) std::printf("solo_cache: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -250,6 +250,25 @@ static void testTextOnHoverSurfaceClearsFloor() {
|
||||
CHECK(contrastRatio(roleColor(Role::TextDim), hover) >= textFloor(TextClass::Large));
|
||||
}
|
||||
|
||||
// The card name strip (panel_render.cpp's drawCardName) draws text/primary Micro (10px, BODY
|
||||
// class) over a bg/base scrim at kCardNameScrimAlpha, itself drawn over whatever the waveform
|
||||
// left in that rect. Enumerate both backgrounds a loud/quiet capture can leave there: the
|
||||
// accent-lime fill (a full-scale peak reaching the strip) and bare bg/cell (a quiet capture,
|
||||
// nothing painted that high). Unscrimmed, text/primary reads ~1:1 against the fill — this is
|
||||
// the load-bearing check that the scrim actually fixes it.
|
||||
static void testCardNameScrimClearsBodyFloorOnItsWorstBackground() {
|
||||
const KitColor scrim = roleColor(Role::BgBase);
|
||||
const KitColor onFill = compositeOver(scrim, roleColor(Role::AccentPrimary),
|
||||
kCardNameScrimAlpha);
|
||||
const KitColor onCell = compositeOver(scrim, roleColor(Role::BgCell), kCardNameScrimAlpha);
|
||||
CHECK(contrastRatio(roleColor(Role::TextPrimary), onFill) >= textFloor(TextClass::Body));
|
||||
CHECK(contrastRatio(roleColor(Role::TextPrimary), onCell) >= textFloor(TextClass::Body));
|
||||
// Without the scrim, text/primary on the bare accent-lime fill is UNDER floor — pins the
|
||||
// defect the scrim exists to close, so a future removal of the scrim fails this first.
|
||||
CHECK(contrastRatio(roleColor(Role::TextPrimary), roleColor(Role::AccentPrimary))
|
||||
< textFloor(TextClass::Body));
|
||||
}
|
||||
|
||||
// --- Single point of change (structural guarantee) ----------------------------
|
||||
//
|
||||
// roleColor is the ONLY color source; there is no other public accessor that yields a
|
||||
@@ -346,6 +365,7 @@ int main() {
|
||||
testRegionTitleAccentsClearLargeFloorOnPanel();
|
||||
testTextOnPastelFillClearsBodyFloor();
|
||||
testTextOnHoverSurfaceClearsFloor();
|
||||
testCardNameScrimClearsBodyFloorOnItsWorstBackground();
|
||||
testCurveTraceOnHoverSurfaceClearsFloor();
|
||||
testSecondaryTertiaryAreDistinguishable();
|
||||
testOverlayTraceClearsIndicatorFloorOnTheWaveform();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Standalone tests for reasampler::track_topology — no REAPER, no framework.
|
||||
// Covers the direct-child walk over a flat I_FOLDERDEPTH delta list: the set a
|
||||
// ranged item render must silence so a folder parent's children stay out of the
|
||||
// capture.
|
||||
|
||||
#include "../src/core/capture/track_topology.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler::capture;
|
||||
|
||||
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 sameIndices(const std::vector<int>& got, const std::vector<int>& want) {
|
||||
return got == want;
|
||||
}
|
||||
|
||||
static void testFlatProjectHasNoChildren() {
|
||||
// No track opens a folder, so no track has children — including the one asked
|
||||
// about. A flat project must produce an empty silence set, not a stray one.
|
||||
const std::vector<int> depths{0, 0, 0};
|
||||
CHECK(sameIndices(directChildIndices(depths, 0), {}));
|
||||
CHECK(sameIndices(directChildIndices(depths, 1), {}));
|
||||
}
|
||||
|
||||
static void testFolderParentReturnsItsDirectChildren() {
|
||||
// 0: folder parent, 1: child, 2: last child (closes the folder), 3: unrelated.
|
||||
const std::vector<int> depths{1, 0, -1, 0};
|
||||
CHECK(sameIndices(directChildIndices(depths, 0), {1, 2}));
|
||||
// Track 3 sits outside the folder entirely and owns nothing.
|
||||
CHECK(sameIndices(directChildIndices(depths, 3), {}));
|
||||
}
|
||||
|
||||
static void testGrandchildrenAreExcluded() {
|
||||
// 0: outer folder, 1: inner folder (a direct child), 2: grandchild closing the
|
||||
// inner folder, 3: second direct child closing the outer. The grandchild reaches
|
||||
// track 0 only through track 1, so silencing track 1's send covers it — listing
|
||||
// it too would be redundant, and the walk must not.
|
||||
const std::vector<int> depths{1, 1, -1, -1};
|
||||
CHECK(sameIndices(directChildIndices(depths, 0), {1, 3}));
|
||||
// Asked about the inner folder, the grandchild IS the direct child.
|
||||
CHECK(sameIndices(directChildIndices(depths, 1), {2}));
|
||||
}
|
||||
|
||||
static void testMultiLevelCloseEndsTheOuterFolderToo() {
|
||||
// A -2 closes two folders at once (SDK: "last in the innermost and next-innermost
|
||||
// folders"), so track 3 is outside track 0's folder even though no track between
|
||||
// them closed it singly.
|
||||
const std::vector<int> depths{1, 1, -2, 0};
|
||||
CHECK(sameIndices(directChildIndices(depths, 0), {1}));
|
||||
CHECK(sameIndices(directChildIndices(depths, 1), {2}));
|
||||
}
|
||||
|
||||
static void testNonFolderAndOutOfRangeReturnEmpty() {
|
||||
const std::vector<int> depths{1, 0, -1};
|
||||
CHECK(sameIndices(directChildIndices(depths, 1), {})); // a plain child
|
||||
CHECK(sameIndices(directChildIndices(depths, 2), {})); // the folder's last track
|
||||
CHECK(sameIndices(directChildIndices(depths, -1), {})); // no such track
|
||||
CHECK(sameIndices(directChildIndices(depths, 3), {})); // past the end
|
||||
CHECK(sameIndices(directChildIndices({}, 0), {})); // empty project
|
||||
}
|
||||
|
||||
static void testUnterminatedFolderSwallowsTheRest() {
|
||||
// A folder that never closes owns every remaining track, which is how REAPER
|
||||
// reads the same list — the walk must not stop early and leave a child audible.
|
||||
const std::vector<int> depths{1, 0, 0};
|
||||
CHECK(sameIndices(directChildIndices(depths, 0), {1, 2}));
|
||||
}
|
||||
|
||||
static void testSiblingFolderAfterParentClosesIsNotIncluded() {
|
||||
// 0: folder parent, 1: child, 2: last child (closes it). 3: an unrelated sibling
|
||||
// folder that opens AFTER track 0's folder has already closed, 4: its child, 5:
|
||||
// its last child. A walk that fails to stop at track 0's own close would keep
|
||||
// consuming and wrongly pull the sibling's children in too.
|
||||
const std::vector<int> depths{1, 0, -1, 1, 0, -1};
|
||||
CHECK(sameIndices(directChildIndices(depths, 0), {1, 2}));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFlatProjectHasNoChildren();
|
||||
testFolderParentReturnsItsDirectChildren();
|
||||
testGrandchildrenAreExcluded();
|
||||
testMultiLevelCloseEndsTheOuterFolderToo();
|
||||
testNonFolderAndOutOfRangeReturnEmpty();
|
||||
testUnterminatedFolderSwallowsTheRest();
|
||||
testSiblingFolderAfterParentClosesIsNotIncluded();
|
||||
|
||||
if (g_fail == 0) std::printf("track_topology: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -67,7 +67,7 @@ static void testSerializeGoldenLiteral() {
|
||||
"{\"version\":1,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\","
|
||||
"\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\","
|
||||
"\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[],"
|
||||
"\"snapshots\":[],\"lanes\":[]}");
|
||||
"\"snapshots\":[],\"lanes\":[],\"soloCache\":[]}");
|
||||
}
|
||||
|
||||
// -- 1. N-mode proven --------------------------------------------------------
|
||||
@@ -1800,6 +1800,88 @@ static void testLaneMalformedJson() {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Per-mode solo cache: persistence + reconcile participation ---------------
|
||||
|
||||
static void testSoloCacheJsonRoundTrip() {
|
||||
ViewModeModel vm;
|
||||
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
|
||||
vm.membership().tag("{T}", kDesignModeId);
|
||||
vm.storeSnapshot("{T}", TrackSnapshot{1, 1, 1, 1, {0, 1}});
|
||||
CHECK(vm.lanes().setManaged("{T}", "lane:0", kArrangeModeId));
|
||||
|
||||
// Every non-zero I_SOLO variant, across more than one mode, plus a GUID that
|
||||
// exercises the string escaper.
|
||||
CHECK(vm.soloCache().store(kArrangeModeId, {{"{A}", 1}, {"{B\"q\"}", 2}}));
|
||||
CHECK(vm.soloCache().store("mixdown", {{"{C}", 5}, {"{D}", 6}}));
|
||||
CHECK(vm.setActiveMode(kDesignModeId));
|
||||
|
||||
const std::string json = vm.serialize();
|
||||
auto back = ViewModeModel::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back && *back == vm); // deserialize(serialize(x)) == x
|
||||
if (back) CHECK(back->serialize() == json); // stable second round-trip
|
||||
|
||||
if (back) {
|
||||
const std::map<std::string, int>* arrange = back->soloCache().query(kArrangeModeId);
|
||||
CHECK(arrange != nullptr);
|
||||
CHECK(arrange && arrange->at("{A}") == 1);
|
||||
CHECK(arrange && arrange->at("{B\"q\"}") == 2);
|
||||
const std::map<std::string, int>* mix = back->soloCache().query("mixdown");
|
||||
CHECK(mix != nullptr);
|
||||
CHECK(mix && mix->at("{C}") == 5);
|
||||
CHECK(mix && mix->at("{D}") == 6);
|
||||
CHECK(back->soloCache().query(kDesignModeId) == nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
static void testBlobWithoutSoloCacheKeyStillParses() {
|
||||
// The compatibility case both ways: a project saved by a build that predates the
|
||||
// key parses to an empty cache, and its own output stays readable here.
|
||||
const char* older =
|
||||
"{\"version\":1,\"activeMode\":\"design\",\"modes\":[{\"id\":\"arrange\","
|
||||
"\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\","
|
||||
"\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[{\"guid\":\"{T}\","
|
||||
"\"modes\":[\"design\"],\"showBoth\":false}],\"snapshots\":[],\"lanes\":[]}";
|
||||
|
||||
auto back = ViewModeModel::deserialize(older);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back && back->soloCache().empty());
|
||||
CHECK(back && back->activeModeId() == kDesignModeId);
|
||||
CHECK(back && back->membership().query("{T}") != nullptr);
|
||||
}
|
||||
|
||||
static void testSoloCacheMalformedJson() {
|
||||
const char* bad[] = {
|
||||
"{\"soloCache\":[{\"tracks\":[{\"guid\":\"{A}\",\"solo\":1}]}]}", // missing mode
|
||||
"{\"soloCache\":[{\"mode\":\"\",\"tracks\":[{\"guid\":\"{A}\",\"solo\":1}]}]}", // empty mode
|
||||
"{\"soloCache\":[{\"mode\":\"arrange\"}]}", // missing tracks
|
||||
"{\"soloCache\":[{\"mode\":\"arrange\",\"tracks\":[]}]}", // empty tracks
|
||||
"{\"soloCache\":[{\"mode\":\"arrange\",\"tracks\":[{\"solo\":1}]}]}", // missing guid
|
||||
"{\"soloCache\":[{\"mode\":\"arrange\",\"tracks\":[{\"guid\":\"{A}\"}]}]}", // missing solo
|
||||
"{\"soloCache\":[{\"mode\":\"arrange\",\"tracks\":[{\"guid\":\"\",\"solo\":1}]}]}", // empty guid
|
||||
"{\"soloCache\":[", // truncated
|
||||
};
|
||||
for (const char* j : bad) {
|
||||
auto r = ViewModeModel::deserialize(j);
|
||||
CHECK(!r.has_value());
|
||||
}
|
||||
}
|
||||
|
||||
static void testReconcilePrunesTheSoloCacheAlongsideSnapshots() {
|
||||
ViewModeModel vm;
|
||||
vm.storeSnapshot("{LIVE}", TrackSnapshot{1, 1, 1, 1, {}});
|
||||
vm.storeSnapshot("{DEAD}", TrackSnapshot{1, 1, 1, 1, {}});
|
||||
vm.soloCache().store(kDesignModeId, {{"{LIVE}", 1}, {"{DEAD}", 2}});
|
||||
|
||||
// The return stays the SNAPSHOT count; the solo cache is pruned by the same call.
|
||||
CHECK(vm.reconcile({"{LIVE}"}) == 1);
|
||||
|
||||
const std::map<std::string, int>* design = vm.soloCache().query(kDesignModeId);
|
||||
CHECK(design != nullptr);
|
||||
CHECK(design && design->size() == 1);
|
||||
CHECK(design && design->count("{LIVE}") == 1);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testSerializeGoldenLiteral();
|
||||
testNModeRegistryAndMembership();
|
||||
@@ -1843,6 +1925,12 @@ int main() {
|
||||
testLaneJsonRoundTrip();
|
||||
testLaneMalformedJson();
|
||||
|
||||
// Per-mode solo cache
|
||||
testSoloCacheJsonRoundTrip();
|
||||
testBlobWithoutSoloCacheKeyStillParses();
|
||||
testSoloCacheMalformedJson();
|
||||
testReconcilePrunesTheSoloCacheAlongsideSnapshots();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
|
||||
+214
-1
@@ -11,10 +11,14 @@
|
||||
// buildFloat32Wav golden header + parse round-trip; hashBytes/hashWavContent
|
||||
// determinism, metadata-skip, fallback, and domain separation; a golden hash
|
||||
// literal pinning exact hex output for a fixed input (guards persisted
|
||||
// contentHash values against a silent feed-sequence drift).
|
||||
// contentHash values against a silent feed-sequence drift); and the lossless mono
|
||||
// collapse (bit-identical N-channel fold, the one-sample-differs and signed-zero
|
||||
// declines, already-mono, zero/single-frame, an odd padded leading chunk, and the
|
||||
// content-hash consequence).
|
||||
|
||||
#include "../src/core/capture/wav_codec.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -47,6 +51,16 @@ static void putFloat(std::vector<std::uint8_t>& b, float f) {
|
||||
std::memcpy(tmp, &f, 4);
|
||||
for (int i = 0; i < 4; ++i) b.push_back(tmp[i]);
|
||||
}
|
||||
static float floatFromBits(std::uint32_t bits) {
|
||||
float f;
|
||||
std::memcpy(&f, &bits, 4);
|
||||
return f;
|
||||
}
|
||||
static std::uint32_t bitsFromFloat(float f) {
|
||||
std::uint32_t bits;
|
||||
std::memcpy(&bits, &f, 4);
|
||||
return bits;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -593,6 +607,194 @@ static void testGoldenHashLiterals() {
|
||||
CHECK(hashBytes(wav.data(), wav.size()) == "68d8a193c958fd44");
|
||||
}
|
||||
|
||||
// --- Lossless mono collapse --------------------------------------------------
|
||||
|
||||
// Every channel carries frame f's value; the collapse must keep those values verbatim
|
||||
// in one channel and leave frame count / rate / bit depth alone.
|
||||
static void testCollapseBitIdenticalStereo() {
|
||||
auto wav = buildFloatWav(2, 48000, 6,
|
||||
[](std::size_t f, std::uint16_t) {
|
||||
return 0.25f * static_cast<float>(f) - 0.5f;
|
||||
});
|
||||
const MonoCollapse c = collapseToMono(wav);
|
||||
CHECK(c.collapsed);
|
||||
|
||||
const WavLayout L = parseWavLayout(c.bytes);
|
||||
CHECK(L.valid); // valid implies float32: the parser rejects anything else
|
||||
CHECK(L.channelCount == 1);
|
||||
CHECK(L.sampleRate == 48000);
|
||||
CHECK(L.frameCount() == 6);
|
||||
|
||||
const auto pcm = extractFloatFrames(c.bytes, L, 0, 6);
|
||||
CHECK(pcm.size() == 6);
|
||||
for (std::size_t f = 0; f < 6 && f < pcm.size(); ++f)
|
||||
CHECK(pcm[f] == 0.25f * static_cast<float>(f) - 0.5f);
|
||||
}
|
||||
|
||||
static void testCollapseDeclinesOnOneDifferingSample() {
|
||||
// Identical everywhere except frame 4's right channel, by the smallest step the
|
||||
// format can express near 1.0.
|
||||
auto wav = buildFloatWav(2, 48000, 8,
|
||||
[](std::size_t f, std::uint16_t ch) {
|
||||
float v = 1.0f + static_cast<float>(f);
|
||||
if (f == 4 && ch == 1) v = nextafterf(v, 2.0f);
|
||||
return v;
|
||||
});
|
||||
CHECK(!collapseToMono(wav).collapsed);
|
||||
CHECK(collapseToMono(wav).bytes.empty());
|
||||
}
|
||||
|
||||
// An already-mono file must come back untouched — a second capture pass over a
|
||||
// collapsed file must not rebuild (and so must not re-hash) it.
|
||||
static void testCollapseDeclinesOnAlreadyMono() {
|
||||
auto wav = buildFloatWav(1, 44100, 4,
|
||||
[](std::size_t f, std::uint16_t) {
|
||||
return static_cast<float>(f);
|
||||
});
|
||||
CHECK(!collapseToMono(wav).collapsed);
|
||||
}
|
||||
|
||||
// N-channel generalization: all-identical collapses to ONE channel, never a partial
|
||||
// fold (4 -> 2). Unreachable from today's capture paths, which always render 2.
|
||||
static void testCollapseFourChannels() {
|
||||
auto same = buildFloatWav(4, 48000, 5,
|
||||
[](std::size_t f, std::uint16_t) {
|
||||
return -0.125f * static_cast<float>(f);
|
||||
});
|
||||
const MonoCollapse c = collapseToMono(same);
|
||||
CHECK(c.collapsed);
|
||||
const WavLayout L = parseWavLayout(c.bytes);
|
||||
CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 5);
|
||||
|
||||
auto oneDiffers = buildFloatWav(4, 48000, 5,
|
||||
[](std::size_t f, std::uint16_t ch) {
|
||||
float v = -0.125f * static_cast<float>(f);
|
||||
if (f == 2 && ch == 3) v += 0.5f;
|
||||
return v;
|
||||
});
|
||||
CHECK(!collapseToMono(oneDiffers).collapsed);
|
||||
}
|
||||
|
||||
static void testCollapseZeroAndSingleFrame() {
|
||||
// No frame of evidence that the channels agree -> decline rather than rebuild.
|
||||
auto empty = buildFloatWav(2, 48000, 0,
|
||||
[](std::size_t, std::uint16_t) { return 0.0f; });
|
||||
CHECK(parseWavLayout(empty).valid && parseWavLayout(empty).frameCount() == 0);
|
||||
CHECK(!collapseToMono(empty).collapsed);
|
||||
|
||||
auto one = buildFloatWav(2, 48000, 1,
|
||||
[](std::size_t, std::uint16_t) { return 0.75f; });
|
||||
const MonoCollapse c = collapseToMono(one);
|
||||
CHECK(c.collapsed);
|
||||
const WavLayout L = parseWavLayout(c.bytes);
|
||||
CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 1);
|
||||
const auto pcm = extractFloatFrames(c.bytes, L, 0, 1);
|
||||
CHECK(pcm.size() == 1 && pcm[0] == 0.75f);
|
||||
}
|
||||
|
||||
// The predicate is over BIT PATTERNS: -0.0f == +0.0f compares equal as floats but is
|
||||
// a different value on disk, so folding it would not be lossless.
|
||||
static void testCollapseSignedZeroIsNotIdentical() {
|
||||
auto wav = buildFloatWav(2, 48000, 3,
|
||||
[](std::size_t, std::uint16_t ch) {
|
||||
return ch == 0 ? 0.0f : -0.0f;
|
||||
});
|
||||
CHECK(!collapseToMono(wav).collapsed);
|
||||
}
|
||||
|
||||
// A leading odd-sized chunk exercises the walk's RIFF pad byte; the rebuilt file is
|
||||
// canonical, so that chunk does not survive.
|
||||
static void testCollapseThroughOddPaddedLeadingChunk() {
|
||||
std::vector<std::uint8_t> chunks;
|
||||
putTag(chunks, "LIST");
|
||||
putU32(chunks, 5); // odd body -> one pad byte
|
||||
for (int i = 0; i < 5; ++i) chunks.push_back(0x41);
|
||||
chunks.push_back(0); // the pad
|
||||
putTag(chunks, "fmt ");
|
||||
putU32(chunks, 16);
|
||||
putU16(chunks, 3);
|
||||
putU16(chunks, 2);
|
||||
putU32(chunks, 48000);
|
||||
putU32(chunks, 48000u * 2u * 4u);
|
||||
putU16(chunks, 8);
|
||||
putU16(chunks, 32);
|
||||
putTag(chunks, "data");
|
||||
putU32(chunks, 3u * 2u * 4u);
|
||||
for (std::size_t f = 0; f < 3; ++f)
|
||||
for (int ch = 0; ch < 2; ++ch) putFloat(chunks, 0.5f * static_cast<float>(f));
|
||||
|
||||
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());
|
||||
|
||||
const MonoCollapse c = collapseToMono(wav);
|
||||
CHECK(c.collapsed);
|
||||
const WavLayout L = parseWavLayout(c.bytes);
|
||||
CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 3);
|
||||
// Canonical rebuild: byte-for-byte what buildFloat32Wav produces for the same PCM.
|
||||
CHECK(c.bytes == buildFloat32Wav(1, 48000, 3, {0.0, 0.5, 1.0}));
|
||||
}
|
||||
|
||||
static void testCollapseDeclinesOnUnparseableBytes() {
|
||||
std::vector<std::uint8_t> junk = {'N','O','P','E', 0,0,0,0, 'W','A','V','E'};
|
||||
CHECK(!collapseToMono(junk).collapsed);
|
||||
CHECK(!collapseToMono(std::vector<std::uint8_t>{}).collapsed);
|
||||
}
|
||||
|
||||
// Stated consequence, pinned: the collapse rewrites both the `fmt ` body and the
|
||||
// `data` payload, so a collapsed capture no longer shares content identity with the
|
||||
// stereo file it came from and will not dedup against one already in the bank.
|
||||
static void testCollapseChangesContentHash() {
|
||||
auto wav = buildFloatWav(2, 48000, 4,
|
||||
[](std::size_t f, std::uint16_t) {
|
||||
return static_cast<float>(f);
|
||||
});
|
||||
const MonoCollapse c = collapseToMono(wav);
|
||||
CHECK(c.collapsed);
|
||||
CHECK(hashWavContent(c.bytes) != hashWavContent(wav));
|
||||
}
|
||||
|
||||
// The float->double->float rebuild's stated hole is a SIGNALING NaN (double promotion
|
||||
// quiets it); a QUIET NaN is not that hole. Both channels carry the identical
|
||||
// quiet-NaN bit pattern, so the predicate collapses; the rebuilt mono channel must
|
||||
// carry that exact bit pattern back, not merely "some NaN".
|
||||
static void testCollapsePreservesQuietNaNBitPattern() {
|
||||
constexpr std::uint32_t kQuietNaNBits = 0x7FC12345u; // exponent all-ones, mantissa MSB set
|
||||
auto wav = buildFloatWav(2, 48000, 1,
|
||||
[kQuietNaNBits](std::size_t, std::uint16_t) {
|
||||
return floatFromBits(kQuietNaNBits);
|
||||
});
|
||||
const MonoCollapse c = collapseToMono(wav);
|
||||
CHECK(c.collapsed);
|
||||
const WavLayout L = parseWavLayout(c.bytes);
|
||||
CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 1);
|
||||
const auto pcm = extractFloatFrames(c.bytes, L, 0, 1);
|
||||
CHECK(pcm.size() == 1);
|
||||
if (!pcm.empty()) CHECK(bitsFromFloat(pcm[0]) == kQuietNaNBits);
|
||||
}
|
||||
|
||||
// The file-side collapse has three outcomes, and a genuine I/O failure once reported
|
||||
// identically to "the channels differ" — a stereo file landing under a plain Ok. The
|
||||
// report is where they must part: Declined stays silent (a capture with nothing to
|
||||
// collapse reads as it always did), and the other two say different things.
|
||||
// Pins the three suffix STRINGS, not user-observable behavior — see wav_codec.h's
|
||||
// monoCollapseSuffix doc: this text reaches no one on a successful capture.
|
||||
static void testCollapseOutcomeSuffixesAreDistinctStrings() {
|
||||
const std::string declined = monoCollapseSuffix(MonoCollapseOutcome::Declined);
|
||||
const std::string collapsed = monoCollapseSuffix(MonoCollapseOutcome::Collapsed);
|
||||
const std::string failed = monoCollapseSuffix(MonoCollapseOutcome::Failed);
|
||||
|
||||
CHECK(declined.empty());
|
||||
CHECK(!collapsed.empty());
|
||||
CHECK(!failed.empty());
|
||||
CHECK(collapsed != failed);
|
||||
// The failure must read as a failure, not as a quieter success.
|
||||
CHECK(failed.find("failed") != std::string::npos);
|
||||
CHECK(collapsed.find("failed") == std::string::npos);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testParseCanonicalStereo();
|
||||
testParseMonoAndLeadingChunk();
|
||||
@@ -618,6 +820,17 @@ int main() {
|
||||
testHashWavContentDomainSeparationFromWholeFile();
|
||||
testHashMatchesBuildOutput();
|
||||
testGoldenHashLiterals();
|
||||
testCollapseBitIdenticalStereo();
|
||||
testCollapseDeclinesOnOneDifferingSample();
|
||||
testCollapseDeclinesOnAlreadyMono();
|
||||
testCollapseFourChannels();
|
||||
testCollapseZeroAndSingleFrame();
|
||||
testCollapseSignedZeroIsNotIdentical();
|
||||
testCollapseThroughOddPaddedLeadingChunk();
|
||||
testCollapseDeclinesOnUnparseableBytes();
|
||||
testCollapseChangesContentHash();
|
||||
testCollapsePreservesQuietNaNBitPattern();
|
||||
testCollapseOutcomeSuffixesAreDistinctStrings();
|
||||
|
||||
if (g_fail == 0) std::printf("wav_codec: all tests passed\n");
|
||||
else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail);
|
||||
|
||||
Reference in New Issue
Block a user