Merge Ψ-W2-T1: captures named after their source track, surfaced on the panel card

This commit is contained in:
2026-08-01 22:16:20 -04:00
26 changed files with 730 additions and 25 deletions
+28
View File
@@ -194,6 +194,34 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r
**Done looks like.** Not stated in the source beyond choosing one of the three placement options.
## Confirm the card name strip reads legibly at the shipping cell size (Ψ-W2-T1 DAW verification)
**Context.** Ψ-W2-T1 (`capture-naming`) put the capture's label on the docked panel card,
across the top of the cell, drawn OVER the waveform thumbnail. Review found the strip's
text/primary was measured at ~1:1 contrast against the accent-lime waveform fill at the
shipping 140×84 cell size — a loud capture's peak reaches into the strip on 12 of its 13
rows — and remediated it with a bg/base scrim behind the name (`kCardNameScrimAlpha`,
`core/ui/theme.h`) sized so the composite clears the WCAG 4.5:1 body floor against both the
bare fill and bare bg/cell (pinned in `test_theme.cpp`).
**The wart.** The floor math is verified; the actual on-screen read is not. No `[verify —
DAW]` deferral was filed for this track's acceptance criterion ("the panel card shows the
name") when it landed, unlike the sibling Ψ tracks.
**Intended fix.** N/A — no code change. Daniel views the docked panel with real captures
(quiet and loud material, long and short names) and confirms the name reads over the
waveform at the shipping cell size.
**The constraint the fix MUST handle.** N/A — verification only.
**Priority / risk.** Not stated. The math clears its floor with real margin (see
`testCardNameScrimClearsBodyFloorOnItsWorstBackground`), so this is a confirmation step,
not a suspected defect.
**Done looks like.** Daniel confirms the card name reads legibly over both quiet and
loud waveform material at the shipping 140×84 cell size, or a follow-up adjusts the scrim
alpha and this entry is re-filed against the new value.
## A realtime capture interrupted by a project switch leaves an untracked file behind
**Context (found by the tracking-consolidation review, 2026-07-30).** `DriveRealtimeCapture` detects that the active project is no longer the one the in-flight capture belongs to, aborts the backend, and drops the handle. On a `Done` abort the backend has *already* moved the recorded WAV into the **original** project's bank folder (`capture_realtime_finalize`), so a file the tool created exists with no bank entry and no ledger record.
+1 -1
View File
@@ -49,7 +49,7 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp
)
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys 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
# 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.
+1
View File
@@ -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`).
- `batch_capture` — pure batch-capture planner: maps source ranges to capture units and aggregates results.
- `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition``NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives.
- `capture_name` — the REAPER-free composition of one capture's label + file-stem base from its source-track name(s), a local-calendar discriminator (`MM-DD HHMM`, from the shell's clock read), and an optional batch ordinal. The label and the stem deliberately diverge: the stem still passes through `capture_paths::sanitizeStem` (so a name that sanitizes to nothing files as `capture`), while the label keeps the source name verbatim. Stem uniqueness stays entirely `makeUniqueTag`'s — this module never disambiguates.
- `insert_plan` — the REAPER-free logic behind the `insert` shell (M6): computes the `InsertMedia` `mode` bitmask from an `InsertOptions` struct (placement target, tempo-conform ratio, preserve-pitch flag), guaranteeing the &4 stretch-to-time-selection bit is never set and that no tempo bits are set when `conform == None`.
- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode``RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), and the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family.
- `render_window` — the REAPER-free frame arithmetic behind exact capture bounds: `frameCountFor` (the frame count a project-time window occupies at the project rate — the number the offline backend checks the rendered file against before landing it, so a widened render is refused rather than banked) and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all.
+5
View File
@@ -1,6 +1,11 @@
reasampler_pure_library(capture_paths SOURCES capture_paths.cpp)
reasampler_test(capture_paths LINK capture_paths)
reasampler_pure_library(capture_name SOURCES capture_name.cpp)
# capture_paths: the stem base's real contract is that sanitizeStem keeps it legal, so the
# name tests assert the composed stem THROUGH the sanitizer rather than in isolation.
reasampler_test(capture_name LINK capture_name capture_paths)
reasampler_pure_library(insert_plan SOURCES insert_plan.cpp)
reasampler_test(insert_plan LINK insert_plan)
+89
View File
@@ -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
+62
View File
@@ -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
+1 -1
View File
@@ -199,7 +199,7 @@ RazorRange razorUnionBounds(const std::vector<RazorRange>& ranges);
struct CaptureActionDef {
const char* commandSuffix; // e.g. "CAPTURE_TRACK" — FOREVER-STABLE (composed w/ prefix)
const char* descriptionPhrase; // e.g. "capture selected track(s)" — Actions-list phrase
const char* baseName; // file-stem base for this capture
const char* baseName; // file-stem FALLBACK; the source track normally names the capture
CaptureScope scope; // FX scope (item / track)
};
+1 -1
View File
@@ -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.
- `tooltip` — pure tooltip placement + prefix-strip: strips the `ReaSampler:` display prefix from the registered action phrase; width clamped to the client rect.
- `card_drag` — pure drag-gesture precedence + slot hit-test: leave-client → OS drag-out; other-bank → move/copy; same-bank → reorder / Alt-over-occupied → replace.
- `card_meta` — pure card-metadata formatters: bars.beats.subdivisions and seconds.milliseconds; blank when the sample is unstamped.
- `card_meta` — pure card-metadata formatters: bars.beats.subdivisions and seconds.milliseconds; blank when the sample is unstamped. Also `cardNameStrip` + the two strip constants — the card's name line sits across the TOP of the cell, drawn over the waveform exactly as the length read-out is over it at the bottom, and is suppressed entirely on a cell with no room for both strips plus a waveform band.
- `stroke_aa` — analytic antialiased thick-stroke COVERAGE (the shell blends it): `StrokeCanvas`, a reusable mask holding distance-to-polyline coverage MAX-accumulated across segments, plus `strokePolyline` / `strokeBounds` / `appendArc` / `rasterRowOffset` (the row-major offset
math for a possibly bottom-up raster, pulled out of the shell's LICE blend so its flipped
branch is pinned by a host-free test). An arc is just a flattened polyline, so ONE path serves the knob arcs, the inner dial, the envelope polyline and both spline traces. Coverage is `clamp(halfWidth + 0.5 - distance, 0, 1)`, which makes perpendicular weight exactly `2·halfWidth` at every angle. **The guaranteed-opaque-core threshold is width ≥ 2 px, not any width above 1 px**: opacity needs `distance <= halfWidth - 0.5`, and the worst-case distance from a pixel centre to the centreline is 0.5, so a 1 px stroke (`halfWidth = 0.5`) has zero slack — its peak alpha modulates with the stroke's exact alignment to the pixel grid instead of pinning to 255 (Daniel's ruling, 2026-08-01: every stroker-drawn width on the editor is now >= 2 px for this reason — `testSubOpaqueCoreAtOnePixelWidth` in `tests/test_stroke_aa.cpp` still pins the 1 px case as a property of the stroker, independent of whether any surface ships at that width). Long segments are subdivided before rasterizing — EXACT, not an approximation (min-distance to a partition of a segment is min-distance to the whole), purely to keep each piece's bounding box tight, since one long diagonal's box has area O(len²).
+12
View File
@@ -7,6 +7,18 @@
namespace reasampler::ui {
Rect cardNameStrip(const Rect& cell) {
// Both strips plus a waveform band at least as tall as one strip; below that the card
// is a text block, not a thumbnail.
const int minHeight = 3 * kCardStripHeight;
if (cell.width <= 2 * kCardStripPad || cell.height < minHeight) return Rect{};
// Inset by 1px from the top edge so the name never sits on the focused-cell inner ring
// (drawn at rect.y+1, panel_render.cpp) — the selection border itself is at rect.y and is
// clear regardless.
return Rect{cell.x + kCardStripPad, cell.y + 1,
cell.width - 2 * kCardStripPad, kCardStripHeight};
}
std::string formatBarsBeats(const MusicalLength& m) {
if (m.tempoBpm <= 0.0 || m.timeSigNum <= 0 || m.timeSigDenom <= 0) return {};
+17
View File
@@ -5,8 +5,25 @@
#include <string>
#include "core/ui/rect.h"
namespace reasampler::ui {
// Both card overlay strips — the name line across the top, the length read-out along the
// bottom — are this tall, with this much horizontal inset.
inline constexpr int kCardStripHeight = 12;
inline constexpr int kCardStripPad = 3;
// The strip the card's name line occupies: across the top of the cell, drawn OVER the
// waveform exactly as the length read-out is drawn over it at the bottom, rather than a
// reserved non-drawing band — kCardStripHeight (12px) is a small slice of the shipping 84px
// cell, and the overlay matches the bottom read-out's existing convention rather than
// introducing a second layout rule. The draw site scrims behind the text so it stays legible
// over the waveform's accent fill (`kCardNameScrimAlpha`, `theme.h`). Empty when the cell has
// no room for both strips plus a waveform worth looking at — the caller draws nothing rather
// than burying the card under text.
Rect cardNameStrip(const Rect& cell);
// Musical length inputs, taken straight off a Sample's capture-time stamp. tempoBpm 0 = unknown;
// timeSigNum/Denom 0 = unstamped.
struct MusicalLength {
+7
View File
@@ -97,6 +97,13 @@ KitColor compositeOver(const KitColor& over, const KitColor& under, double alpha
// test composes the same value the shell draws with (see compositeOver).
inline constexpr double kLoopSpanFillAlpha = 0.20;
// The card name strip's scrim: bg/base composited at this alpha UNDER the name text, so
// text/primary stays readable when a loud capture's waveform peak (accent/primary) reaches
// into the strip. Named HERE, same reason as kLoopSpanFillAlpha above — test_theme.cpp composes
// this exact value against the strip's worst-case background (accent/primary) to pin the 4.5:1
// body floor Font::Micro answers to.
inline constexpr double kCardNameScrimAlpha = 0.75;
// Relative luminance per WCAG 2.1 (sRGB linearization + 0.2126/0.7152/0.0722 weighting). Alpha
// is ignored — a translucent overlay's effective color is the caller's to compose first
// (compositeOver).
+1 -1
View File
@@ -52,7 +52,7 @@ detail not covered there:
## Modules
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`.
- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain).
- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain). Also the one place a source track's NAME is read (`trackName`, via `GetTrackName` — chosen over `P_NAME` because it already answers REAPER's `"Track N"` convention for an unnamed track), landed on `ResolvedSource::trackNames` parallel to `sourceTracks` and composed into the capture's label + stem by the pure `core/capture/capture_name`.
- `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged ONLY for that source mode, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`.
- `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`).
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places).
+27 -1
View File
@@ -201,6 +201,32 @@ std::string makeUniqueTag(const std::string& prefix) {
std::to_string(++counter);
}
CaptureName captureNameFor(const std::vector<std::string>& sourceNames,
int ordinal, const std::string& fallback) {
CaptureNameInputs in;
in.sourceNames = sourceNames;
in.ordinal = ordinal;
in.fallback = fallback;
// localtime, not gmtime: the discriminator is read by the person who made the
// capture, so it must match the clock on their wall. A failed conversion leaves the
// stamp zeroed, which composeCaptureName renders as no discriminator at all.
const std::time_t now = std::time(nullptr);
std::tm local{};
#ifdef _WIN32
const bool ok = (localtime_s(&local, &now) == 0);
#else
const bool ok = (localtime_r(&now, &local) != nullptr);
#endif
if (ok) {
in.stamp.month = local.tm_mon + 1; // tm_mon is 0-based
in.stamp.day = local.tm_mday;
in.stamp.hour = local.tm_hour;
in.stamp.minute = local.tm_min;
}
return composeCaptureName(in);
}
void stampCaptureSample(Sample& s, const CaptureRequest& req,
ReaProject* rateProj, ReaProject* timeSigProj,
const std::string& absolutePath) {
@@ -452,7 +478,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
// Same uniqueTag that named the file — calling makeUniqueTag() again could
// yield a different value and desync Sample.id from the file name.
s.id = "cap-" + uniqueTag + "-" + paths.fileName;
s.displayName = request.baseName;
s.displayName = request.label();
s.relativePath = paths.relativePath; // project-relative (invariant)
s.sourceMode = request.sourceMode;
s.sourceRange.startSeconds = request.startSeconds;
+18
View File
@@ -10,6 +10,7 @@
#include <vector>
#include "core/model/bank_model.h"
#include "core/capture/capture_name.h" // CaptureName — label + file-stem base
#include "core/capture/render_settings.h" // TailMode — the three-state tail contract
// 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.
std::string baseName = "capture";
std::string uniqueTag;
// The label the bank shows, which may legitimately differ from the file stem: the
// stem must survive sanitizeStem, the label carries the source name verbatim. Empty
// means "the stem base is also the label" — what a caller that names nothing else gets.
std::string displayName;
// The one home for that fallback rule; both backends populate Sample::displayName
// from here rather than each spelling the condition out.
std::string label() const { return displayName.empty() ? baseName : displayName; }
};
// Every failure is an explicit code, never a thrown exception across the REAPER boundary.
@@ -100,6 +110,14 @@ public:
// backend's family marker ("" offline, "rt-" realtime).
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
// (echoed from the request), resolved sampleRate (request rate, else PROJECT_SRATE
// from `rateProj`), captureTempo, the capture-start time signature
+15 -7
View File
@@ -54,9 +54,9 @@ namespace reasampler::capture {
// captureAndIndexOne so every precision invariant holds; nothing lands in the
// arrange (load-bearing principle).
//
// Each unit's baseName carries its ordinal ("item-1", "item-2", ...) so two units
// in one batch never share a stem, and makeUniqueTag's per-session monotonic
// counter keeps same-second units across batches from colliding too.
// Each unit is named after its own source track and carries its batch ordinal, so two
// units in one batch read apart even when they came off the same track; makeUniqueTag's
// per-session monotonic counter keeps same-second units across batches from colliding.
namespace {
@@ -219,12 +219,14 @@ void RunBatchCaptureItems(ReaSamplerSession& session)
// selected-items render source prints exactly it — batch keeps the
// one-sample-per-item-at-item-extent semantics, unchanged.
src.itemExtentIsWindow = true;
src.trackNames.push_back(trackName(u.track));
if (std::string g = guidString(u.track); !g.empty())
src.trackGuids.push_back(std::move(g));
const std::string baseName = "item-" + std::to_string(unit.ordinal);
const CaptureName name =
captureNameFor(src.trackNames, unit.ordinal, "item");
CaptureResult res = captureAndIndexOne(
session, CaptureScope::Item, src, baseName,
session, CaptureScope::Item, src, name,
unit.startSeconds, unit.endSeconds);
const bool ok = (res.status == CaptureStatus::Ok);
@@ -283,12 +285,14 @@ void RunBatchCaptureRazor(ReaSamplerSession& session)
src.startSeconds = unit.startSeconds;
src.endSeconds = unit.endSeconds;
src.sourceTracks.push_back(tr);
src.trackNames.push_back(trackName(tr));
if (std::string g = guidString(tr); !g.empty())
src.trackGuids.push_back(std::move(g));
const std::string baseName = "razor-" + std::to_string(unit.ordinal);
const CaptureName name =
captureNameFor(src.trackNames, unit.ordinal, "razor");
CaptureResult res = captureAndIndexOne(
session, CaptureScope::Track, src, baseName,
session, CaptureScope::Track, src, name,
unit.startSeconds, unit.endSeconds);
const bool ok = (res.status == CaptureStatus::Ok);
@@ -397,6 +401,10 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
req.sampleRate = recipe->sampleRate;
req.channelCount = recipe->channelCount;
req.bitDepth = WavBitDepth::Float32;
// orig->displayName is the ORIGINAL capture's label (recapture preserves identity, it
// does not re-mint it — see this function's header comment), so the regenerated file's
// stem carries the original capture's stamp, not this render's — do not read it as a
// render timestamp.
req.baseName = orig->displayName.empty() ? "recapture" : orig->displayName;
req.trackGuids = recipe->trackGuids;
+13 -4
View File
@@ -243,7 +243,7 @@ CaptureResult renderOffline(CaptureScope scope,
CaptureResult captureAndIndexOne(ReaSamplerSession& session,
CaptureScope scope,
const ResolvedSource& src,
const std::string& baseName,
const CaptureName& name,
double startSeconds,
double endSeconds)
{
@@ -262,7 +262,8 @@ CaptureResult captureAndIndexOne(ReaSamplerSession& session,
req.sampleRate = 0; // follow project rate
req.channelCount = 2;
req.bitDepth = WavBitDepth::Float32; // deterministic, no dither
req.baseName = baseName;
req.baseName = name.stemBase;
req.displayName = name.label;
req.trackGuids = src.trackGuids; // recorded on the Sample (provenance)
// M10: compute provenance BEFORE the FxBypassGuard neutralizes the in-scope chain —
@@ -320,7 +321,12 @@ std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def)
return {};
}
CaptureResult res = captureAndIndexOne(session, def.scope, src, def.baseName,
// The scope literal survives only as the fallback for a source whose name could not
// be read at all — the source track names the capture on every reachable path.
const CaptureName name =
captureNameFor(src.trackNames, /*ordinal=*/0, def.baseName);
CaptureResult res = captureAndIndexOne(session, def.scope, src, name,
src.startSeconds, src.endSeconds);
if (res.status != CaptureStatus::Ok)
{
@@ -438,7 +444,10 @@ void RunCaptureRealtimeTrack(ReaSamplerSession& session)
req.sampleRate = 0; // follow project rate
req.channelCount = 2;
req.bitDepth = WavBitDepth::Float32;
req.baseName = "realtime";
const CaptureName name =
captureNameFor(src.trackNames, /*ordinal=*/0, "realtime");
req.baseName = name.stemBase;
req.displayName = name.label;
req.trackGuids = src.trackGuids; // provenance on the Sample
CaptureResult failure;
+1 -1
View File
@@ -40,7 +40,7 @@ CaptureResult renderOffline(CaptureScope scope,
CaptureResult captureAndIndexOne(ReaSamplerSession& session,
CaptureScope scope,
const ResolvedSource& src,
const std::string& baseName,
const CaptureName& name,
double startSeconds,
double endSeconds);
@@ -192,7 +192,7 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
cap.startSeconds = request.startSeconds;
cap.endSeconds = request.endSeconds;
cap.wetDry = request.wetDry;
cap.displayName = request.baseName;
cap.displayName = request.label();
cap.trackGuids = request.trackGuids;
cap.channelCount = request.channelCount;
+16
View File
@@ -7,6 +7,7 @@
#include "shell/capture/scope_resolve.h"
#include <cstring> // strnlen — bounded read of GetTrackName's buffer
#include <filesystem> // project-dir derivation for provenance parent resolution
#include <utility>
@@ -27,6 +28,7 @@
#define REAPERAPI_WANT_GetMediaItemInfo_Value
#define REAPERAPI_WANT_CountSelectedTracks
#define REAPERAPI_WANT_GetSelectedTrack
#define REAPERAPI_WANT_GetTrackName
#include "reaper_plugin_functions.h"
namespace reasampler::capture {
@@ -81,6 +83,7 @@ bool collectSelectedItemTracks(ResolvedSource& out,
for (MediaTrack* t : out.sourceTracks) if (t == tr) { seen = true; break; }
if (seen) continue;
out.sourceTracks.push_back(tr);
out.trackNames.push_back(trackName(tr));
std::string g = guidString(tr);
if (!g.empty()) out.trackGuids.push_back(std::move(g));
}
@@ -101,6 +104,18 @@ int projectSampleRate()
} // namespace
std::string trackName(MediaTrack* tr)
{
if (!tr) return {};
// Track names are user-typed and unbounded; 1 KB is far past any real one, and this
// runs once per capture, not per frame.
std::vector<char> buf(1024, '\0');
if (!GetTrackName(tr, buf.data(), static_cast<int>(buf.size()))) return {};
// Bounded construction: the SDK doesn't document NUL-termination within bufOut_sz, so
// strnlen over the whole buffer (never past it) rather than trusting one.
return std::string(buf.data(), strnlen(buf.data(), buf.size()));
}
// Reads every track's P_RAZOREDITS (SDK header ~2899: space-separated triples of
// start, end, envGuidString) and returns the union of parsed track-audio areas.
// Reads only — never clears the razor selection.
@@ -148,6 +163,7 @@ bool collectSelectedTracks(ResolvedSource& out)
MediaTrack* tr = GetSelectedTrack(nullptr, i);
if (!tr) continue;
out.sourceTracks.push_back(tr);
out.trackNames.push_back(trackName(tr));
std::string g = guidString(tr);
if (!g.empty()) out.trackGuids.push_back(std::move(g));
}
+16 -1
View File
@@ -35,6 +35,11 @@ struct ResolvedSource
std::vector<MediaTrack*> sourceTracks;
std::vector<std::string> trackGuids;
// Parallel to sourceTracks (one entry per track, in the same order) — the names the
// capture is labeled and filed after. trackGuids is NOT parallel: an unreadable GUID
// is dropped there, while an unreadable name still holds its track's slot.
std::vector<std::string> trackNames;
// Item scope only: does the selected items' own extent already print
// [startSeconds, endSeconds)? Feeds sourceModeForScope. Defaults false so a
// hand-built source fails closed to the time-bounded render — a caller whose
@@ -50,9 +55,19 @@ bool resolveRazorRange(double& start, double& end);
// selection. Returns false with a reason when neither yields a non-empty range.
bool resolveRange(double& start, double& end, std::string& why);
// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs.
// Collects the selected tracks (Track scope) into out.sourceTracks + GUIDs + names.
bool collectSelectedTracks(ResolvedSource& out);
// The track's display name. GetTrackName (SDK header ~3626) is used rather than P_NAME
// because it already answers REAPER's own convention for an unnamed track ("Track N"),
// which is exactly the deterministic fallback a capture label wants; P_NAME would hand
// back an empty string instead. `[verify]` the SDK header states neither that the read is
// non-mutating nor what a `false` return means; the call site treats it as read-only and
// treats `false` (or a `true` with an untouched buffer) the same way — an empty name, which
// falls through to the scope literal fallback either way, so both readings are safe.
// Callers that build a ResolvedSource by hand (batch capture) use this directly.
std::string trackName(MediaTrack* tr);
// Resolves the source for a scope: the selection tracks (item/track), plus the
// inferred range. Returns false with a reason on nothing to do.
bool ResolveScopeSource(CaptureScope scope, ResolvedSource& out, std::string& why);
+25 -6
View File
@@ -30,17 +30,33 @@ void drawCardMeta(LICE_IBitmap* bmp, const CellRect& rect, const Sample& s) {
const std::string bars = formatBarsBeats(ml);
const std::string secs = formatSecondsMs(s.lengthSeconds);
const int stripH = 12;
const int pad = 3;
const int y = rect.y + rect.height - stripH;
const int y = rect.y + rect.height - kCardStripHeight;
if (!bars.empty()) {
const KitBox left{rect.x + pad, y, rect.width / 2 - pad, stripH};
const KitBox left{rect.x + kCardStripPad, y,
rect.width / 2 - kCardStripPad, kCardStripHeight};
text(bmp, left, bars.c_str(), Font::Micro, Role::TextDim, Align::Left);
}
const KitBox right{rect.x + rect.width / 2, y, rect.width / 2 - pad, stripH};
const KitBox right{rect.x + rect.width / 2, y,
rect.width / 2 - kCardStripPad, kCardStripHeight};
text(bmp, right, secs.c_str(), Font::ValueMono, Role::TextDim, Align::Right);
}
// The capture's name across the top of the card. The kit clips with an end-ellipsis, so a
// long name shortens ON SCREEN only — the stored label is never truncated. An entry with an
// empty label (old banks predate this track and are not migrated) draws nothing.
void drawCardName(LICE_IBitmap* bmp, const CellRect& rect, const Sample& s) {
if (s.displayName.empty()) return;
const CellRect strip = cardNameStrip(rect);
if (strip.empty()) return;
// Scrim behind the name: text/primary alone reads ~1:1 against the accent-lime waveform
// fill a loud capture's peak reaches into this strip. kCardNameScrimAlpha (core/ui/theme.h)
// is picked so bg/base composited at that alpha over accent/primary clears the 4.5:1 body
// floor Font::Micro answers to — pinned in test_theme.cpp.
LICE_FillRect(bmp, strip.x, strip.y, strip.width, strip.height,
toLice(roleColor(Role::BgBase)), static_cast<float>(kCardNameScrimAlpha), 0);
text(bmp, strip, s.displayName.c_str(), Font::Micro, Role::TextPrimary, Align::Left);
}
void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env,
bool selected, bool focused, bool hovered, const Sample* sample) {
// Selected cards draw the normal cell surface, not an inverted fill — selection
@@ -59,7 +75,10 @@ void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env,
drawWaveform(bmp, cell, env);
if (sample) drawCardMeta(bmp, rect, *sample);
if (sample) {
drawCardName(bmp, rect, *sample);
drawCardMeta(bmp, rect, *sample);
}
}
KitBox toKitBox(const RECT& r) {
+4
View File
@@ -109,6 +109,7 @@ using ui::TooltipBox;
using ui::TooltipSpec;
using ui::applyClick;
using ui::assemblePathList;
using ui::cardNameStrip;
using ui::clampTabScroll;
using ui::columnsForWidth;
using ui::computeBarSlots;
@@ -132,6 +133,9 @@ using ui::hitTestMenuButton;
using ui::hitTestPruneButton;
using ui::hitTestSlot;
using ui::hitTestTabStrip;
using ui::kCardNameScrimAlpha;
using ui::kCardStripHeight;
using ui::kCardStripPad;
using ui::menuButtonReserve;
using ui::modeSegmentEnabled;
using ui::navigate;
+31
View File
@@ -489,6 +489,36 @@ static void testLegacyJsonDefaults() {
}
}
// A bank written before captures were named after their source track carries the literal
// "item"/"track" label. Nothing migrates or relabels it: the label must survive load and
// re-serialize byte-identically, so an old bank reads exactly as it did.
static void testPreNamingSchemeLabelsSurviveUntouched() {
const char* old =
"{\"version\":1,\"samples\":["
"{\"id\":\"cap-1700000000-1-item_1700000000-1.wav\","
"\"displayName\":\"item\",\"relativePath\":\"reasampler_bank/item_1700000000-1.wav\","
"\"contentHash\":\"h-old-a\"},"
"{\"id\":\"cap-1700000000-2-track_1700000000-2.wav\","
"\"displayName\":\"track\",\"relativePath\":\"reasampler_bank/track_1700000000-2.wav\","
"\"contentHash\":\"h-old-b\"}]}";
auto r = BankModel::deserialize(old);
CHECK(r.has_value());
if (!r) return;
const Sample* a = r->query("cap-1700000000-1-item_1700000000-1.wav");
const Sample* b = r->query("cap-1700000000-2-track_1700000000-2.wav");
CHECK(a && a->displayName == "item");
CHECK(b && b->displayName == "track");
auto again = BankModel::deserialize(r->serialize());
CHECK(again.has_value());
CHECK(again && *again == *r);
if (again) {
const Sample* a2 = again->query("cap-1700000000-1-item_1700000000-1.wav");
CHECK(a2 && a2->displayName == "item");
}
}
// S2 test case 4: boundary values for the seam fields are representable and
// round-trip. rootNote 0 and 127 (the MIDI edges); loopStart == loopEnd (a valid
// zero-length marker); a loop whose end sits at the file's last frame. Also asserts
@@ -588,6 +618,7 @@ int main() {
testIntegerOverflow();
testEnumRangeValidation();
testLegacyJsonDefaults();
testPreNamingSchemeLabelsSurviveUntouched();
testSeamFieldBoundaries();
testSeamFieldsAdditiveInvariant();
+274
View File
@@ -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;
}
+44
View File
@@ -109,7 +109,51 @@ static void testSecondsMsNearWholeSecondCarry() {
CHECK(r == "0.999" || r == "1.000");
}
// --- the card's name strip ----------------------------------------------------
// The shipping cell size (panel_state.h's kGrid).
static const Rect kCell{40, 100, 140, 84};
static void testNameStripSitsAcrossTheTopOfTheCell() {
const Rect s = cardNameStrip(kCell);
CHECK(s.x == kCell.x + kCardStripPad);
CHECK(s.y == kCell.y + 1); // clear of the selection border
CHECK(s.width == kCell.width - 2 * kCardStripPad);
CHECK(s.height == kCardStripHeight);
}
static void testNameStripNeverOverlapsTheLengthReadOut() {
// The read-out occupies the bottom kCardStripHeight of the same cell.
const Rect s = cardNameStrip(kCell);
CHECK(s.bottom() <= kCell.bottom() - kCardStripHeight);
}
static void testNameStripStaysInsideTheCell() {
const Rect s = cardNameStrip(kCell);
CHECK(s.x >= kCell.x);
CHECK(s.right() <= kCell.right());
CHECK(s.y >= kCell.y);
CHECK(s.bottom() <= kCell.bottom());
}
static void testNameStripSuppressedOnATooShortCell() {
// Below three strip-heights the card would be text with a sliver of waveform — draw
// no name rather than bury the thumbnail.
CHECK(cardNameStrip(Rect{0, 0, 140, 3 * kCardStripHeight - 1}).empty());
CHECK(!cardNameStrip(Rect{0, 0, 140, 3 * kCardStripHeight}).empty());
}
static void testNameStripSuppressedOnATooNarrowCell() {
CHECK(cardNameStrip(Rect{0, 0, 2 * kCardStripPad, 84}).empty());
CHECK(cardNameStrip(Rect{0, 0, 0, 84}).empty());
}
int main() {
testNameStripSitsAcrossTheTopOfTheCell();
testNameStripNeverOverlapsTheLengthReadOut();
testNameStripStaysInsideTheCell();
testNameStripSuppressedOnATooShortCell();
testNameStripSuppressedOnATooNarrowCell();
testZeroLengthIsBarOneOrigin();
testSubBeat();
testWholeBeatWithinBar();
+20
View File
@@ -250,6 +250,25 @@ static void testTextOnHoverSurfaceClearsFloor() {
CHECK(contrastRatio(roleColor(Role::TextDim), hover) >= textFloor(TextClass::Large));
}
// The card name strip (panel_render.cpp's drawCardName) draws text/primary Micro (10px, BODY
// class) over a bg/base scrim at kCardNameScrimAlpha, itself drawn over whatever the waveform
// left in that rect. Enumerate both backgrounds a loud/quiet capture can leave there: the
// accent-lime fill (a full-scale peak reaching the strip) and bare bg/cell (a quiet capture,
// nothing painted that high). Unscrimmed, text/primary reads ~1:1 against the fill — this is
// the load-bearing check that the scrim actually fixes it.
static void testCardNameScrimClearsBodyFloorOnItsWorstBackground() {
const KitColor scrim = roleColor(Role::BgBase);
const KitColor onFill = compositeOver(scrim, roleColor(Role::AccentPrimary),
kCardNameScrimAlpha);
const KitColor onCell = compositeOver(scrim, roleColor(Role::BgCell), kCardNameScrimAlpha);
CHECK(contrastRatio(roleColor(Role::TextPrimary), onFill) >= textFloor(TextClass::Body));
CHECK(contrastRatio(roleColor(Role::TextPrimary), onCell) >= textFloor(TextClass::Body));
// Without the scrim, text/primary on the bare accent-lime fill is UNDER floor — pins the
// defect the scrim exists to close, so a future removal of the scrim fails this first.
CHECK(contrastRatio(roleColor(Role::TextPrimary), roleColor(Role::AccentPrimary))
< textFloor(TextClass::Body));
}
// --- Single point of change (structural guarantee) ----------------------------
//
// roleColor is the ONLY color source; there is no other public accessor that yields a
@@ -346,6 +365,7 @@ int main() {
testRegionTitleAccentsClearLargeFloorOnPanel();
testTextOnPastelFillClearsBodyFloor();
testTextOnHoverSurfaceClearsFloor();
testCardNameScrimClearsBodyFloorOnItsWorstBackground();
testCurveTraceOnHoverSurfaceClearsFloor();
testSecondaryTertiaryAreDistinguishable();
testOverlayTraceClearsIndicatorFloorOnTheWaveform();