Merge Ψ-W2-T1: captures named after their source track, surfaced on the panel card
This commit is contained in:
@@ -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.
|
**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
|
## 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.
|
**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.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ add_library(reaper_reasampler MODULE
|
|||||||
${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp
|
${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.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 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)
|
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
|
# 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
|
# 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.
|
# free of the voice engine — a link edge to it here means the design drifted.
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ Detail specific to these pure modules:
|
|||||||
- `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`).
|
- `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.
|
- `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_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`.
|
- `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_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.
|
- `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.
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
reasampler_pure_library(capture_paths SOURCES capture_paths.cpp)
|
reasampler_pure_library(capture_paths SOURCES capture_paths.cpp)
|
||||||
reasampler_test(capture_paths LINK capture_paths)
|
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_pure_library(insert_plan SOURCES insert_plan.cpp)
|
||||||
reasampler_test(insert_plan LINK insert_plan)
|
reasampler_test(insert_plan LINK insert_plan)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -199,7 +199,7 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges);
|
|||||||
struct CaptureActionDef {
|
struct CaptureActionDef {
|
||||||
const char* commandSuffix; // e.g. "CAPTURE_TRACK" — FOREVER-STABLE (composed w/ prefix)
|
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* 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)
|
CaptureScope scope; // FX scope (item / track)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ L7 sub-pass, 2026-07-27):
|
|||||||
- `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.
|
- `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.
|
- `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_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
|
- `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
|
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²).
|
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²).
|
||||||
|
|||||||
@@ -7,6 +7,18 @@
|
|||||||
|
|
||||||
namespace reasampler::ui {
|
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) {
|
std::string formatBarsBeats(const MusicalLength& m) {
|
||||||
if (m.tempoBpm <= 0.0 || m.timeSigNum <= 0 || m.timeSigDenom <= 0) return {};
|
if (m.tempoBpm <= 0.0 || m.timeSigNum <= 0 || m.timeSigDenom <= 0) return {};
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,25 @@
|
|||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
#include "core/ui/rect.h"
|
||||||
|
|
||||||
namespace reasampler::ui {
|
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;
|
// Musical length inputs, taken straight off a Sample's capture-time stamp. tempoBpm 0 = unknown;
|
||||||
// timeSigNum/Denom 0 = unstamped.
|
// timeSigNum/Denom 0 = unstamped.
|
||||||
struct MusicalLength {
|
struct MusicalLength {
|
||||||
|
|||||||
@@ -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).
|
// test composes the same value the shell draws with (see compositeOver).
|
||||||
inline constexpr double kLoopSpanFillAlpha = 0.20;
|
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
|
// 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
|
// is ignored — a translucent overlay's effective color is the caller's to compose first
|
||||||
// (compositeOver).
|
// (compositeOver).
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ detail not covered there:
|
|||||||
## Modules
|
## 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`.
|
- `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).
|
- `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_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`).
|
- `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).
|
- `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).
|
||||||
|
|||||||
@@ -201,6 +201,32 @@ std::string makeUniqueTag(const std::string& prefix) {
|
|||||||
std::to_string(++counter);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
void stampCaptureSample(Sample& s, const CaptureRequest& req,
|
void stampCaptureSample(Sample& s, const CaptureRequest& req,
|
||||||
ReaProject* rateProj, ReaProject* timeSigProj,
|
ReaProject* rateProj, ReaProject* timeSigProj,
|
||||||
const std::string& absolutePath) {
|
const std::string& absolutePath) {
|
||||||
@@ -452,7 +478,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
|||||||
// Same uniqueTag that named the file — calling makeUniqueTag() again could
|
// Same uniqueTag that named the file — calling makeUniqueTag() again could
|
||||||
// yield a different value and desync Sample.id from the file name.
|
// yield a different value and desync Sample.id from the file name.
|
||||||
s.id = "cap-" + uniqueTag + "-" + paths.fileName;
|
s.id = "cap-" + uniqueTag + "-" + paths.fileName;
|
||||||
s.displayName = request.baseName;
|
s.displayName = request.label();
|
||||||
s.relativePath = paths.relativePath; // project-relative (invariant)
|
s.relativePath = paths.relativePath; // project-relative (invariant)
|
||||||
s.sourceMode = request.sourceMode;
|
s.sourceMode = request.sourceMode;
|
||||||
s.sourceRange.startSeconds = request.startSeconds;
|
s.sourceRange.startSeconds = request.startSeconds;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "core/model/bank_model.h"
|
#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/render_settings.h" // TailMode — the three-state tail contract
|
||||||
|
|
||||||
// Forward-declared, never dereferenced here — only the REAPER-facing .cpp touches these.
|
// Forward-declared, never dereferenced here — only the REAPER-facing .cpp touches these.
|
||||||
@@ -60,6 +61,15 @@ struct CaptureRequest {
|
|||||||
// backend caller so the pure naming logic stays testable.
|
// backend caller so the pure naming logic stays testable.
|
||||||
std::string baseName = "capture";
|
std::string baseName = "capture";
|
||||||
std::string uniqueTag;
|
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.
|
// Every failure is an explicit code, never a thrown exception across the REAPER boundary.
|
||||||
@@ -100,6 +110,14 @@ public:
|
|||||||
// backend's family marker ("" offline, "rt-" realtime).
|
// backend's family marker ("" offline, "rt-" realtime).
|
||||||
std::string makeUniqueTag(const std::string& prefix);
|
std::string makeUniqueTag(const std::string& prefix);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
// Stamps the metadata shared by both backends onto `s`: trackGuids + channelCount
|
// Stamps the metadata shared by both backends onto `s`: trackGuids + channelCount
|
||||||
// (echoed from the request), resolved sampleRate (request rate, else PROJECT_SRATE
|
// (echoed from the request), resolved sampleRate (request rate, else PROJECT_SRATE
|
||||||
// from `rateProj`), captureTempo, the capture-start time signature
|
// from `rateProj`), captureTempo, the capture-start time signature
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ namespace reasampler::capture {
|
|||||||
// captureAndIndexOne so every precision invariant holds; nothing lands in the
|
// captureAndIndexOne so every precision invariant holds; nothing lands in the
|
||||||
// arrange (load-bearing principle).
|
// arrange (load-bearing principle).
|
||||||
//
|
//
|
||||||
// Each unit's baseName carries its ordinal ("item-1", "item-2", ...) so two units
|
// Each unit is named after its own source track and carries its batch ordinal, so two
|
||||||
// in one batch never share a stem, and makeUniqueTag's per-session monotonic
|
// units in one batch read apart even when they came off the same track; makeUniqueTag's
|
||||||
// counter keeps same-second units across batches from colliding too.
|
// per-session monotonic counter keeps same-second units across batches from colliding.
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -219,12 +219,14 @@ void RunBatchCaptureItems(ReaSamplerSession& session)
|
|||||||
// selected-items render source prints exactly it — batch keeps the
|
// selected-items render source prints exactly it — batch keeps the
|
||||||
// one-sample-per-item-at-item-extent semantics, unchanged.
|
// one-sample-per-item-at-item-extent semantics, unchanged.
|
||||||
src.itemExtentIsWindow = true;
|
src.itemExtentIsWindow = true;
|
||||||
|
src.trackNames.push_back(trackName(u.track));
|
||||||
if (std::string g = guidString(u.track); !g.empty())
|
if (std::string g = guidString(u.track); !g.empty())
|
||||||
src.trackGuids.push_back(std::move(g));
|
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(
|
CaptureResult res = captureAndIndexOne(
|
||||||
session, CaptureScope::Item, src, baseName,
|
session, CaptureScope::Item, src, name,
|
||||||
unit.startSeconds, unit.endSeconds);
|
unit.startSeconds, unit.endSeconds);
|
||||||
|
|
||||||
const bool ok = (res.status == CaptureStatus::Ok);
|
const bool ok = (res.status == CaptureStatus::Ok);
|
||||||
@@ -283,12 +285,14 @@ void RunBatchCaptureRazor(ReaSamplerSession& session)
|
|||||||
src.startSeconds = unit.startSeconds;
|
src.startSeconds = unit.startSeconds;
|
||||||
src.endSeconds = unit.endSeconds;
|
src.endSeconds = unit.endSeconds;
|
||||||
src.sourceTracks.push_back(tr);
|
src.sourceTracks.push_back(tr);
|
||||||
|
src.trackNames.push_back(trackName(tr));
|
||||||
if (std::string g = guidString(tr); !g.empty())
|
if (std::string g = guidString(tr); !g.empty())
|
||||||
src.trackGuids.push_back(std::move(g));
|
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(
|
CaptureResult res = captureAndIndexOne(
|
||||||
session, CaptureScope::Track, src, baseName,
|
session, CaptureScope::Track, src, name,
|
||||||
unit.startSeconds, unit.endSeconds);
|
unit.startSeconds, unit.endSeconds);
|
||||||
|
|
||||||
const bool ok = (res.status == CaptureStatus::Ok);
|
const bool ok = (res.status == CaptureStatus::Ok);
|
||||||
@@ -397,6 +401,10 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
|
|||||||
req.sampleRate = recipe->sampleRate;
|
req.sampleRate = recipe->sampleRate;
|
||||||
req.channelCount = recipe->channelCount;
|
req.channelCount = recipe->channelCount;
|
||||||
req.bitDepth = WavBitDepth::Float32;
|
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.baseName = orig->displayName.empty() ? "recapture" : orig->displayName;
|
||||||
req.trackGuids = recipe->trackGuids;
|
req.trackGuids = recipe->trackGuids;
|
||||||
|
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ CaptureResult renderOffline(CaptureScope scope,
|
|||||||
CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
||||||
CaptureScope scope,
|
CaptureScope scope,
|
||||||
const ResolvedSource& src,
|
const ResolvedSource& src,
|
||||||
const std::string& baseName,
|
const CaptureName& name,
|
||||||
double startSeconds,
|
double startSeconds,
|
||||||
double endSeconds)
|
double endSeconds)
|
||||||
{
|
{
|
||||||
@@ -262,7 +262,8 @@ CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
|||||||
req.sampleRate = 0; // follow project rate
|
req.sampleRate = 0; // follow project rate
|
||||||
req.channelCount = 2;
|
req.channelCount = 2;
|
||||||
req.bitDepth = WavBitDepth::Float32; // deterministic, no dither
|
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)
|
req.trackGuids = src.trackGuids; // recorded on the Sample (provenance)
|
||||||
|
|
||||||
// M10: compute provenance BEFORE the FxBypassGuard neutralizes the in-scope chain —
|
// M10: compute provenance BEFORE the FxBypassGuard neutralizes the in-scope chain —
|
||||||
@@ -320,7 +321,12 @@ std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def)
|
|||||||
return {};
|
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);
|
src.startSeconds, src.endSeconds);
|
||||||
if (res.status != CaptureStatus::Ok)
|
if (res.status != CaptureStatus::Ok)
|
||||||
{
|
{
|
||||||
@@ -438,7 +444,10 @@ void RunCaptureRealtimeTrack(ReaSamplerSession& session)
|
|||||||
req.sampleRate = 0; // follow project rate
|
req.sampleRate = 0; // follow project rate
|
||||||
req.channelCount = 2;
|
req.channelCount = 2;
|
||||||
req.bitDepth = WavBitDepth::Float32;
|
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
|
req.trackGuids = src.trackGuids; // provenance on the Sample
|
||||||
|
|
||||||
CaptureResult failure;
|
CaptureResult failure;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ CaptureResult renderOffline(CaptureScope scope,
|
|||||||
CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
||||||
CaptureScope scope,
|
CaptureScope scope,
|
||||||
const ResolvedSource& src,
|
const ResolvedSource& src,
|
||||||
const std::string& baseName,
|
const CaptureName& name,
|
||||||
double startSeconds,
|
double startSeconds,
|
||||||
double endSeconds);
|
double endSeconds);
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
|
|||||||
cap.startSeconds = request.startSeconds;
|
cap.startSeconds = request.startSeconds;
|
||||||
cap.endSeconds = request.endSeconds;
|
cap.endSeconds = request.endSeconds;
|
||||||
cap.wetDry = request.wetDry;
|
cap.wetDry = request.wetDry;
|
||||||
cap.displayName = request.baseName;
|
cap.displayName = request.label();
|
||||||
cap.trackGuids = request.trackGuids;
|
cap.trackGuids = request.trackGuids;
|
||||||
cap.channelCount = request.channelCount;
|
cap.channelCount = request.channelCount;
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#include "shell/capture/scope_resolve.h"
|
#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 <filesystem> // project-dir derivation for provenance parent resolution
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
#define REAPERAPI_WANT_GetMediaItemInfo_Value
|
#define REAPERAPI_WANT_GetMediaItemInfo_Value
|
||||||
#define REAPERAPI_WANT_CountSelectedTracks
|
#define REAPERAPI_WANT_CountSelectedTracks
|
||||||
#define REAPERAPI_WANT_GetSelectedTrack
|
#define REAPERAPI_WANT_GetSelectedTrack
|
||||||
|
#define REAPERAPI_WANT_GetTrackName
|
||||||
#include "reaper_plugin_functions.h"
|
#include "reaper_plugin_functions.h"
|
||||||
|
|
||||||
namespace reasampler::capture {
|
namespace reasampler::capture {
|
||||||
@@ -81,6 +83,7 @@ bool collectSelectedItemTracks(ResolvedSource& out,
|
|||||||
for (MediaTrack* t : out.sourceTracks) if (t == tr) { seen = true; break; }
|
for (MediaTrack* t : out.sourceTracks) if (t == tr) { seen = true; break; }
|
||||||
if (seen) continue;
|
if (seen) continue;
|
||||||
out.sourceTracks.push_back(tr);
|
out.sourceTracks.push_back(tr);
|
||||||
|
out.trackNames.push_back(trackName(tr));
|
||||||
std::string g = guidString(tr);
|
std::string g = guidString(tr);
|
||||||
if (!g.empty()) out.trackGuids.push_back(std::move(g));
|
if (!g.empty()) out.trackGuids.push_back(std::move(g));
|
||||||
}
|
}
|
||||||
@@ -101,6 +104,18 @@ int projectSampleRate()
|
|||||||
|
|
||||||
} // namespace
|
} // 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
|
// 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.
|
// start, end, envGuidString) and returns the union of parsed track-audio areas.
|
||||||
// Reads only — never clears the razor selection.
|
// Reads only — never clears the razor selection.
|
||||||
@@ -148,6 +163,7 @@ bool collectSelectedTracks(ResolvedSource& out)
|
|||||||
MediaTrack* tr = GetSelectedTrack(nullptr, i);
|
MediaTrack* tr = GetSelectedTrack(nullptr, i);
|
||||||
if (!tr) continue;
|
if (!tr) continue;
|
||||||
out.sourceTracks.push_back(tr);
|
out.sourceTracks.push_back(tr);
|
||||||
|
out.trackNames.push_back(trackName(tr));
|
||||||
std::string g = guidString(tr);
|
std::string g = guidString(tr);
|
||||||
if (!g.empty()) out.trackGuids.push_back(std::move(g));
|
if (!g.empty()) out.trackGuids.push_back(std::move(g));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ struct ResolvedSource
|
|||||||
std::vector<MediaTrack*> sourceTracks;
|
std::vector<MediaTrack*> sourceTracks;
|
||||||
std::vector<std::string> trackGuids;
|
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
|
// Item scope only: does the selected items' own extent already print
|
||||||
// [startSeconds, endSeconds)? Feeds sourceModeForScope. Defaults false so a
|
// [startSeconds, endSeconds)? Feeds sourceModeForScope. Defaults false so a
|
||||||
// hand-built source fails closed to the time-bounded render — a caller whose
|
// hand-built source fails closed to the time-bounded render — a caller whose
|
||||||
@@ -50,9 +55,19 @@ bool resolveRazorRange(double& start, double& end);
|
|||||||
// selection. Returns false with a reason when neither yields a non-empty range.
|
// selection. Returns false with a reason when neither yields a non-empty range.
|
||||||
bool resolveRange(double& start, double& end, std::string& why);
|
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);
|
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
|
// Resolves the source for a scope: the selection tracks (item/track), plus the
|
||||||
// inferred range. Returns false with a reason on nothing to do.
|
// inferred range. Returns false with a reason on nothing to do.
|
||||||
bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why);
|
bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why);
|
||||||
|
|||||||
@@ -30,17 +30,33 @@ void drawCardMeta(LICE_IBitmap* bmp, const CellRect& rect, const Sample& s) {
|
|||||||
const std::string bars = formatBarsBeats(ml);
|
const std::string bars = formatBarsBeats(ml);
|
||||||
const std::string secs = formatSecondsMs(s.lengthSeconds);
|
const std::string secs = formatSecondsMs(s.lengthSeconds);
|
||||||
|
|
||||||
const int stripH = 12;
|
const int y = rect.y + rect.height - kCardStripHeight;
|
||||||
const int pad = 3;
|
|
||||||
const int y = rect.y + rect.height - stripH;
|
|
||||||
if (!bars.empty()) {
|
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);
|
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);
|
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,
|
void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env,
|
||||||
bool selected, bool focused, bool hovered, const Sample* sample) {
|
bool selected, bool focused, bool hovered, const Sample* sample) {
|
||||||
// Selected cards draw the normal cell surface, not an inverted fill — selection
|
// 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);
|
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) {
|
KitBox toKitBox(const RECT& r) {
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ using ui::TooltipBox;
|
|||||||
using ui::TooltipSpec;
|
using ui::TooltipSpec;
|
||||||
using ui::applyClick;
|
using ui::applyClick;
|
||||||
using ui::assemblePathList;
|
using ui::assemblePathList;
|
||||||
|
using ui::cardNameStrip;
|
||||||
using ui::clampTabScroll;
|
using ui::clampTabScroll;
|
||||||
using ui::columnsForWidth;
|
using ui::columnsForWidth;
|
||||||
using ui::computeBarSlots;
|
using ui::computeBarSlots;
|
||||||
@@ -132,6 +133,9 @@ using ui::hitTestMenuButton;
|
|||||||
using ui::hitTestPruneButton;
|
using ui::hitTestPruneButton;
|
||||||
using ui::hitTestSlot;
|
using ui::hitTestSlot;
|
||||||
using ui::hitTestTabStrip;
|
using ui::hitTestTabStrip;
|
||||||
|
using ui::kCardNameScrimAlpha;
|
||||||
|
using ui::kCardStripHeight;
|
||||||
|
using ui::kCardStripPad;
|
||||||
using ui::menuButtonReserve;
|
using ui::menuButtonReserve;
|
||||||
using ui::modeSegmentEnabled;
|
using ui::modeSegmentEnabled;
|
||||||
using ui::navigate;
|
using ui::navigate;
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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
|
// zero-length marker); a loop whose end sits at the file's last frame. Also asserts
|
||||||
@@ -588,6 +618,7 @@ int main() {
|
|||||||
testIntegerOverflow();
|
testIntegerOverflow();
|
||||||
testEnumRangeValidation();
|
testEnumRangeValidation();
|
||||||
testLegacyJsonDefaults();
|
testLegacyJsonDefaults();
|
||||||
|
testPreNamingSchemeLabelsSurviveUntouched();
|
||||||
testSeamFieldBoundaries();
|
testSeamFieldBoundaries();
|
||||||
testSeamFieldsAdditiveInvariant();
|
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");
|
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() {
|
int main() {
|
||||||
|
testNameStripSitsAcrossTheTopOfTheCell();
|
||||||
|
testNameStripNeverOverlapsTheLengthReadOut();
|
||||||
|
testNameStripStaysInsideTheCell();
|
||||||
|
testNameStripSuppressedOnATooShortCell();
|
||||||
|
testNameStripSuppressedOnATooNarrowCell();
|
||||||
testZeroLengthIsBarOneOrigin();
|
testZeroLengthIsBarOneOrigin();
|
||||||
testSubBeat();
|
testSubBeat();
|
||||||
testWholeBeatWithinBar();
|
testWholeBeatWithinBar();
|
||||||
|
|||||||
@@ -250,6 +250,25 @@ static void testTextOnHoverSurfaceClearsFloor() {
|
|||||||
CHECK(contrastRatio(roleColor(Role::TextDim), hover) >= textFloor(TextClass::Large));
|
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) ----------------------------
|
// --- Single point of change (structural guarantee) ----------------------------
|
||||||
//
|
//
|
||||||
// roleColor is the ONLY color source; there is no other public accessor that yields a
|
// roleColor is the ONLY color source; there is no other public accessor that yields a
|
||||||
@@ -346,6 +365,7 @@ int main() {
|
|||||||
testRegionTitleAccentsClearLargeFloorOnPanel();
|
testRegionTitleAccentsClearLargeFloorOnPanel();
|
||||||
testTextOnPastelFillClearsBodyFloor();
|
testTextOnPastelFillClearsBodyFloor();
|
||||||
testTextOnHoverSurfaceClearsFloor();
|
testTextOnHoverSurfaceClearsFloor();
|
||||||
|
testCardNameScrimClearsBodyFloorOnItsWorstBackground();
|
||||||
testCurveTraceOnHoverSurfaceClearsFloor();
|
testCurveTraceOnHoverSurfaceClearsFloor();
|
||||||
testSecondaryTertiaryAreDistinguishable();
|
testSecondaryTertiaryAreDistinguishable();
|
||||||
testOverlayTraceClearsIndicatorFloorOnTheWaveform();
|
testOverlayTraceClearsIndicatorFloorOnTheWaveform();
|
||||||
|
|||||||
Reference in New Issue
Block a user