From 3278b4eced685d78c9e5d415097e915c5690d4f0 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 21:46:53 -0400 Subject: [PATCH] Name captures after their source track: label and filename both, on every interactive mint site, and show the name on the panel card --- src/app/CMakeLists.txt | 2 +- src/core/capture/CLAUDE.md | 1 + src/core/capture/CMakeLists.txt | 5 + src/core/capture/capture_name.cpp | 85 ++++++ src/core/capture/capture_name.h | 62 +++++ src/core/capture/render_settings.h | 2 +- src/core/ui/CLAUDE.md | 2 +- src/core/ui/card_meta.cpp | 10 + src/core/ui/card_meta.h | 13 + src/shell/capture/CLAUDE.md | 2 +- src/shell/capture/capture.cpp | 28 +- src/shell/capture/capture.h | 18 ++ src/shell/capture/capture_batch.cpp | 18 +- src/shell/capture/capture_orchestrator.cpp | 17 +- src/shell/capture/capture_orchestrator.h | 2 +- .../capture/capture_realtime_finalize.cpp | 2 +- src/shell/capture/scope_resolve.cpp | 13 + src/shell/capture/scope_resolve.h | 14 +- src/shell/panel/panel_render.cpp | 25 +- src/shell/panel/panel_state.h | 3 + tests/test_bank_model.cpp | 31 +++ tests/test_capture_name.cpp | 252 ++++++++++++++++++ tests/test_card_meta.cpp | 44 +++ 23 files changed, 626 insertions(+), 25 deletions(-) create mode 100644 src/core/capture/capture_name.cpp create mode 100644 src/core/capture/capture_name.h create mode 100644 tests/test_capture_name.cpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 498c798..5bbc5bb 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -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. diff --git a/src/core/capture/CLAUDE.md b/src/core/capture/CLAUDE.md index c740414..5c7afb9 100644 --- a/src/core/capture/CLAUDE.md +++ b/src/core/capture/CLAUDE.md @@ -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. diff --git a/src/core/capture/CMakeLists.txt b/src/core/capture/CMakeLists.txt index 57fba28..ee52a52 100644 --- a/src/core/capture/CMakeLists.txt +++ b/src/core/capture/CMakeLists.txt @@ -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) diff --git a/src/core/capture/capture_name.cpp b/src/core/capture/capture_name.cpp new file mode 100644 index 0000000..f35939b --- /dev/null +++ b/src/core/capture/capture_name.cpp @@ -0,0 +1,85 @@ +// capture_name — pure implementation. See the header. + +#include "core/capture/capture_name.h" + +#include + +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(s[b]))) ++b; + while (e > b && isSpace(static_cast(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(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); + + 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 diff --git a/src/core/capture/capture_name.h b/src/core/capture/capture_name.h new file mode 100644 index 0000000..803ac60 --- /dev/null +++ b/src/core/capture/capture_name.h @@ -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 +#include +#include + +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 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 diff --git a/src/core/capture/render_settings.h b/src/core/capture/render_settings.h index 4de5bcf..c871a54 100644 --- a/src/core/capture/render_settings.h +++ b/src/core/capture/render_settings.h @@ -199,7 +199,7 @@ RazorRange razorUnionBounds(const std::vector& 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) }; diff --git a/src/core/ui/CLAUDE.md b/src/core/ui/CLAUDE.md index 876550d..ac439e6 100644 --- a/src/core/ui/CLAUDE.md +++ b/src/core/ui/CLAUDE.md @@ -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²). diff --git a/src/core/ui/card_meta.cpp b/src/core/ui/card_meta.cpp index 12b0176..8d42d5e 100644 --- a/src/core/ui/card_meta.cpp +++ b/src/core/ui/card_meta.cpp @@ -7,6 +7,16 @@ 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 selection border. + 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 {}; diff --git a/src/core/ui/card_meta.h b/src/core/ui/card_meta.h index c314690..7bb0fcc 100644 --- a/src/core/ui/card_meta.h +++ b/src/core/ui/card_meta.h @@ -5,8 +5,21 @@ #include +#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. 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 { diff --git a/src/shell/capture/CLAUDE.md b/src/shell/capture/CLAUDE.md index 2cf1287..55bcce0 100644 --- a/src/shell/capture/CLAUDE.md +++ b/src/shell/capture/CLAUDE.md @@ -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). diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index ca79649..432064b 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -201,6 +201,32 @@ std::string makeUniqueTag(const std::string& prefix) { std::to_string(++counter); } +CaptureName captureNameFor(const std::vector& 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; diff --git a/src/shell/capture/capture.h b/src/shell/capture/capture.h index 4ef62a1..33fced7 100644 --- a/src/shell/capture/capture.h +++ b/src/shell/capture/capture.h @@ -10,6 +10,7 @@ #include #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& 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 diff --git a/src/shell/capture/capture_batch.cpp b/src/shell/capture/capture_batch.cpp index 954dddd..070c984 100644 --- a/src/shell/capture/capture_batch.cpp +++ b/src/shell/capture/capture_batch.cpp @@ -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); diff --git a/src/shell/capture/capture_orchestrator.cpp b/src/shell/capture/capture_orchestrator.cpp index 91cc47c..e12450e 100644 --- a/src/shell/capture/capture_orchestrator.cpp +++ b/src/shell/capture/capture_orchestrator.cpp @@ -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; diff --git a/src/shell/capture/capture_orchestrator.h b/src/shell/capture/capture_orchestrator.h index f03b7bc..94d250b 100644 --- a/src/shell/capture/capture_orchestrator.h +++ b/src/shell/capture/capture_orchestrator.h @@ -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); diff --git a/src/shell/capture/capture_realtime_finalize.cpp b/src/shell/capture/capture_realtime_finalize.cpp index 7552ab0..ce8ef94 100644 --- a/src/shell/capture/capture_realtime_finalize.cpp +++ b/src/shell/capture/capture_realtime_finalize.cpp @@ -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; diff --git a/src/shell/capture/scope_resolve.cpp b/src/shell/capture/scope_resolve.cpp index d68bac0..e6930e4 100644 --- a/src/shell/capture/scope_resolve.cpp +++ b/src/shell/capture/scope_resolve.cpp @@ -27,6 +27,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 +82,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 +103,16 @@ 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 buf(1024, '\0'); + if (!GetTrackName(tr, buf.data(), static_cast(buf.size()))) return {}; + return std::string(buf.data()); +} + // 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 +160,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)); } diff --git a/src/shell/capture/scope_resolve.h b/src/shell/capture/scope_resolve.h index b0b42b4..5adf0ba 100644 --- a/src/shell/capture/scope_resolve.h +++ b/src/shell/capture/scope_resolve.h @@ -35,6 +35,11 @@ struct ResolvedSource std::vector sourceTracks; std::vector 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 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,16 @@ 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, read-only. 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. Empty only if the read itself fails. +// 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); diff --git a/src/shell/panel/panel_render.cpp b/src/shell/panel/panel_render.cpp index cd6be8f..b197284 100644 --- a/src/shell/panel/panel_render.cpp +++ b/src/shell/panel/panel_render.cpp @@ -30,17 +30,27 @@ 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 +// no label (nothing writes one today, but old banks 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; + 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 +69,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) { diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h index 8f9ef02..999f166 100644 --- a/src/shell/panel/panel_state.h +++ b/src/shell/panel/panel_state.h @@ -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,8 @@ using ui::hitTestMenuButton; using ui::hitTestPruneButton; using ui::hitTestSlot; using ui::hitTestTabStrip; +using ui::kCardStripHeight; +using ui::kCardStripPad; using ui::menuButtonReserve; using ui::modeSegmentEnabled; using ui::navigate; diff --git a/tests/test_bank_model.cpp b/tests/test_bank_model.cpp index 299aef0..d810cf3 100644 --- a/tests/test_bank_model.cpp +++ b/tests/test_bank_model.cpp @@ -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(); diff --git a/tests/test_capture_name.cpp b/tests/test_capture_name.cpp new file mode 100644 index 0000000..9f8ec5a --- /dev/null +++ b/tests/test_capture_name.cpp @@ -0,0 +1,252 @@ +// 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 +#include +#include +#include + +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 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 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 names = { + "Bass", "", "***", kana, std::string(400, 'x'), "Lead Vox", "Track 3", + "a/b\\c:d*e?f\"gi|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(); + 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; +} diff --git a/tests/test_card_meta.cpp b/tests/test_card_meta.cpp index b94974b..02c5a8d 100644 --- a/tests/test_card_meta.cpp +++ b/tests/test_card_meta.cpp @@ -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();