Merge dev (tail-toggle panel refactor) into D2 Wave 2

# Conflicts:
#	src/bank_panel.cpp
This commit is contained in:
2026-07-23 18:13:18 -04:00
15 changed files with 720 additions and 124 deletions
+17 -1
View File
@@ -118,6 +118,18 @@ add_library(render_settings STATIC src/render_settings.cpp)
target_include_directories(render_settings PUBLIC src) target_include_directories(render_settings PUBLIC src)
target_link_libraries(render_settings PUBLIC bank_model) target_link_libraries(render_settings PUBLIC bank_model)
# ---------------------------------------------------------------------------
# 2f') Pure tail_control library — NO REAPER, NO SWELL. The docked bank_panel's
# tail-mode toggle logic (T1 exposure): TailSetting state, cycle order
# (None->Auto->Manual->None), the manual-length clamp to the 8 s cap, and the
# toggle label text. Split out so the toggle's cycle/clamp/label is unit-tested
# outside the DAW; the bank_panel footer that draws it + routes clicks is
# DAW-verified. Depends on render_settings for the pure TailMode enum + caps.
# ---------------------------------------------------------------------------
add_library(tail_control STATIC src/tail_control.cpp)
target_include_directories(tail_control PUBLIC src)
target_link_libraries(tail_control PUBLIC render_settings)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 2g) Pure realtime_record library — NO REAPER, NO SWELL. The M8 realtime-record # 2g) Pure realtime_record library — NO REAPER, NO SWELL. The M8 realtime-record
# logic: capture scope + FX-tap point -> I_RECMODE / I_RECMODE_FLAGS values, # logic: capture scope + FX-tap point -> I_RECMODE / I_RECMODE_FLAGS values,
@@ -178,6 +190,10 @@ add_executable(render_settings_tests tests/test_render_settings.cpp)
target_link_libraries(render_settings_tests PRIVATE render_settings) target_link_libraries(render_settings_tests PRIVATE render_settings)
add_test(NAME render_settings_tests COMMAND render_settings_tests) add_test(NAME render_settings_tests COMMAND render_settings_tests)
add_executable(tail_control_tests tests/test_tail_control.cpp)
target_link_libraries(tail_control_tests PRIVATE tail_control)
add_test(NAME tail_control_tests COMMAND tail_control_tests)
add_executable(realtime_record_tests tests/test_realtime_record.cpp) add_executable(realtime_record_tests tests/test_realtime_record.cpp)
target_link_libraries(realtime_record_tests PRIVATE realtime_record) target_link_libraries(realtime_record_tests PRIVATE realtime_record)
add_test(NAME realtime_record_tests COMMAND realtime_record_tests) add_test(NAME realtime_record_tests COMMAND realtime_record_tests)
@@ -215,7 +231,7 @@ add_library(reaper_reasampler MODULE
src/lane_keys.cpp src/lane_keys.cpp
src/actions.cpp src/actions.cpp
) )
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch view_mode_model insert_plan render_settings realtime_record) target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch view_mode_model insert_plan render_settings tail_control realtime_record)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler") set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
+126
View File
@@ -307,3 +307,129 @@ auto-inserts into the arrange.
- **FX-scope semantics (initial rework):** item = item/take FX only; track = item FX + the selected track's own track FX; master = full chain. For item/track, the out-of-scope chain (ancestors + master, plus the item's own track for item scope) is neutralized during the render. - **FX-scope semantics (initial rework):** item = item/take FX only; track = item FX + the selected track's own track FX; master = full chain. For item/track, the out-of-scope chain (ancestors + master, plus the item's own track for item scope) is neutralized during the render.
- **Master scope subsequently removed:** capture is now **two scopes — item and track only**. `CAPTURE_MASTER` and `CAPTURE_MASTER_REALTIME` are retired (to capture the master, render a track instead). The master track is still neutralized as out-of-scope chain for both item and track captures; it is a bypass target, not a capture scope. The realtime backend taps the selected track (track scope only; item realtime deferred). - **Master scope subsequently removed:** capture is now **two scopes — item and track only**. `CAPTURE_MASTER` and `CAPTURE_MASTER_REALTIME` are retired (to capture the master, render a track instead). The master track is still neutralized as out-of-scope chain for both item and track captures; it is a bypass target, not a capture scope. The realtime backend taps the selected track (track scope only; item realtime deferred).
- **`FxBypassGuard` (RAII):** snapshot → neutralize (FX bypassed via `I_FXEN`; gain zeroed via `D_VOL`; pan/width/pan-law/mode set to unity via `D_PAN`/`D_WIDTH`/`D_PANLAW`/`I_PANMODE`) → render → restore. Non-destructive. This guard is the reusable mechanism M8 (realtime backend) and M10 (null-test / true dry) build on. - **`FxBypassGuard` (RAII):** snapshot → neutralize (FX bypassed via `I_FXEN`; gain zeroed via `D_VOL`; pan/width/pan-law/mode set to unity via `D_PAN`/`D_WIDTH`/`D_PANLAW`/`I_PANMODE`) → render → restore. Non-destructive. This guard is the reusable mechanism M8 (realtime backend) and M10 (null-test / true dry) build on.
---
## Milestone 8 — RealtimeRecordBackend
**Goal:** Realtime record behind the same `ICaptureBackend`, producing identical
bank entries. CONTEXT.md §capture (realtime), §Precision invariants.
**Verify (in DAW):** Hidden temp track taps each selected track's own post-fader
output via a `CreateTrackSend`; recorded file moves into the bank; **non-destructive**
— temp track (and its sends) removed cleanly, every snapshotted track arm, time
selection, and edit cursor restored unchanged on every terminal path.
- [x] Track-scope tap: a `CreateTrackSend(source, temp)` from each selected track
into a hidden temp track (`B_MAINSEND=0`, hidden from TCP/mixer). The temp records
its own post-fader output — capturing each source track's output **after its own FX
and fader, before the parent/folder/master sums it** — chain-independent by
construction. No `FxBypassGuard` needed or used. Multiple selected tracks sum in the
temp track (matching offline track scope). Item realtime deferred (`UnsupportedMode`).
No track selected → refused.
- [x] Timer-driven async state machine (`begin`/`tick`/`abort` driven by `OnTimer`,
non-blocking — REAPER's UI stays responsive across the record). `begin()` validates,
snapshots all state, creates the temp track, routes the tap, arms, calls
`CSurf_OnRecord`, and **returns immediately**. `tick()` (called from `OnTimer`)
reads the transport via `GetPlayStateEx`/`GetPlayPositionEx` scoped to the record's
own `ReaProject*` (project-switch safe), advances the pure `advanceRecordPhase`
state machine, and on a terminal verdict stops + finalizes/restores. `abort()` is
the force-terminate path for shutdown and project switch.
- [x] `RealtimeCaptureState` snapshot + idempotent restore: snapshots cursor,
time selection, and every other track's `I_RECARM`; restore() is latched
(`restored_` flag) and safe to call from whichever terminal path fires first.
Terminal paths: normal completion, manual stop, error, second-capture reject,
project switch (project-scoped `OnStopButtonEx(proj_)`, never the global
`CSurf_OnStop`), **project close** (guarded by `ValidatePtr2(nullptr, proj_,
"ReaProject*")` — a closed project calls `dropWithoutRestore()` rather than
touching freed pointers), and extension unload.
- [x] `Finalizing` flush-wait before file move: after the transport stops,
`tick()` waits for the recorded file size to be positive and stable across a tick
before calling `finalizeRecording` (file is no longer being written by REAPER's
audio thread). A wall-clock ceiling (steady-clock, independent of the play cursor)
bounds both the total record duration and the flush wait separately.
- [x] Move recorded source into bank: `recordedFilePath` discovers the take's source
file from the temp track's first media item; `finalizeRecording` moves it into the
bank folder (cross-volume fallback: copy+remove); populates a `Sample` via
`sampleFromRecordedCapture`; clean teardown via `restore()` deletes the temp track
(which REAPER uses to automatically remove every send routed into it).
- [x] Dialog-free; realtime is inherently non-deterministic (documented, not asserted
bit-identical); saved-project gate (refuses + prompts Save-As if unsaved, matching
offline). Bindable **cancel** action registered. Master scope removed entirely —
to capture the master, render a track.
**Notes/decisions:**
- **Track scope only this increment.** Item realtime is deferred: item scope needs
per-item take isolation on top of the track-output tap — a separate increment.
- **TAP vs. FxBypassGuard.** The `CreateTrackSend` defaults to post-fader
(`I_SENDMODE=0`) with full-stereo (`I_SRCCHAN` default): post-fader taps the source
track after its own FX and fader/pan, before the parent sums it. The parent chain
downstream of that branch is not in the tapped path at all — so there is nothing to
neutralize and `FxBypassGuard` (which mutates the live chain, altering the user's
monitoring) is deliberately not used. This also fixed the earlier silent-file bug
from the spike, which sent FROM the master INTO a temp track (a feedback loop REAPER
refuses, recording silence). A regular track→track send has no feedback.
- **Project-close guard.** `abort()` gates every REAPER call on
`ValidatePtr2(nullptr, proj_, "ReaProject*")`. A closed project already reclaimed
its temp track, arms, and transport — `dropWithoutRestore()` latches `restored_`
and clears `temp_` / `armSnaps_` without touching any REAPER pointer.
- **No undo block.** The transient mutations (temp track, sends, arm, transport) are
fully reversed by `restore()`; surfacing them as an undo point would pollute the
user's history with an internal scaffold they cannot meaningfully undo.
---
## T1 — offline tail: auto (default) + manual override
**Goal:** Preserve decay tails on offline captures. **Auto**: render an 8 s-capped
tail, then auto-trim trailing silence to -72 dB via a **surgical** `RENDER_NORMALIZE`
(only the trim-end bit, `32768`) + a derived `RENDER_TRIMEND` amplitude ratio.
**Manual**: a fixed tail length clamped to the 8 s cap, no trim. **None** (default):
exact bounds, byte-identical to the pre-tail capture. See
`docs/product/capture-tail.md` §The offline path.
**Verify (in DAW):** A range ending mid-reverb + Auto tail ends at the -72 dB decay
point (not a hard 8 s, not the range end); a non-decaying signal caps at range + 8 s;
**two identical Auto requests are byte-identical** (deterministic trim); a
TailMode::None capture is byte-identical to the pre-tail exact-bounds capture;
Manual(N ms) yields range + N ms untrimmed, with N clamped to 8000;
`ScopedRenderSettings` restores `RENDER_NORMALIZE` and every touched setting on every
path.
- [x] Pure layer (`render_settings.{h,cpp}`): named constants `kAutoTrimThresholdDb`
(-72) + derived `RENDER_TRIMEND` amplitude ratio via `autoTrimEndRatio()` (≈
0.00025119 for -72 dB, computed as `10^(dB/20)``std::pow` is not `constexpr`
before C++26 so this is a function, not a constant), `kMaxTailSeconds`/`kMaxTailMs`
(8 s); `TailMode { None, Auto, Manual }` enum; `TailRenderSettings` struct
(tailFlag/tailMs/normalize/trimEnd); `tailRenderSettingsFor(mode, manualTailMs)`
mapping (None = kTailFlagNone + kNormalizeDisableAll; Auto = kTailFlagCustomBounds
+ kMaxTailMs + kNormalizeTrimEnd (32768) + autoTrimEndRatio(); Manual =
kTailFlagCustomBounds + clamped ms + kNormalizeDisableAll); unit-tested.
- [x] Wire the mapping into `OfflineRenderBackend` (`capture.cpp`): drives tail +
surgical-normalize (Auto) / disable-all (Manual/None) via `GetSetProjectInfo`;
`ScopedRenderSettings` snapshots and restores `RENDER_TRIMEND` alongside the
existing `RENDER_*` set. `RENDER_TAILFLAG = kTailFlagCustomBounds` (1) for Auto
and Manual — custom bounds is the always-applicable tail bit for offline captures.
- [x] `CaptureRequest` three-state tail contract (None/Auto/Manual(ms)); default
None (exact bounds, null-test-safe). The earlier `renderTail` bool/`tailMs` pair
was superseded.
- [x] Exposure: a **docked-panel footer toggle** (label "Tail: Off" / "Tail: Auto" /
"Tail: Manual", cycles on click via `cycleTailMode`) in `bank_panel.cpp`, backed
by the pure `tail_control` module (`TailSetting`, `cycleTailMode`,
`clampManualMs`, `tailToggleLabel` — unit-tested). Default `TailMode::None`.
`CAPTURE_ITEM` and `CAPTURE_TRACK` read the panel setting at fire time — **no
per-action tail variants shipped** (the "…with tail" variants were dropped in
favour of the toggle; null-test/verify captures use None explicitly).
- [x] DAW-confirm: `RENDER_TRIMEND` amplitude curve (0.00025119 ≈ -72 dB); trim-end-only
normalize (32768) does not engage fades/normalize/pad; trim never eats pre-`ENDPOS`
body. (See spec §Open questions / DAW-confirm.)
**Notes/decisions:**
- **Surgical normalize.** `kNormalizeTrimEnd = 32768` sets only the trim-ending-silence
bit; every other postprocessing bit is clear. A fixed-threshold trailing-silence trim
scales and fades nothing, so two identical Auto requests trim at the identical sample
→ bit-identical repeats hold (spec §surgical normalize).
- **`kNormalizeDisableAll = (4 << 16) = 262144`.** Used for None and Manual — the
same disable-all value the pre-tail exact-bounds capture used.
- **`tail_control` pure module** (`src/tail_control.{h,cpp}`): REAPER-free logic for
the panel toggle. `kDefaultManualTailMs = 2000.0` (2 s). Fine-adjust UI (±
click zones / scroll) is a noted follow-on; this pass ships a fixed default.
- **Follow-ons noted, not done:** Manual fine-adjust UI; per-project persistence of
the toggle (currently extension-session lifetime, resets to None on unload); T2
realtime tail.
-53
View File
@@ -14,21 +14,6 @@ it here and appends it to `COMPLETED.md`.
--- ---
## Milestone 8 — RealtimeRecordBackend
**Goal:** Realtime record behind the same `ICaptureBackend`, producing identical
bank entries. CONTEXT.md §capture (realtime), §Precision invariants.
**Verify (in DAW):** Hidden temp track resamples wet output; recorded file moves
into the bank; **non-destructive** — temp track removed cleanly, source routing
and user monitoring restored unchanged.
**Note (from M3):** The realtime backend captures during playback and does NOT invoke the offline-render path, so it is inherently dialog-free (no render-progress window) — a secondary benefit beyond hardware/performed-FX capture.
- [ ] Hidden-track resample recipe (`I_RECMODE`/`I_RECINPUT`/`I_RECARM`,
`CSurf_OnRecord`/`CSurf_OnStop`); verify record-mode values against SDK.
- [ ] Resolve wet-master routing that does not alter user monitoring (open
question).
- [ ] Move recorded source into bank; populate identical `Sample`; clean teardown.
## Milestone 9 — slots (MPC-style) ## Milestone 9 — slots (MPC-style)
**Goal:** "Capture to slot N" / "insert slot N", MIDI-bindable. CONTEXT.md **Goal:** "Capture to slot N" / "insert slot N", MIDI-bindable. CONTEXT.md
Build order 9. Build order 9.
@@ -72,8 +57,6 @@ invariants; drag-out places a valid file in the OS target.
Carried from CONTEXT.md §Open questions — keep visible until each is closed by a Carried from CONTEXT.md §Open questions — keep visible until each is closed by a
landed milestone. landed milestone.
- **Realtime wet-master routing** that captures master output without altering the
user's monitoring. (blocks M8)
- **`parseInt` narrowing hardening:** `src/bank_model.cpp` `parseInt` casts - **`parseInt` narrowing hardening:** `src/bank_model.cpp` `parseInt` casts
`int64_t → int` via `static_cast` without a range check; integers that fit `int64_t → int` via `static_cast` without a range check; integers that fit
in int64 but exceed `INT_MAX` are implementation-defined. Hardening candidate in int64 but exceed `INT_MAX` are implementation-defined. Hardening candidate
@@ -94,34 +77,6 @@ landed milestone.
> threshold **-72 dB**, max-tail cap **8 s**. When a point lands, doc-keeper moves > threshold **-72 dB**, max-tail cap **8 s**. When a point lands, doc-keeper moves
> it to `COMPLETED.md`. > it to `COMPLETED.md`.
## T1 — offline tail: auto (default) + manual override
**Goal:** Preserve decay tails on offline captures. **Auto** (default): render an
8 s-capped tail, then auto-trim trailing silence to -72 dB via a **surgical**
`RENDER_NORMALIZE` (only the trim-end bit set) + `RENDER_TRIMEND`. **Manual**: a
fixed tail length (clamped to the 8 s cap), no trim, keeping today's disable-all
normalize. Tail is opt-in; **None** stays byte-identical to today. See
`docs/product/capture-tail.md` §The offline path.
**Verify (in DAW):** A range ending mid-reverb + Auto tail ends at the -72 dB decay
point (not a hard 8 s, not the range end); a non-decaying signal caps at range + 8 s;
**two identical Auto requests are byte-identical** (deterministic trim); a
TailMode::None capture is byte-identical to the pre-tail exact-bounds capture;
Manual(N ms) yields range + N ms untrimmed, with N clamped to 8000; `ScopedRenderSettings`
restores `RENDER_NORMALIZE` and every touched setting on every path.
- [ ] Pure layer (`render_settings.{h,cpp}`): named constants `kAutoTrimThresholdDb`
(-72) + derived `RENDER_TRIMEND` ratio (≈0.00025119), `kMaxTailSeconds`/`kMaxTailMs`
(8 s); a `TailMode { None, Auto, Manual }` → (`RENDER_TAILFLAG`/`RENDER_TAILMS`/
`RENDER_NORMALIZE`/`RENDER_TRIMEND`) mapping + the manual-tail clamp; unit-tested.
- [ ] Wire the mapping into `OfflineRenderBackend` (`capture.cpp`): drive the tail +
surgical-normalize (Auto) / disable-all (Manual/None) values; snapshot/restore
`RENDER_TRIMEND` alongside the existing `RENDER_*` set. `RENDER_TAILFLAG = 1`
unconditionally (custom bounds — not per range type).
- [ ] Replace/extend `CaptureRequest.renderTail`(bool)/`tailMs` with the three-state
tail contract (None/Auto/Manual(ms)); default None (exact bounds, null-test-safe).
- [ ] DAW-confirm: `RENDER_TRIMEND` amplitude curve (0.00025119 ≈ -72 dB); trim-end-only
normalize (32768) does not engage fades/normalize/pad; trim never eats pre-`ENDPOS`
body. (See spec §Open questions / DAW-confirm.)
## T2 — realtime tail (follow-on to T1) ## T2 — realtime tail (follow-on to T1)
**Goal:** The parallel tail path for the M8 realtime backend, which does not drive **Goal:** The parallel tail path for the M8 realtime backend, which does not drive
`RENDER_*`: record an 8 s-capped tail window past the range end, then **trim in a `RENDER_*`: record an 8 s-capped tail window past the range end, then **trim in a
@@ -141,14 +96,6 @@ then trims at the -72 dB decay point (± inherent realtime tolerance); realtime
- [ ] Realtime shell: read the recorded wav PCM into a float buffer, find the trim - [ ] Realtime shell: read the recorded wav PCM into a float buffer, find the trim
frame, rewrite the file truncated (new I/O the backend does not do today). frame, rewrite the file truncated (new I/O the backend does not do today).
## Milestone T open questions
- **Auto as the shipped-action default?** Whether `CAPTURE_ITEM`/`CAPTURE_TRACK`
(currently all TailMode::None) flip to Auto, gain a "…with tail" variant, or take
a modifier. Product call for Daniel; **leaning** paired variant / toggle over
silently changing the exact-bounds default. Not blocking T1 (the request-level
three-state contract is independent). (touches `render_settings.cpp` action table +
`actions`.)
--- ---
# Phase D2 — Two-canvas (item-level mode projection; additive to D1) # Phase D2 — Two-canvas (item-level mode projection; additive to D1)
+13 -9
View File
@@ -384,14 +384,18 @@ Neither is in scope now; both are single-constant seams so promotion is cheap.
trailing edge, and the tail is appended after `ENDPOS`. But a range that itself trailing edge, and the tail is appended after `ENDPOS`. But a range that itself
ends in near-silence before a loud transient is the edge case to check the trim ends in near-silence before a loud transient is the edge case to check the trim
doesn't over-eat.) doesn't over-eat.)
- **Auto as the action default?** Should the two shipped capture actions - **Auto as the action default? — DECIDED (2026-07-23).** The two shipped capture
(`CAPTURE_ITEM` / `CAPTURE_TRACK`, currently all TailMode::None actions (`CAPTURE_ITEM` / `CAPTURE_TRACK`) stay `TailMode::None` by default; the
per `render_settings.cpp §captureActionTable`) flip to Auto tail by default once tail mode is exposed as a **settings toggle in the docked bank panel** (None / Auto
this ships, or should tail be a separate action variant / a modifier? Product call / Manual — the footer strip, `bank_panel.cpp` + pure `tail_control`), and the plain
for Daniel. **Leaning:** a per-action-family toggle or a paired "…​with tail" capture actions READ that toggle when building the `CaptureRequest`. Chosen over the
variant rather than silently changing the existing actions' behavior — the current earlier "…with tail" paired-action lean: one toggle covers all three states without
exact-bounds default is a documented contract and some captures (chops, wavetable doubling the action count, and the exact-bounds contract still holds because the
grabs) want no tail. Not blocking the offline implementation; the request-level toggle defaults to None. The setting is an extension-session setting (default None;
contract (three tail states) is independent of which action sets which. persists across project loads and panel open/close within a REAPER session; resets to
None only on extension unload — i.e. fresh REAPER session; project persistence across
REAPER restarts is a follow-on). Manual ships a fixed 2 s default; a
fine-adjust affordance (+/- click zones or scroll) is a follow-on. The verify /
null-test capture still always runs `TailMode::None` regardless of the toggle.
- **Realtime tail sequencing.** Confirmed a **follow-on** to the offline tail — do - **Realtime tail sequencing.** Confirmed a **follow-on** to the offline tail — do
not block offline on it. Filed as a separate PLAN point. not block offline on it. Filed as a separate PLAN point.
+98 -5
View File
@@ -46,6 +46,7 @@
#include "mode_switch.h" #include "mode_switch.h"
#include "peaks.h" #include "peaks.h"
#include "persist.h" #include "persist.h"
#include "tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure)
#include "track_guid.h" // guidString — canonical track GUID key (D2 Wave 2) #include "track_guid.h" // guidString — canonical track GUID key (D2 Wave 2)
#include "view.h" // applyMode — the D2/D4 mode-activation entrypoint the switch fires #include "view.h" // applyMode — the D2/D4 mode-activation entrypoint the switch fires
#include "view_mode_model.h" // autoTagNewContent / NewItem (D2 Wave 2) #include "view_mode_model.h" // autoTagNewContent / NewItem (D2 Wave 2)
@@ -150,6 +151,20 @@ const LICE_pixel kColSegBorder = LICE_RGBA(70, 70, 76, 255); // segment di
const COLORREF kRgbSegText = RGB(170, 170, 176); // inactive label const COLORREF kRgbSegText = RGB(170, 170, 176); // inactive label
const COLORREF kRgbSegActiveText = RGB(220, 235, 228); // active label const COLORREF kRgbSegActiveText = RGB(220, 235, 228); // active label
// --- Tail-mode footer (T1 exposure) -------------------------------------------
// A fixed-height strip at the BOTTOM of the client area holding the tail-mode
// toggle ("Tail: Off / Auto / Manual"). Clicking anywhere in it cycles the mode
// (None -> Auto -> Manual -> None). Display/settings only: it mutates the panel's
// in-memory tail setting the plain capture actions read — NEVER the project/bank/
// arrange. The cycle/label logic is the pure tail_control module; only the draw +
// click routing is here. The grid viewport is shortened by this strip's height so
// cells never draw under it.
constexpr int kFooterHeight = 26; // px; fixed strip at the bottom
const LICE_pixel kColFooterBg = LICE_RGBA(20, 20, 22, 255); // footer strip fill
const LICE_pixel kColFooterBorder = LICE_RGBA(70, 70, 76, 255); // top divider
const COLORREF kRgbFooterText = RGB(190, 205, 198); // toggle label
// --- Panel state -------------------------------------------------------------- // --- Panel state --------------------------------------------------------------
// A computed thumbnail: the per-channel envelope at a known width. Held in the // A computed thumbnail: the per-channel envelope at a known width. Held in the
@@ -189,6 +204,12 @@ struct PanelState {
// clear the selection rather than risk indices pointing past the new count. // clear the selection rather than risk indices pointing past the new count.
int selItemCount = 0; int selItemCount = 0;
// --- Tail-mode toggle (T1 exposure) ---------------------------------------
// The current tail setting the plain capture actions read (bankPanelTailSetting).
// Default None (exact bounds). In-memory only — resets on panel teardown; project
// persistence is a noted follow-on. Mutated ONLY by a click in the footer strip.
TailSetting tail;
// --- Audition preview (Wave B) -------------------------------------------- // --- Audition preview (Wave B) --------------------------------------------
// //
// The stock preview register we hand to PlayPreview/StopPreview. Its cs/mutex // The stock preview register we hand to PlayPreview/StopPreview. Its cs/mutex
@@ -454,6 +475,45 @@ void drawModeSwitch(LICE_IBitmap* bmp, int w) {
} }
} }
// The tail-toggle footer rect for a client of width `w` and height `h`: the
// full-width strip of fixed height pinned to the BOTTOM. A RECT (not HeaderRect)
// since the whole strip is one hit target — a click anywhere in it cycles the mode.
// Shared by paint and click routing so both agree on the band. Degenerate (empty)
// when the client is too short to host it above the header.
RECT panelFooter(int w, int h) {
RECT rc{};
rc.left = 0;
rc.right = w;
rc.top = h - kFooterHeight;
rc.bottom = h;
// Clamp so the footer never rides up into (or above) the header band on a very
// short panel — it collapses to empty rather than overlapping the mode switch.
if (rc.top < kHeaderHeight) rc.top = rc.bottom; // empty: top == bottom
return rc;
}
// Draws the tail-mode toggle into the footer strip: a filled band, a top divider,
// and the current mode's label ("Tail: Off / Auto / Manual") from the pure
// tail_control module. READ-ONLY: reads g_panel.tail; the click handler mutates it.
void drawTailFooter(LICE_IBitmap* bmp, int w, int h) {
const RECT f = panelFooter(w, h);
if (f.top >= f.bottom) return; // no room — skip (short panel)
LICE_FillRect(bmp, f.left, f.top, w, kFooterHeight, kColFooterBg, 1.0f, 0);
// Top divider so the strip reads as distinct from the grid above it.
LICE_Line(bmp, f.left, f.top, f.right, f.top, kColFooterBorder, 1.0f, 0, false);
HDC dc = bmp->getDC();
if (!dc) return;
const std::string label = tailToggleLabel(g_panel.tail);
RECT rc = f;
rc.left += 8; // small left pad so the label is not flush against the edge
SetTextColor(dc, kRgbFooterText);
SetBkMode(dc, TRANSPARENT);
DrawText(dc, label.c_str(), -1, &rc,
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
}
// The cell rects for the panel's CURRENT client width and bank size, translated // The cell rects for the panel's CURRENT client width and bank size, translated
// DOWN by the header height so the grid sits below the mode switch. Both paint and // DOWN by the header height so the grid sits below the mode switch. Both paint and
// mouse hit-testing call this so they share identical geometry (no drift between // mouse hit-testing call this so they share identical geometry (no drift between
@@ -499,11 +559,15 @@ void paintPanel(HWND hwnd, HDC hdc) {
// Draw each cell's thumbnail. Inner drawable width == cell width - inset; // Draw each cell's thumbnail. Inner drawable width == cell width - inset;
// compute the envelope at the cell's inner column count so bins map 1:1. // compute the envelope at the cell's inner column count so bins map 1:1.
const int binWidth = kGrid.cellWidth - 4; const int binWidth = kGrid.cellWidth - 4;
// Cells must not draw under the footer strip: the visible grid stops at the
// footer top (or the client bottom when the panel is too short for a footer).
const RECT footer = panelFooter(w, h);
const int gridBottom = footer.top < footer.bottom ? footer.top : h;
for (std::size_t i = 0; i < rects.size(); ++i) { for (std::size_t i = 0; i < rects.size(); ++i) {
const CellRect& rect = rects[i]; const CellRect& rect = rects[i];
// Skip cells entirely below the viewport (Wave A has no scroll; this // Skip cells entirely below the visible grid area (Wave A has no scroll;
// just avoids computing thumbnails that cannot be seen). // this just avoids computing thumbnails that cannot be seen).
if (rect.y >= h) continue; if (rect.y >= gridBottom) continue;
const int idx = static_cast<int>(i); const int idx = static_cast<int>(i);
const bool selected = g_panel.selection.contains(idx); const bool selected = g_panel.selection.contains(idx);
const bool focused = g_panel.selection.focus == idx; const bool focused = g_panel.selection.focus == idx;
@@ -512,9 +576,10 @@ void paintPanel(HWND hwnd, HDC hdc) {
} }
} }
// The mode switch draws LAST so its header band overlays the top of the grid / // The mode switch and tail footer draw LAST so their bands overlay the top/bottom
// empty-state area regardless of which branch ran above. // of the grid / empty-state area regardless of which branch ran above.
drawModeSwitch(&bmp, w); drawModeSwitch(&bmp, w);
drawTailFooter(&bmp, w, h);
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
} }
@@ -836,6 +901,23 @@ void handleClick(int x, int y) {
} }
} }
// Tail-mode footer: a click anywhere in the bottom strip cycles the tail mode
// (None -> Auto -> Manual -> None) and repaints. Settings-only — it mutates the
// panel's in-memory tail setting the capture actions read, and NOTHING in the
// project/bank/arrange. Checked before the grid so a footer click never selects.
{
RECT cr{};
GetClientRect(g_panel.hwnd, &cr);
const int w = cr.right - cr.left;
const int h = cr.bottom - cr.top;
const RECT f = panelFooter(w, h);
if (f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom) {
g_panel.tail.mode = cycleTailMode(g_panel.tail.mode);
invalidatePanel();
return; // footer click consumed; do NOT fall through to grid selection
}
}
const std::vector<CellRect> rects = panelRects(); const std::vector<CellRect> rects = panelRects();
const int hit = hitTestCell(x, y, rects); const int hit = hitTestCell(x, y, rects);
const int count = bankItemCount(); const int count = bankItemCount();
@@ -1073,6 +1155,17 @@ void bankPanelRefresh() {
InvalidateRect(g_panel.hwnd, nullptr, FALSE); InvalidateRect(g_panel.hwnd, nullptr, FALSE);
} }
TailSetting bankPanelTailSetting() {
// In-memory for the extension's lifetime (g_panel is static): the toggle's mode
// survives panel open/close and bank changes, and resets to the default None only
// on extension unload. Persistence across project reload is a noted follow-on.
// manualMs is clamped here so a caller always receives a within-cap length, even
// if a future fine-adjust UI stored an over-cap value.
TailSetting s = g_panel.tail;
s.manualMs = clampManualMs(s.manualMs);
return s;
}
void bankPanelShutdown() { void bankPanelShutdown() {
closePanel(); // stops audition + destroys the window closePanel(); // stops audition + destroys the window
deinitPreview(); // destroy the preview lock (after the last stop) deinitPreview(); // destroy the preview lock (after the last stop)
+13
View File
@@ -13,6 +13,8 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "tail_control.h" // TailSetting — the panel's tail-mode toggle state
namespace reasampler { namespace reasampler {
class ReaSamplerSession; class ReaSamplerSession;
@@ -48,6 +50,17 @@ std::vector<std::string> bankPanelSelectedSampleIds();
// reflected without the panel diffing the bank itself. // reflected without the panel diffing the bank itself.
void bankPanelRefresh(); void bankPanelRefresh();
// The panel's current tail-mode setting (mode + Manual length), read by the plain
// CAPTURE_ITEM / CAPTURE_TRACK actions when building a CaptureRequest so a capture
// applies whatever the panel toggle is set to. Default None (exact bounds) — a
// capture with no explicit choice stays byte-identical to today. Extension-session
// setting: persists across project loads and panel open/close within a REAPER session;
// resets to None only when the extension unloads (fresh REAPER session). Project
// persistence across REAPER restarts is a noted follow-on.
// Safe to call before the panel has ever opened (returns the default). READ of panel
// state only; the toggle is mutated by a click inside the panel, never here.
TailSetting bankPanelTailSetting();
// Tears the panel down on extension unload: destroys the window and releases any // Tears the panel down on extension unload: destroys the window and releases any
// cached thumbnails / PCM handles. Mirror of bankPanelInit; safe if never opened. // cached thumbnails / PCM handles. Mirror of bankPanelInit; safe if never opened.
void bankPanelShutdown(); void bankPanelShutdown();
+31 -23
View File
@@ -74,10 +74,9 @@ constexpr int kActionRenderUsingMostRecentSettings = 42230;
// ourselves for exact, unrounded bounds). Verified: SDK header line ~3042. // ourselves for exact, unrounded bounds). Verified: SDK header line ~3042.
constexpr double kBoundsCustom = 0.0; constexpr double kBoundsCustom = 0.0;
// RENDER_TAILFLAG bit &1 = apply tail for custom time bounds. We clear it for // RENDER_TAILFLAG / RENDER_TAILMS / RENDER_NORMALIZE / RENDER_TRIMEND for the tail
// the spike (exact bounds, no added silence — precision invariant). // are driven from the pure tailRenderSettingsFor mapping (render_settings.h),
constexpr double kTailFlagNone = 0.0; // unit-tested outside the DAW. See the tail-driving block in capture() below.
constexpr double kTailFlagCustomBounds = 1.0; // &1, used only if renderTail set
// RENDER_DITHER disable-all: &16 = disable all dither/noise-shaping. // RENDER_DITHER disable-all: &16 = disable all dither/noise-shaping.
// Verified: SDK header line ~3050: "&16=disable all". // Verified: SDK header line ~3050: "&16=disable all".
@@ -85,12 +84,6 @@ constexpr double kTailFlagCustomBounds = 1.0; // &1, used only if renderTail se
// enabled the render would obey it, breaking bit-identical repeats. Force off. // enabled the render would obey it, breaking bit-identical repeats. Force off.
constexpr double kDitherDisableAll = 16.0; constexpr double kDitherDisableAll = 16.0;
// RENDER_NORMALIZE disable-all: &(4<<16) = disable all render postprocessing.
// Verified: SDK header line ~3051: "(&(4<<16))==disable all render postprocessing".
// This masks out normalization, brickwall, fades, pad/trim — every post-process
// that is nondeterministic relative to the source signal.
constexpr double kNormalizeDisableAll = static_cast<double>(4 << 16); // 262144
// --- WAV render sink configuration ------------------------------------------ // --- WAV render sink configuration ------------------------------------------
// //
// FORMAT CHOICE (CONTEXT.md open question — surfaced for Daniel to confirm): // FORMAT CHOICE (CONTEXT.md open question — surfaced for Daniel to confirm):
@@ -147,6 +140,7 @@ struct RenderSettingsSnapshot {
double addToProj = 0.0; double addToProj = 0.0;
double dither = 0.0; // RENDER_DITHER — snapshotted so user's setting is restored double dither = 0.0; // RENDER_DITHER — snapshotted so user's setting is restored
double normalize = 0.0; // RENDER_NORMALIZE — snapshotted so user's setting is restored double normalize = 0.0; // RENDER_NORMALIZE — snapshotted so user's setting is restored
double trimEnd = 0.0; // RENDER_TRIMEND — snapshotted so the Auto trim threshold is restored
// String settings (GetSetProjectInfo_String). Big buffers: REAPER writes the // String settings (GetSetProjectInfo_String). Big buffers: REAPER writes the
// full value in, and RENDER_FORMAT is a base64 blob that can be long. // full value in, and RENDER_FORMAT is a base64 blob that can be long.
@@ -183,6 +177,7 @@ void snapshotRenderSettings(RenderSettingsSnapshot& s, ReaProject* proj) {
s.addToProj = GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, false); s.addToProj = GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, false);
s.dither = GetSetProjectInfo(proj, "RENDER_DITHER", 0.0, false); s.dither = GetSetProjectInfo(proj, "RENDER_DITHER", 0.0, false);
s.normalize = GetSetProjectInfo(proj, "RENDER_NORMALIZE", 0.0, false); s.normalize = GetSetProjectInfo(proj, "RENDER_NORMALIZE", 0.0, false);
s.trimEnd = GetSetProjectInfo(proj, "RENDER_TRIMEND", 0.0, false);
s.renderFile = getProjString(proj, "RENDER_FILE"); s.renderFile = getProjString(proj, "RENDER_FILE");
s.renderPattern = getProjString(proj, "RENDER_PATTERN"); s.renderPattern = getProjString(proj, "RENDER_PATTERN");
s.renderFormat = getProjString(proj, "RENDER_FORMAT"); s.renderFormat = getProjString(proj, "RENDER_FORMAT");
@@ -207,6 +202,7 @@ void restoreRenderSettings(const RenderSettingsSnapshot& s) {
GetSetProjectInfo(s.proj, "RENDER_ADDTOPROJ", s.addToProj, true); GetSetProjectInfo(s.proj, "RENDER_ADDTOPROJ", s.addToProj, true);
GetSetProjectInfo(s.proj, "RENDER_DITHER", s.dither, true); GetSetProjectInfo(s.proj, "RENDER_DITHER", s.dither, true);
GetSetProjectInfo(s.proj, "RENDER_NORMALIZE", s.normalize, true); GetSetProjectInfo(s.proj, "RENDER_NORMALIZE", s.normalize, true);
GetSetProjectInfo(s.proj, "RENDER_TRIMEND", s.trimEnd, true);
} }
// RAII wrapper: guarantees restore on every return path from capture(). // RAII wrapper: guarantees restore on every return path from capture().
@@ -353,13 +349,18 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true); GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true);
GetSetProjectInfo(proj, "RENDER_ENDPOS", request.endSeconds, true); GetSetProjectInfo(proj, "RENDER_ENDPOS", request.endSeconds, true);
if (request.renderTail) { // Tail: TAILFLAG / TAILMS / NORMALIZE / TRIMEND all come from the pure mapping
GetSetProjectInfo(proj, "RENDER_TAILFLAG", kTailFlagCustomBounds, true); // (render_settings.h, unit-tested). None -> exact bounds + disable-all normalize
GetSetProjectInfo(proj, "RENDER_TAILMS", request.tailMs, true); // (byte-identical to the pre-tail path); Auto -> 8 s tail + surgical trim-end
} else { // normalize + -72 dB TRIMEND; Manual -> clamped fixed tail + disable-all, no trim.
GetSetProjectInfo(proj, "RENDER_TAILFLAG", kTailFlagNone, true); // RENDER_NORMALIZE is driven HERE from the mapping (not the determinism block
GetSetProjectInfo(proj, "RENDER_TAILMS", 0.0, true); // below) so the Auto surgical value is not clobbered — the snapshot guard restores
} // the user's original RENDER_NORMALIZE / RENDER_TRIMEND on every exit path.
const TailRenderSettings tail =
tailRenderSettingsFor(request.tailMode, request.tailMs);
GetSetProjectInfo(proj, "RENDER_TAILFLAG",
static_cast<double>(tail.tailFlag), true);
GetSetProjectInfo(proj, "RENDER_TAILMS", tail.tailMs, true);
// Source-selection bits for this mode, from the pure render_settings mapping // Source-selection bits for this mode, from the pure render_settings mapping
// (verified against SDK header ~3041). All M7 actions are wet-only: // (verified against SDK header ~3041). All M7 actions are wet-only:
@@ -394,13 +395,20 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
// item. Clearing RENDER_ADDTOPROJ&1 keeps capture out of the arrange. // item. Clearing RENDER_ADDTOPROJ&1 keeps capture out of the arrange.
GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, true); GetSetProjectInfo(proj, "RENDER_ADDTOPROJ", 0.0, true);
// Determinism: disable dither and all render post-processing so identical // Determinism: disable dither so identical inputs produce bit-identical files
// inputs produce bit-identical files and a dry capture nulls to silence. // and a dry capture nulls to silence. RENDER_DITHER &16 = disable all dither/
// RENDER_DITHER &16 = disable all dither/noise-shaping (SDK header line ~3050). // noise-shaping (SDK header line ~3050). Snapshotted above; restored by the guard.
// RENDER_NORMALIZE &(4<<16) = disable all render postprocessing (line ~3051).
// Both are snapshotted above and restored by the RAII guard on every path.
GetSetProjectInfo(proj, "RENDER_DITHER", kDitherDisableAll, true); GetSetProjectInfo(proj, "RENDER_DITHER", kDitherDisableAll, true);
GetSetProjectInfo(proj, "RENDER_NORMALIZE", kNormalizeDisableAll, true);
// RENDER_NORMALIZE + RENDER_TRIMEND come from the tail mapping (above). None /
// Manual -> disable-all (byte-identical to the pre-tail path); Auto -> surgical
// trim-end (only &32768) + the -72 dB TRIMEND. A fixed-threshold trailing-silence
// trim scales/limits/fades nothing, so Auto stays deterministic and un-coloring
// (spec §surgical normalize). TRIMEND is only consulted when the trim bit is set,
// but we write it unconditionally (harmless when clear) so the value is explicit.
GetSetProjectInfo(proj, "RENDER_NORMALIZE",
static_cast<double>(tail.normalize), true);
GetSetProjectInfo(proj, "RENDER_TRIMEND", tail.trimEnd, true);
// Output location: directory (RENDER_FILE) + file stem (RENDER_PATTERN). // Output location: directory (RENDER_FILE) + file stem (RENDER_PATTERN).
// RENDER_PATTERN with no wildcards is a literal stem; REAPER appends the // RENDER_PATTERN with no wildcards is a literal stem; REAPER appends the
+8 -4
View File
@@ -20,6 +20,7 @@
#include <vector> #include <vector>
#include "bank_model.h" #include "bank_model.h"
#include "render_settings.h" // TailMode (pure) — the three-state tail contract
// MediaTrack is forward-declared (like track_guid.h) so this header stays // MediaTrack is forward-declared (like track_guid.h) so this header stays
// REAPER-free while RealtimeRecordBackend::begin can take the resolved source // REAPER-free while RealtimeRecordBackend::begin can take the resolved source
@@ -64,10 +65,13 @@ struct CaptureRequest {
// it stays source-agnostic, driven entirely by the request). // it stays source-agnostic, driven entirely by the request).
std::vector<std::string> trackGuids; std::vector<std::string> trackGuids;
// Render tail. Default OFF for the spike (exact bounds, no added silence — // Render tail (docs/product/capture-tail.md §The three tail states). Default
// precision invariant). M7 makes this bindable. // None: exact bounds, no added silence — the precision invariant, and the only
bool renderTail = false; // mode valid for null-test / verify captures. `tailMs` is meaningful ONLY for
double tailMs = 0.0; // TailMode::Manual (clamped to the 8 s cap by the pure mapping); Auto uses the
// 8 s cap + -72 dB trim internally, None ignores it.
TailMode tailMode = TailMode::None;
double tailMs = 0.0;
// Output format. 0 sampleRate => follow project rate (deterministic: the // Output format. 0 sampleRate => follow project rate (deterministic: the
// project rate is fixed for a given project). // project rate is fixed for a given project).
+14 -3
View File
@@ -69,12 +69,17 @@ static std::vector<gaccel_register_t> g_captureAccels;
// * CAPTURE_MASTER and CAPTURE_MASTER_REALTIME — the master offline scope and the // * CAPTURE_MASTER and CAPTURE_MASTER_REALTIME — the master offline scope and the
// master realtime action are REMOVED (capture is now item + track only; realtime // master realtime action are REMOVED (capture is now item + track only; realtime
// taps the selected track). Their shipped ids are retired so old keybindings clear. // taps the selected track). Their shipped ids are retired so old keybindings clear.
// * CAPTURE_ITEM_TAIL and CAPTURE_TRACK_TAIL — the former per-action tail variants
// are REMOVED; tail is now a panel-setting toggle, not a paired action. Retired so
// old keybindings clear.
static const char* const kRetiredCaptureCmdStrings[] = { static const char* const kRetiredCaptureCmdStrings[] = {
"CEREBELLUM_REASAMPLER_CAPTURE_TRACKS_WET", "CEREBELLUM_REASAMPLER_CAPTURE_TRACKS_WET",
"CEREBELLUM_REASAMPLER_CAPTURE_ITEMS_WET", "CEREBELLUM_REASAMPLER_CAPTURE_ITEMS_WET",
"CEREBELLUM_REASAMPLER_CAPTURE_RAZOR_WET", "CEREBELLUM_REASAMPLER_CAPTURE_RAZOR_WET",
"CEREBELLUM_REASAMPLER_CAPTURE_MASTER", "CEREBELLUM_REASAMPLER_CAPTURE_MASTER",
"CEREBELLUM_REASAMPLER_CAPTURE_MASTER_REALTIME", "CEREBELLUM_REASAMPLER_CAPTURE_MASTER_REALTIME",
"CEREBELLUM_REASAMPLER_CAPTURE_ITEM_TAIL",
"CEREBELLUM_REASAMPLER_CAPTURE_TRACK_TAIL",
}; };
// Command id for "ReaSampler: toggle bank panel" (M5). FOREVER-STABLE string. // Command id for "ReaSampler: toggle bank panel" (M5). FOREVER-STABLE string.
@@ -505,13 +510,19 @@ static void RunCapture(const reasampler::CaptureActionDef& def)
return; return;
} }
// The tail mode is a PANEL SETTING (docked bank panel's toggle), not a per-action
// variant: the plain capture actions apply whatever the panel is set to. Default
// is None (exact bounds / byte-identical to today) until the user opts in via the
// toggle. tailMs is meaningful only for Manual and is pre-clamped by the panel.
const reasampler::TailSetting tail = reasampler::bankPanelTailSetting();
reasampler::CaptureRequest req; reasampler::CaptureRequest req;
req.sourceMode = reasampler::sourceModeForScope(def.scope); req.sourceMode = reasampler::sourceModeForScope(def.scope);
req.startSeconds = src.startSeconds; // exact bounds — no rounding req.startSeconds = src.startSeconds; // exact bounds — no rounding
req.endSeconds = src.endSeconds; req.endSeconds = src.endSeconds;
req.wetDry = 1.0; // wet post the FX left enabled by the scope req.wetDry = 1.0; // wet post the FX left enabled by the scope
req.renderTail = false; // exact bounds, no tail (default) req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle
req.tailMs = 0.0; req.tailMs = tail.manualMs; // Manual-only (clamped); ignored for None/Auto
req.sampleRate = 0; // follow project rate req.sampleRate = 0; // follow project rate
req.channelCount = 2; req.channelCount = 2;
req.bitDepth = reasampler::WavBitDepth::Float32; // deterministic, no dither req.bitDepth = reasampler::WavBitDepth::Float32; // deterministic, no dither
@@ -590,7 +601,7 @@ static void RunCaptureRealtimeTrack()
req.startSeconds = src.startSeconds; // exact bounds — no rounding req.startSeconds = src.startSeconds; // exact bounds — no rounding
req.endSeconds = src.endSeconds; req.endSeconds = src.endSeconds;
req.wetDry = 1.0; // fully wet (post-fader tap) req.wetDry = 1.0; // fully wet (post-fader tap)
req.renderTail = false; req.tailMode = reasampler::TailMode::None; // realtime tail is T2; exact bounds here
req.tailMs = 0.0; req.tailMs = 0.0;
req.sampleRate = 0; // follow project rate req.sampleRate = 0; // follow project rate
req.channelCount = 2; req.channelCount = 2;
+56 -5
View File
@@ -3,10 +3,59 @@
#include "render_settings.h" #include "render_settings.h"
#include <algorithm>
#include <cmath>
#include <sstream> #include <sstream>
namespace reasampler { namespace reasampler {
double autoTrimEndRatio() {
// Amplitude ratio = 10^(dB/20). Derived from kAutoTrimThresholdDb so the dB is
// the single source of truth (header ~3062: RENDER_TRIMEND is an amplitude ratio,
// "0.5 means -6.02 dB"). For -72 dB this is ~= 0.00025119.
return std::pow(10.0, kAutoTrimThresholdDb / 20.0);
}
TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
TailRenderSettings t;
switch (mode) {
case TailMode::None:
// Exact bounds — byte-identical to the pre-tail no-tail capture. Tail off,
// disable-all normalize (the current default), no trim.
t.tailFlag = kTailFlagNone;
t.tailMs = 0.0;
t.normalize = kNormalizeDisableAll;
t.trimEnd = 0.0;
return t;
case TailMode::Auto:
// Generous 8 s tail, then SURGICAL normalize: ONLY the trim-ending-silence
// bit (32768) — every other postprocessing bit clear. A fixed-threshold
// trailing-silence trim is a pure boundary decision (it scales/limits/fades
// nothing), so it re-introduces none of the coloring the disable-all bit
// guarded against, and two identical requests trim at the identical sample
// -> bit-identical repeats hold (spec §surgical normalize).
t.tailFlag = kTailFlagCustomBounds;
t.tailMs = kMaxTailMs;
t.normalize = kNormalizeTrimEnd;
t.trimEnd = autoTrimEndRatio();
return t;
case TailMode::Manual:
// Fixed tail, no trim -> keep the disable-all normalize exactly as the
// no-tail path does. Clamp to the 8 s cap even here: the runaway guard
// applies whether the length came from the Auto default or an explicit
// request (spec §Manual override). Negative requests floor to 0.
t.tailFlag = kTailFlagCustomBounds;
t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs);
t.normalize = kNormalizeDisableAll;
t.trimEnd = 0.0;
return t;
}
// Unreachable for a valid enum; fail closed to exact bounds (never a stray tail).
return t;
}
RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) { RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
// `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future // `wetDry` is accepted so CaptureRequest.wetDry remains the seam for future
// dry work (M10 null test), but it does not affect this mapping. FX scoping is // dry work (M10 null test), but it does not affect this mapping. FX scoping is
@@ -130,20 +179,22 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges) {
} }
const std::vector<CaptureActionDef>& captureActionTable() { const std::vector<CaptureActionDef>& captureActionTable() {
// Built once (function-local static): two SCOPE actions. Tail OFF for all // Built once (function-local static): two SCOPE actions, item + track. Both
// (exact bounds). Ids are FOREVER-STABLE — never edit a shipped string. Each // exact bounds by default; the tail mode a capture applies is read from the
// action infers its range (razor-else-time) at fire time and enforces its // docked-panel setting at fire time (tail_control + bank_panel), so tail is NOT
// a per-action variant. Ids are FOREVER-STABLE — never edit a shipped string.
// Each action infers its range (razor-else-time) at fire time and enforces its
// FX-scope invariant via fxBypassPlanFor. The M7 CAPTURE_TRACKS_WET / // FX-scope invariant via fxBypassPlanFor. The M7 CAPTURE_TRACKS_WET /
// CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids are RETIRED (mirror-unregistered in // CAPTURE_ITEMS_WET / CAPTURE_RAZOR_WET ids are RETIRED (mirror-unregistered in
// main.cpp); the CAPTURE_MASTER scope action is REMOVED (its id is likewise // main.cpp); the CAPTURE_MASTER scope action is REMOVED (its id is likewise
// mirror-unregistered) — to capture the master you render a track. // mirror-unregistered) — to capture the master you render a track.
static const std::vector<CaptureActionDef> table = { static const std::vector<CaptureActionDef> table = {
// Item scope — item/take FX only. NEW forever-stable id. // Item scope — item/take FX only.
{"CEREBELLUM_REASAMPLER_CAPTURE_ITEM", {"CEREBELLUM_REASAMPLER_CAPTURE_ITEM",
"ReaSampler: capture selected item(s)", "item", "ReaSampler: capture selected item(s)", "item",
CaptureScope::Item}, CaptureScope::Item},
// Track scope — item FX + the track's own FX. NEW forever-stable id. // Track scope — item FX + the track's own FX.
{"CEREBELLUM_REASAMPLER_CAPTURE_TRACK", {"CEREBELLUM_REASAMPLER_CAPTURE_TRACK",
"ReaSampler: capture selected track(s)", "track", "ReaSampler: capture selected track(s)", "track",
CaptureScope::Track}, CaptureScope::Track},
+78 -7
View File
@@ -45,6 +45,75 @@ inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor e
// (post the FX that remain enabled); the scope decides which FX remain enabled. // (post the FX that remain enabled); the scope decides which FX remain enabled.
inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file
// --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ----------
//
// The capture-tail feature (docs/product/capture-tail.md) preserves reverb/release
// decay past the range end. Every offline capture renders custom-time-bounds, so
// the only tail-flag bit that ever applies is &1 (RENDER_TAILFLAG, header ~3047).
// These values are the pure part — mode -> (RENDER_* values) — unit-tested outside
// the DAW exactly like renderSettingsFor; the backend just applies them.
//
// RENDER_NORMALIZE bit meanings (verbatim from SDK header ~3051):
// &32768 = trim ending silence (the surgical Auto path)
// &(4<<16) = disable all render postprocessing (the None/Manual path)
inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence
inline constexpr int kNormalizeDisableAll = (4 << 16); // &(4<<16) = 262144, disable all
// RENDER_TAILFLAG &1 = apply tail for custom time bounds (header ~3047). We render
// custom bounds unconditionally, so this is the only tail bit that ever applies.
inline constexpr int kTailFlagNone = 0;
inline constexpr int kTailFlagCustomBounds = 1; // &1
// Auto-trim trailing-silence threshold. -72 dB is quiet enough that the trimmed
// region is inaudible decay, loud enough to not chase a reverb's infinite noise
// floor. Daniel-set. Single source of truth: the RENDER_TRIMEND ratio derives from
// this dB, never the reverse.
inline constexpr double kAutoTrimThresholdDb = -72.0;
// Max tail rendered past the range end. The runaway guard: a non-decaying or
// looping signal never crosses the trim threshold, so this caps the render.
// Daniel-set. Shared by the offline (T1) and future realtime (T2) tail paths.
inline constexpr double kMaxTailSeconds = 8.0;
inline constexpr double kMaxTailMs = 8000.0;
// Derived linear amplitude ratio for RENDER_TRIMEND. The header (~3062) documents
// RENDER_TRIMEND as an amplitude ratio ("0.5 means -6.02 dB"), i.e. 10^(dB/20).
// Derived from kAutoTrimThresholdDb so the dB stays the single source of truth and
// a future config change to the dB does not require hand-recomputing the ratio.
//
// std::pow is not constexpr before C++26, so this is a function, not a constant.
// For -72 dB: 10^(-72/20) = 10^(-3.6) ~= 0.00025119 (the value the DAW confirm targets).
double autoTrimEndRatio();
// The three tail states (docs/product/capture-tail.md §The three tail states):
// None — exact bounds, no tail. Byte-identical to the pre-tail capture. The
// default and the ONLY mode for null-test / verify captures.
// Auto — generous 8 s tail then trim trailing silence to -72 dB (surgical
// normalize). The user-facing tail-on option (panel toggle).
// Manual — a fixed tail length (clamped to the 8 s cap), no trim.
enum class TailMode {
None,
Auto,
Manual,
};
// The RENDER_* values a tail mode drives, in addition to the exact STARTPOS/ENDPOS
// the backend already sets. `trimEnd` is meaningful only when the trim-end normalize
// bit is set (Auto); it is 0 otherwise. This is the pure mapping — the backend reads
// these four fields straight onto GetSetProjectInfo.
struct TailRenderSettings {
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1)
double tailMs = 0.0; // RENDER_TAILMS
int normalize = kNormalizeDisableAll; // RENDER_NORMALIZE
double trimEnd = 0.0; // RENDER_TRIMEND (only used when trim bit set)
};
// Maps a tail mode (+ the requested manual tail ms) to its RENDER_* values.
// `manualTailMs` is used ONLY for TailMode::Manual (ignored otherwise). Manual is
// clamped to kMaxTailMs — the runaway guard applies whether the length came from
// the Auto default or an explicit request (spec §Manual override). Pure + tested.
TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs);
// The RENDER_SETTINGS value for a given source mode. `supported` is false only // The RENDER_SETTINGS value for a given source mode. `supported` is false only
// for SourceMode::Realtime (that is the M8 backend, not offline render). // for SourceMode::Realtime (that is the M8 backend, not offline render).
struct RenderSettingsChoice { struct RenderSettingsChoice {
@@ -144,10 +213,11 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges);
// --- Capture-action taxonomy (the bindable set main.cpp registers) ----------- // --- Capture-action taxonomy (the bindable set main.cpp registers) -----------
// //
// One row per bindable SCOPE action. Two scopes (item / track); the // One row per bindable SCOPE action: item and track. The range each captures
// range each captures (razor-else-time) is inferred at fire time, not a mode. // (razor-else-time) is inferred at fire time, not a mode. TAIL is NOT a per-action
// Tail is OFF for every row (exact bounds); a tail-on variant is a later opt-in, // variant — the tail MODE (None/Auto/Manual) is a panel SETTING the capture reads
// YAGNI now. Bounded, discoverable, NO dialogs (the tool's no-clutter ethos). // at fire time (see tail_control + bank_panel), so a single pair of actions covers
// every tail state. Bounded, discoverable, NO dialogs (the tool's no-clutter ethos).
// //
// commandString is FOREVER-STABLE (user keybindings key off it) — never change a // commandString is FOREVER-STABLE (user keybindings key off it) — never change a
// shipped value. baseName feeds the file stem (sanitized by capture_paths). // shipped value. baseName feeds the file stem (sanitized by capture_paths).
@@ -162,9 +232,10 @@ struct CaptureActionDef {
// each fired command back to its definition. Kept here (pure) so the taxonomy is // each fired command back to its definition. Kept here (pure) so the taxonomy is
// one testable list, not scattered registration code. // one testable list, not scattered registration code.
// //
// Two scope rows: CAPTURE_ITEM, CAPTURE_TRACK. There is no master capture — to // Two rows: CAPTURE_ITEM / CAPTURE_TRACK. There is no master capture — to capture
// capture the master you render a track. Razor is an inferred range, not a mode, // the master you render a track. Razor is an inferred range, not a mode, and each
// and each scope enforces its FX-scope invariant via fxBypassPlanFor. // scope enforces its FX-scope invariant via fxBypassPlanFor. The tail mode each
// capture applies is read from the docked-panel setting, not baked into the row.
const std::vector<CaptureActionDef>& captureActionTable(); const std::vector<CaptureActionDef>& captureActionTable();
} // namespace reasampler } // namespace reasampler
+33
View File
@@ -0,0 +1,33 @@
// tail_control — pure implementation. See tail_control.h. NO REAPER / SWELL / vendor.
#include "tail_control.h"
#include <algorithm>
namespace reasampler {
TailMode cycleTailMode(TailMode current) {
switch (current) {
case TailMode::None: return TailMode::Auto;
case TailMode::Auto: return TailMode::Manual;
case TailMode::Manual: return TailMode::None;
}
return TailMode::None; // unreachable for a valid enum; fail to the safe default
}
double clampManualMs(double manualMs) {
// Same runaway guard the pure tailRenderSettingsFor applies to Manual: floor a
// negative request to 0, cap at the 8 s ceiling.
return std::clamp(manualMs, 0.0, kMaxTailMs);
}
std::string tailToggleLabel(const TailSetting& setting) {
switch (setting.mode) {
case TailMode::None: return "Tail: Off";
case TailMode::Auto: return "Tail: Auto";
case TailMode::Manual: return "Tail: Manual";
}
return "Tail: Off"; // unreachable for a valid enum; fail to the safe default
}
} // namespace reasampler
+49
View File
@@ -0,0 +1,49 @@
#pragma once
// tail_control — the REAPER-free logic behind the docked bank_panel's tail-mode
// toggle. The panel shell (bank_panel.cpp) owns the SWELL window, LICE drawing, and
// click hit-testing; what is NOT DAW-bound — the cycle order, the manual-length
// clamp, and the toggle's label text — lives here so it is unit-tested outside the
// DAW (CLAUDE.md §load-bearing split). Mirror of bank_grid / mode_switch.
//
// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library
// only (plus render_settings for the pure TailMode enum). Builds and unit-tests
// without REAPER.
#include <string>
#include "render_settings.h" // TailMode (pure enum) — the three-state tail contract
namespace reasampler {
// The Manual-mode starting length. 2 s is a musically useful default tail (a bar of
// reverb throw at a moderate tempo) that is well under the 8 s cap. A fine-adjust UI
// (+/- click zones or scroll) is a noted follow-on; this pass ships a fixed default.
inline constexpr double kDefaultManualTailMs = 2000.0;
// The panel's current tail setting: the mode plus the length used ONLY when the
// mode is Manual. Held as in-memory panel/session state (bank_panel.cpp), default
// None so a capture with no explicit choice stays exact-bounds / byte-identical to
// today. `manualMs` is a stored default a future fine-adjust UI can tune; it is
// clamped to the 8 s cap (kMaxTailMs) before it ever reaches a CaptureRequest.
struct TailSetting {
TailMode mode = TailMode::None;
double manualMs = kDefaultManualTailMs;
};
// Cycles the tail mode: None -> Auto -> Manual -> None. Pure so the wrap order is
// pinned by a test and the panel's click handler owns no enum arithmetic of its own.
// An out-of-range value (unreachable for a valid enum) cycles back to None.
TailMode cycleTailMode(TailMode current);
// The effective manual length a Manual capture uses: `manualMs` clamped to
// [0, kMaxTailMs] (the runaway guard the pure tailRenderSettingsFor also applies).
// Exposed so the panel can show the clamped value and main.cpp hands a pre-clamped
// tailMs into the CaptureRequest. Meaningful only for TailMode::Manual.
double clampManualMs(double manualMs);
// The toggle's label for a setting, e.g. "Tail: Off", "Tail: Auto", "Tail: Manual".
// (Manual omits the length here — the panel is unobtrusive; a length readout can be
// added with the fine-adjust follow-on.) Pure so the exact strings are test-pinned.
std::string tailToggleLabel(const TailSetting& setting);
} // namespace reasampler
+101 -14
View File
@@ -1,12 +1,14 @@
// Standalone tests for reasampler::render_settings — no REAPER, no framework. // Standalone tests for reasampler::render_settings — no REAPER, no framework.
// Covers the pure pieces behind the two-scope capture family: the source-mode -> // Covers the pure pieces behind the capture family: the source-mode ->
// RENDER_SETTINGS bit mapping, P_RAZOREDITS parsing -> ranges + union, scope -> // RENDER_SETTINGS bit mapping, the TailMode -> RENDER_* (tail/normalize/trim-end)
// source mode, range inference (razor-else-time), the FX-bypass plan (corrects // mapping + the -72 dB derived ratio + the 8 s manual clamp, P_RAZOREDITS parsing
// the "items captured through parent FX" defect), and the capture-action taxonomy // -> ranges + union, scope -> source mode, range inference (razor-else-time), the
// table (stable ids, one row per scope). // FX-bypass plan (corrects the "items captured through parent FX" defect), and the
// capture-action taxonomy table (stable ids, scope x tail-variant matrix).
#include "../src/render_settings.h" #include "../src/render_settings.h"
#include <cmath>
#include <cstdio> #include <cstdio>
#include <set> #include <set>
#include <string> #include <string>
@@ -60,6 +62,70 @@ static void testRealtimeIsUnsupportedOffline() {
CHECK(!renderSettingsFor(SourceMode::Realtime, 1.0).supported); CHECK(!renderSettingsFor(SourceMode::Realtime, 1.0).supported);
} }
// --- tail: TailMode -> RENDER_* mapping (docs/product/capture-tail.md) --------
static void testTailNoneIsExactBounds() {
// None -> exact bounds, byte-identical to the pre-tail capture: tail flag clear,
// 0 ms, disable-all normalize (the current default), no trim. Asserting the exact
// bit values (not just "some value") pins the byte-identical contract: if the
// mapping regressed to set a tail bit or a non-disable-all normalize, this fails.
TailRenderSettings t = tailRenderSettingsFor(TailMode::None, 0.0);
CHECK(t.tailFlag == kTailFlagNone); // 0
CHECK(t.tailMs == 0.0);
CHECK(t.normalize == kNormalizeDisableAll); // 262144
CHECK(t.trimEnd == 0.0);
// manualTailMs must be ignored for None (a stray tail from a leftover ms is the bug).
TailRenderSettings t2 = tailRenderSettingsFor(TailMode::None, 5000.0);
CHECK(t2.tailFlag == kTailFlagNone);
CHECK(t2.tailMs == 0.0);
}
static void testTailAutoIsSurgicalTrim() {
// Auto -> custom-bounds tail bit, 8 s cap, SURGICAL normalize (ONLY &32768), and
// the -72 dB TRIMEND ratio. The disable-all bit must NOT be set (it is semantically
// opposed to trim — this assertion catches a regression to the None normalize).
TailRenderSettings t = tailRenderSettingsFor(TailMode::Auto, 0.0);
CHECK(t.tailFlag == kTailFlagCustomBounds); // &1
CHECK(t.tailMs == kMaxTailMs); // 8000
CHECK(t.normalize == kNormalizeTrimEnd); // exactly 32768, nothing else
CHECK((t.normalize & kNormalizeDisableAll) == 0); // disable-all is NOT set
// TRIMEND is the derived -72 dB ratio ~= 0.00025119 (the DAW-confirm value).
CHECK(std::fabs(t.trimEnd - 0.00025119) < 1e-8);
// manualTailMs is ignored for Auto (Auto always uses the 8 s cap).
CHECK(tailRenderSettingsFor(TailMode::Auto, 3000.0).tailMs == kMaxTailMs);
}
static void testAutoTrimRatioDerivesFromDb() {
// The ratio must DERIVE from the -72 dB constant (10^(dB/20)), not be a hardcoded
// float — recompute it independently and require an exact match with the mapping.
double expected = std::pow(10.0, kAutoTrimThresholdDb / 20.0);
CHECK(autoTrimEndRatio() == expected);
CHECK(tailRenderSettingsFor(TailMode::Auto, 0.0).trimEnd == expected);
// Sanity: -72 dB is well below unity but above zero.
CHECK(expected > 0.0 && expected < 0.001);
}
static void testTailManualFixedNoTrim() {
// Manual -> custom-bounds tail, the requested ms (within cap), disable-all
// normalize (no trim). A Manual capture is a fixed tail, so it keeps today's
// disable-all exactly like the no-tail path.
TailRenderSettings t = tailRenderSettingsFor(TailMode::Manual, 2500.0);
CHECK(t.tailFlag == kTailFlagCustomBounds);
CHECK(t.tailMs == 2500.0);
CHECK(t.normalize == kNormalizeDisableAll);
CHECK(t.trimEnd == 0.0);
}
static void testTailManualClampsToCap() {
// The 8 s cap is a runaway guard that applies to Manual too: ms > 8000 -> 8000.
CHECK(tailRenderSettingsFor(TailMode::Manual, 9000.0).tailMs == kMaxTailMs);
CHECK(tailRenderSettingsFor(TailMode::Manual, 8000.0).tailMs == kMaxTailMs);
// Below the cap is passed through unchanged.
CHECK(tailRenderSettingsFor(TailMode::Manual, 100.0).tailMs == 100.0);
// A negative request floors to 0 (no negative tail leaks into RENDER_TAILMS).
CHECK(tailRenderSettingsFor(TailMode::Manual, -50.0).tailMs == 0.0);
}
// --- parseRazorEdits: P_RAZOREDITS string -> ranges -------------------------- // --- parseRazorEdits: P_RAZOREDITS string -> ranges --------------------------
static void testParseSingleTrackAudioArea() { static void testParseSingleTrackAudioArea() {
@@ -150,15 +216,16 @@ static void testTrackScopeKeepsSelfBypassesAncestorsAndMaster() {
CHECK(p.bypassMaster); // no master FX CHECK(p.bypassMaster); // no master FX
} }
// --- captureActionTable: the two-scope taxonomy ------------------------------ // --- captureActionTable: the scope taxonomy ----------------------------------
static void testTableHasTwoScopeRows() { static void testTableHasBothScopes() {
const auto& table = captureActionTable(); const auto& table = captureActionTable();
// Exactly 2 scope rows: item, track. There is no master scope. // Two rows: item + track. No master scope, and NO tail variants — tail is a
// panel setting the capture reads at fire time, not a per-action row.
CHECK(table.size() == 2); CHECK(table.size() == 2);
std::set<std::string> ids; std::set<std::string> ids;
bool sawItem = false, sawTrack = false; int item = 0, track = 0;
for (const auto& def : table) { for (const auto& def : table) {
// Every id is a non-empty CEREBELLUM_REASAMPLER_ string and is UNIQUE // Every id is a non-empty CEREBELLUM_REASAMPLER_ string and is UNIQUE
// (duplicate ids would collide on registration). // (duplicate ids would collide on registration).
@@ -168,11 +235,25 @@ static void testTableHasTwoScopeRows() {
// Every scope resolves to a supported offline source. // Every scope resolves to a supported offline source.
CHECK(renderSettingsFor(sourceModeForScope(def.scope), 1.0).supported); CHECK(renderSettingsFor(sourceModeForScope(def.scope), 1.0).supported);
if (def.scope == CaptureScope::Item) sawItem = true; if (def.scope == CaptureScope::Item) ++item;
if (def.scope == CaptureScope::Track) sawTrack = true; if (def.scope == CaptureScope::Track) ++track;
} }
CHECK(sawItem); // Exactly one row per scope — no dupes, no gaps, no tail variants.
CHECK(sawTrack); CHECK(item == 1);
CHECK(track == 1);
}
static void testScopeActionIdsAreTheShippedStrings() {
// Pin the shipped CAPTURE_ITEM / CAPTURE_TRACK ids so a future edit that silently
// changes them (breaking user keybindings) fails the gate.
const auto& table = captureActionTable();
std::string itemId, trackId;
for (const auto& def : table) {
if (def.scope == CaptureScope::Item) itemId = def.commandString;
if (def.scope == CaptureScope::Track) trackId = def.commandString;
}
CHECK(itemId == "CEREBELLUM_REASAMPLER_CAPTURE_ITEM");
CHECK(trackId == "CEREBELLUM_REASAMPLER_CAPTURE_TRACK");
} }
int main() { int main() {
@@ -181,6 +262,11 @@ int main() {
testSelectedItemsSingleFile(); testSelectedItemsSingleFile();
testRazorSingleFile(); testRazorSingleFile();
testRealtimeIsUnsupportedOffline(); testRealtimeIsUnsupportedOffline();
testTailNoneIsExactBounds();
testTailAutoIsSurgicalTrim();
testAutoTrimRatioDerivesFromDb();
testTailManualFixedNoTrim();
testTailManualClampsToCap();
testParseSingleTrackAudioArea(); testParseSingleTrackAudioArea();
testParseMultipleAreas(); testParseMultipleAreas();
testParseSkipsEnvelopeLaneAreas(); testParseSkipsEnvelopeLaneAreas();
@@ -190,7 +276,8 @@ int main() {
testRangeInference(); testRangeInference();
testItemScopeBypassesEverythingButTake(); testItemScopeBypassesEverythingButTake();
testTrackScopeKeepsSelfBypassesAncestorsAndMaster(); testTrackScopeKeepsSelfBypassesAncestorsAndMaster();
testTableHasTwoScopeRows(); testTableHasBothScopes();
testScopeActionIdsAreTheShippedStrings();
if (g_fail == 0) std::printf("render_settings: all tests passed\n"); if (g_fail == 0) std::printf("render_settings: all tests passed\n");
else std::printf("render_settings: %d CHECK(s) FAILED\n", g_fail); else std::printf("render_settings: %d CHECK(s) FAILED\n", g_fail);
+83
View File
@@ -0,0 +1,83 @@
// Standalone tests for reasampler::tail_control — no REAPER, no framework. Covers
// the pure pieces behind the docked panel's tail-mode toggle: the cycle order
// (None -> Auto -> Manual -> None), the manual-length clamp to the 8 s cap, and the
// toggle label text. The drawing / click hit-testing is DAW-verified in bank_panel.
#include "../src/tail_control.h"
#include <cstdio>
#include <string>
using namespace reasampler;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// --- cycleTailMode: the toggle order ------------------------------------------
static void testCycleOrderIsNoneAutoManualNone() {
// None -> Auto -> Manual -> None, wrapping. The click handler relies on exactly
// this order; a reorder (e.g. skipping Manual) would fail here.
CHECK(cycleTailMode(TailMode::None) == TailMode::Auto);
CHECK(cycleTailMode(TailMode::Auto) == TailMode::Manual);
CHECK(cycleTailMode(TailMode::Manual) == TailMode::None);
}
static void testCycleThreeStepsReturnsToStart() {
// Three cycles from any state land back on that state (a full lap of the 3-cycle).
TailMode m = TailMode::None;
m = cycleTailMode(m);
m = cycleTailMode(m);
m = cycleTailMode(m);
CHECK(m == TailMode::None);
}
// --- clampManualMs: the runaway guard -----------------------------------------
static void testManualClampInRangeIsUnchanged() {
// A value inside [0, kMaxTailMs] passes through untouched.
CHECK(clampManualMs(kDefaultManualTailMs) == kDefaultManualTailMs);
CHECK(clampManualMs(0.0) == 0.0);
CHECK(clampManualMs(kMaxTailMs) == kMaxTailMs);
}
static void testManualClampCapsAtEightSeconds() {
// Over the 8 s cap clamps to kMaxTailMs; negative floors to 0. This mirrors the
// clamp in tailRenderSettingsFor, so the panel and the render agree on the bound.
CHECK(clampManualMs(kMaxTailMs + 5000.0) == kMaxTailMs);
CHECK(clampManualMs(-100.0) == 0.0);
}
// --- tailToggleLabel: the exact strings the panel draws -----------------------
static void testLabelStringsPerMode() {
TailSetting off; off.mode = TailMode::None;
TailSetting autoM; autoM.mode = TailMode::Auto;
TailSetting man; man.mode = TailMode::Manual;
CHECK(tailToggleLabel(off) == "Tail: Off");
CHECK(tailToggleLabel(autoM) == "Tail: Auto");
CHECK(tailToggleLabel(man) == "Tail: Manual");
}
static void testDefaultSettingIsOff() {
// The zero-value setting is None (Off) with the 2 s manual default — the safe
// default the panel starts in so captures stay exact-bounds until opt-in.
TailSetting s;
CHECK(s.mode == TailMode::None);
CHECK(s.manualMs == kDefaultManualTailMs);
CHECK(tailToggleLabel(s) == "Tail: Off");
}
int main() {
testCycleOrderIsNoneAutoManualNone();
testCycleThreeStepsReturnsToStart();
testManualClampInRangeIsUnchanged();
testManualClampCapsAtEightSeconds();
testLabelStringsPerMode();
testDefaultSettingIsOff();
if (g_fail == 0) std::printf("tail_control: all tests passed\n");
else std::printf("tail_control: %d CHECK(s) FAILED\n", g_fail);
return g_fail == 0 ? 0 : 1;
}