Name captures after their source track: label and filename both, on every interactive mint site, and show the name on the panel card

This commit is contained in:
2026-08-01 21:46:53 -04:00
parent 09d64c9f46
commit 3278b4eced
23 changed files with 626 additions and 25 deletions
+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
+11 -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);
+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;
+13
View File
@@ -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<char> buf(1024, '\0');
if (!GetTrackName(tr, buf.data(), static_cast<int>(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));
}
+13 -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,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);
+19 -6
View File
@@ -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) {
+3
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,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;