Ψ-W2-T2: collapse a capture whose channels are bit-identical to one lossless mono channel, index value measured off the landed file
This commit is contained in:
@@ -206,7 +206,7 @@ Plan-style docs live under `docs/`:
|
|||||||
- **Null test:** a dry offline capture of a range, re-inserted at its source position, nulls to silence against the source — the tool's trust anchor. Ship as a verification action. (Verification action cut per `docs/product/provenance.md` — manual verification only.)
|
- **Null test:** a dry offline capture of a range, re-inserted at its source position, nulls to silence against the source — the tool's trust anchor. Ship as a verification action. (Verification action cut per `docs/product/provenance.md` — manual verification only.)
|
||||||
- **Bit-identical repeats:** identical offline capture requests produce identical files.
|
- **Bit-identical repeats:** identical offline capture requests produce identical files.
|
||||||
- **Non-destructive:** capture never mutates source items or tracks; the realtime backend's temp track is created and removed cleanly, and source routing is restored.
|
- **Non-destructive:** capture never mutates source items or tracks; the realtime backend's temp track is created and removed cleanly, and source routing is restored.
|
||||||
- **Exact bounds:** no rounding of the requested range; no added silence unless a tail is explicitly requested; channel count preserved (no silent stereo fold).
|
- **Exact bounds:** no rounding of the requested range; no added silence unless a tail is explicitly requested; **no lossy channel fold** — summing or averaging differing channels is forbidden. The one permitted collapse is lossless: a new capture whose channels are bit-identical per frame (float bit patterns, never an epsilon) lands as a 1-channel file, with `Sample::channelCount` and the file's `fmt` written together so the two can never disagree. Frame count, sample rate and bit depth are untouched by it. Never retroactive — existing entries and files are never rewritten — and ingest is excluded, because an imported file is the user's bytes, not our capture. The superseded wording ("channel count preserved") was already untrue in the other direction: a mono source renders at `RENDER_CHANNELS = 2`.
|
||||||
- **Relative paths only** in the persisted `BankIndex`.
|
- **Relative paths only** in the persisted `BankIndex`.
|
||||||
- **Capture FX scope:** two scopes only — item = item/take FX only; track = item FX + the selected track's own track FX. There is no master scope (to capture the master, render a track instead). For both scopes, the out-of-scope chain (ancestors + master track, plus the item's own track for item scope) has its FX, gain, and pan/width/pan-law/mode neutralized to unity — the master track is bypassed as out-of-scope chain, not captured as a scope. Range (time selection or razor) is orthogonal.
|
- **Capture FX scope:** two scopes only — item = item/take FX only; track = item FX + the selected track's own track FX. There is no master scope (to capture the master, render a track instead). For both scopes, the out-of-scope chain (ancestors + master track, plus the item's own track for item scope) has its FX, gain, and pan/width/pan-law/mode neutralized to unity — the master track is bypassed as out-of-scope chain, not captured as a scope. Range (time selection or razor) is orthogonal.
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ Detail specific to these pure modules:
|
|||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
- `wav_codec` — chunk walker + layout parse + float32 build + size-field patch + content hashes; the single pure RIFF/WAV owner (`wav_trim` is retired; `wav_codec` is the sole owner).
|
- `wav_codec` — chunk walker + layout parse + float32 build + size-field patch + the lossless mono collapse + content hashes; the single pure RIFF/WAV owner (`wav_trim` is retired; `wav_codec` is the sole owner).
|
||||||
- `capture_realtime` (`core/capture`, **renamed from `realtime_record` in Q-W3** — the Q-9 naming rider: pure module takes the stem, the shell takes the suffix, matching `drag_out`/`drag_out_win`) — the M8 realtime-record pure logic: capture scope + FX-tap point → `I_RECMODE`/`I_RECMODE_FLAGS` values, wet/dry → tap point, the recorded-file → `Sample` mapping, and the async record-phase state machine. Depends on `bank_model` for the plain `Sample`/`SourceMode` types. The transport/temp-track/send recipe lives in the shell (`shell/capture/capture_realtime_shell.cpp` + `capture_realtime_finalize.cpp`).
|
- `capture_realtime` (`core/capture`, **renamed from `realtime_record` in Q-W3** — the Q-9 naming rider: pure module takes the stem, the shell takes the suffix, matching `drag_out`/`drag_out_win`) — the M8 realtime-record pure logic: capture scope + FX-tap point → `I_RECMODE`/`I_RECMODE_FLAGS` values, wet/dry → tap point, the recorded-file → `Sample` mapping, and the async record-phase state machine. Depends on `bank_model` for the plain `Sample`/`SourceMode` types. The transport/temp-track/send recipe lives in the shell (`shell/capture/capture_realtime_shell.cpp` + `capture_realtime_finalize.cpp`).
|
||||||
- `batch_capture` — pure batch-capture planner: maps source ranges to capture units and aggregates results.
|
- `batch_capture` — pure batch-capture planner: maps source ranges to capture units and aggregates results.
|
||||||
- `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition` → `NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives.
|
- `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition` → `NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives.
|
||||||
@@ -81,6 +81,14 @@ Detail specific to these pure modules:
|
|||||||
- `kRenderPreFaderStems` (&8192) is deliberately **not** used — REAPER offline
|
- `kRenderPreFaderStems` (&8192) is deliberately **not** used — REAPER offline
|
||||||
render has no true pre-FX "dry" bit; FX scoping is done entirely by the
|
render has no true pre-FX "dry" bit; FX scoping is done entirely by the
|
||||||
FX-bypass-around-render mechanism, never by a render bit.
|
FX-bypass-around-render mechanism, never by a render bit.
|
||||||
|
- **The mono collapse changes a capture's content identity, by design.**
|
||||||
|
`hashWavContent` covers the `fmt ` body plus the `data` payload, and the collapse
|
||||||
|
rewrites both — so a collapsed capture does NOT hash-dedup against a stereo twin of
|
||||||
|
the same audio already in the bank. Accepted: the predicate is deterministic over
|
||||||
|
deterministic bytes, so repeats of the same request still dedup against each other,
|
||||||
|
which is what the bit-identical-repeats invariant actually asks for. Do not "fix"
|
||||||
|
this by hashing pre-collapse — that would make two entries with different audio
|
||||||
|
layouts share one identity.
|
||||||
- `tail_control`'s `kDefaultManualTailMs`/`kManualStepMs` and
|
- `tail_control`'s `kDefaultManualTailMs`/`kManualStepMs` and
|
||||||
`render_settings`'s `kMaxTailMs`/`kAutoTrimThresholdDb` are separate constants
|
`render_settings`'s `kMaxTailMs`/`kAutoTrimThresholdDb` are separate constants
|
||||||
in separate files by design (panel-facing default/step vs. runaway-guard cap)
|
in separate files by design (panel-facing default/step vs. runaway-guard cap)
|
||||||
|
|||||||
@@ -257,6 +257,44 @@ std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MonoCollapse collapseToMono(const std::vector<std::uint8_t>& bytes) {
|
||||||
|
MonoCollapse out;
|
||||||
|
|
||||||
|
const WavLayout layout = parseWavLayout(bytes);
|
||||||
|
if (!layout.valid || layout.channelCount < 2) return out;
|
||||||
|
|
||||||
|
const std::size_t frames = layout.frameCount();
|
||||||
|
if (frames == 0) return out;
|
||||||
|
|
||||||
|
const std::size_t stride = layout.channelCount;
|
||||||
|
const std::vector<AudioSample> pcm = extractFloatFrames(bytes, layout, 0, frames);
|
||||||
|
if (pcm.size() != frames * stride) return out; // short read -> decline, never guess
|
||||||
|
|
||||||
|
// Bit patterns, not values: see the header. memcpy is the only defined float->bits
|
||||||
|
// read, and it compiles to a register move.
|
||||||
|
auto bitsOf = [](AudioSample s) {
|
||||||
|
std::uint32_t bits = 0;
|
||||||
|
std::memcpy(&bits, &s, 4u);
|
||||||
|
return bits;
|
||||||
|
};
|
||||||
|
for (std::size_t f = 0; f < frames; ++f) {
|
||||||
|
const std::uint32_t first = bitsOf(pcm[f * stride]);
|
||||||
|
for (std::size_t c = 1; c < stride; ++c) {
|
||||||
|
if (bitsOf(pcm[f * stride + c]) != first) return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// float -> double -> float round-trips exactly (double represents every float),
|
||||||
|
// so channel 0 reaches the rebuilt file unaltered.
|
||||||
|
std::vector<double> mono(frames);
|
||||||
|
for (std::size_t f = 0; f < frames; ++f)
|
||||||
|
mono[f] = static_cast<double>(pcm[f * stride]);
|
||||||
|
|
||||||
|
out.collapsed = true;
|
||||||
|
out.bytes = buildFloat32Wav(1, layout.sampleRate, frames, mono);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
std::string hashBytes(const std::uint8_t* data, std::size_t len) {
|
std::string hashBytes(const std::uint8_t* data, std::size_t len) {
|
||||||
// FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity.
|
// FNV-1a 64-bit: deterministic, no dependencies, adequate for dedup identity.
|
||||||
std::uint64_t h = kFnvOffsetBasis;
|
std::uint64_t h = kFnvOffsetBasis;
|
||||||
|
|||||||
@@ -90,6 +90,32 @@ std::vector<std::uint8_t> buildFloat32Wav(int nch, std::uint32_t rate,
|
|||||||
std::size_t frameCount,
|
std::size_t frameCount,
|
||||||
const std::vector<double>& interleaved);
|
const std::vector<double>& interleaved);
|
||||||
|
|
||||||
|
// --- Lossless mono collapse ---------------------------------------------------
|
||||||
|
|
||||||
|
// The outcome of the bit-identical mono collapse. `collapsed == false` means the
|
||||||
|
// caller must leave the source file exactly as it is — it writes nothing.
|
||||||
|
struct MonoCollapse {
|
||||||
|
bool collapsed = false;
|
||||||
|
std::vector<std::uint8_t> bytes; // the rebuilt 1-channel WAV; empty unless collapsed
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collapses a multi-channel float32 WAV to one channel when EVERY channel of EVERY
|
||||||
|
// frame carries the identical float BIT PATTERN. Bit equality, never an epsilon and
|
||||||
|
// never `==` on floats: +0.0/-0.0 and two NaNs with differing payloads are NOT
|
||||||
|
// identical and are never folded. Frame count, sample rate and bit depth are
|
||||||
|
// preserved — only the interleave stride changes — so the collapse cannot lose
|
||||||
|
// information, and a lossy downmix (summing differing channels) is not something
|
||||||
|
// this can express.
|
||||||
|
//
|
||||||
|
// Declines for: bytes that do not parse; a file already at one channel; a zero-frame
|
||||||
|
// file (no frame of evidence to act on); any differing channel pair.
|
||||||
|
//
|
||||||
|
// The rebuild is a canonical minimal WAV, so non-audio chunks (a renderer's `bext`
|
||||||
|
// timestamp, iXML, LIST) do not survive it. That much hashWavContent already skips —
|
||||||
|
// but the collapse rewrites the `fmt ` body and the `data` payload too, which moves
|
||||||
|
// the file's content identity; see this directory's CLAUDE.md for what that costs.
|
||||||
|
MonoCollapse collapseToMono(const std::vector<std::uint8_t>& bytes);
|
||||||
|
|
||||||
// --- Content identity (dedup hashes) -----------------------------------------
|
// --- Content identity (dedup hashes) -----------------------------------------
|
||||||
|
|
||||||
// Deterministic FNV-1a 64-bit content hash over `len` bytes, as 16-char lowercase
|
// Deterministic FNV-1a 64-bit content hash over `len` bytes, as 16-char lowercase
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ struct Sample {
|
|||||||
|
|
||||||
double wetDry = 1.0; // 1.0 = fully wet, 0.0 = fully dry
|
double wetDry = 1.0; // 1.0 = fully wet, 0.0 = fully dry
|
||||||
|
|
||||||
|
// Channels in the file this entry names — equal to its `fmt ` count by
|
||||||
|
// construction, which is what makes the instrument's mono/stereo read-out and
|
||||||
|
// its waveform lane count agree with the audio. 0 = unknown (pre-field entry).
|
||||||
int channelCount = 0;
|
int channelCount = 0;
|
||||||
int sampleRate = 0;
|
int sampleRate = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ detail not covered there:
|
|||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`.
|
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. It also owns the two file-side steps both backends share: `collapseCapturedFileToMono` (the lossless mono collapse, applied to the landed file) and `stampCaptureSample`, which measures the channel count off that same file so the entry and the audio cannot disagree.
|
||||||
- `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).
|
||||||
- `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged ONLY for that source mode, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`.
|
- `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged ONLY for that source mode, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`.
|
||||||
- `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`).
|
- `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`).
|
||||||
|
|||||||
@@ -201,11 +201,27 @@ std::string makeUniqueTag(const std::string& prefix) {
|
|||||||
std::to_string(++counter);
|
std::to_string(++counter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void collapseCapturedFileToMono(const std::string& absolutePath) {
|
||||||
|
const std::vector<std::uint8_t> bytes = util::readFileBytes(absolutePath);
|
||||||
|
if (bytes.empty()) return;
|
||||||
|
|
||||||
|
const MonoCollapse collapse = collapseToMono(bytes);
|
||||||
|
if (!collapse.collapsed) return;
|
||||||
|
|
||||||
|
// One truncating write — the same shape, and the same accepted mid-write residual,
|
||||||
|
// as the realtime Auto-tail trim (capture_realtime_finalize.cpp).
|
||||||
|
std::ofstream out(absolutePath, std::ios::binary | std::ios::trunc);
|
||||||
|
if (!out) return;
|
||||||
|
out.write(reinterpret_cast<const char*>(collapse.bytes.data()),
|
||||||
|
static_cast<std::streamsize>(collapse.bytes.size()));
|
||||||
|
}
|
||||||
|
|
||||||
void stampCaptureSample(Sample& s, const CaptureRequest& req,
|
void stampCaptureSample(Sample& s, const CaptureRequest& req,
|
||||||
ReaProject* rateProj, ReaProject* timeSigProj,
|
ReaProject* rateProj, ReaProject* timeSigProj,
|
||||||
const std::string& absolutePath) {
|
const std::string& absolutePath) {
|
||||||
// Track GUIDs + channel count: echoed from the request (the caller resolved
|
// Track GUIDs echoed from the request (the caller resolved the selection; the
|
||||||
// the selection; the backends stay source-agnostic).
|
// backends stay source-agnostic). channelCount starts at the request value only
|
||||||
|
// as the fallback for an unparseable file — the produced FILE overrides it below.
|
||||||
s.trackGuids = req.trackGuids;
|
s.trackGuids = req.trackGuids;
|
||||||
s.channelCount = req.channelCount;
|
s.channelCount = req.channelCount;
|
||||||
|
|
||||||
@@ -238,6 +254,10 @@ void stampCaptureSample(Sample& s, const CaptureRequest& req,
|
|||||||
const std::vector<std::uint8_t> fileBytes = util::readFileBytes(absolutePath);
|
const std::vector<std::uint8_t> fileBytes = util::readFileBytes(absolutePath);
|
||||||
if (!fileBytes.empty()) {
|
if (!fileBytes.empty()) {
|
||||||
s.contentHash = hashWavContent(fileBytes);
|
s.contentHash = hashWavContent(fileBytes);
|
||||||
|
// The one authority for the entry's channel count is the file's own `fmt`
|
||||||
|
// — never the render request, which asks for 2 on every capture path.
|
||||||
|
const WavLayout layout = parseWavLayout(fileBytes);
|
||||||
|
if (layout.valid) s.channelCount = static_cast<int>(layout.channelCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,6 +467,14 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lossless mono collapse, deliberately AFTER the bounds gate: the gate measures
|
||||||
|
// REAPER's own render against the requested window, so nothing of ours may sit
|
||||||
|
// between the render and that measurement, and a refusal must delete the
|
||||||
|
// renderer's file rather than one this step had already rewritten. The collapse
|
||||||
|
// preserves the frame count, so the two are order-independent in outcome — only
|
||||||
|
// in what each is measuring.
|
||||||
|
collapseCapturedFileToMono(expectedPath);
|
||||||
|
|
||||||
// Record the request's own bounds (exact) rather than re-measuring the file.
|
// Record the request's own bounds (exact) rather than re-measuring the file.
|
||||||
Sample s;
|
Sample s;
|
||||||
// Same uniqueTag that named the file — calling makeUniqueTag() again could
|
// Same uniqueTag that named the file — calling makeUniqueTag() again could
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ struct CaptureRequest {
|
|||||||
|
|
||||||
// 0 sampleRate => follow project rate.
|
// 0 sampleRate => follow project rate.
|
||||||
int sampleRate = 0;
|
int sampleRate = 0;
|
||||||
|
// What the RENDER is asked for (RENDER_CHANNELS / the realtime record mode), not
|
||||||
|
// what the capture lands as: a dual-mono render is collapsed to 1 channel after
|
||||||
|
// the fact, and the Sample's count comes from the produced file.
|
||||||
int channelCount = 2;
|
int channelCount = 2;
|
||||||
WavBitDepth bitDepth = WavBitDepth::Float32;
|
WavBitDepth bitDepth = WavBitDepth::Float32;
|
||||||
|
|
||||||
@@ -100,8 +103,16 @@ public:
|
|||||||
// backend's family marker ("" offline, "rt-" realtime).
|
// backend's family marker ("" offline, "rt-" realtime).
|
||||||
std::string makeUniqueTag(const std::string& prefix);
|
std::string makeUniqueTag(const std::string& prefix);
|
||||||
|
|
||||||
// Stamps the metadata shared by both backends onto `s`: trackGuids + channelCount
|
// Rewrites a just-captured WAV in place as a 1-channel file when its channels are
|
||||||
// (echoed from the request), resolved sampleRate (request rate, else PROJECT_SRATE
|
// bit-identical (the pure `collapseToMono` decides). Every other file is left
|
||||||
|
// untouched, byte for byte, so the not-collapsed path is exactly what the backend
|
||||||
|
// produced. Must run BEFORE stampCaptureSample, which measures the landed file.
|
||||||
|
void collapseCapturedFileToMono(const std::string& absolutePath);
|
||||||
|
|
||||||
|
// Stamps the metadata shared by both backends onto `s`: trackGuids (echoed from the
|
||||||
|
// request) + channelCount (measured from the produced file's `fmt`; the request
|
||||||
|
// value only as the fallback for a file that cannot be parsed), resolved
|
||||||
|
// sampleRate (request rate, else PROJECT_SRATE
|
||||||
// from `rateProj`), captureTempo, the capture-start time signature
|
// from `rateProj`), captureTempo, the capture-start time signature
|
||||||
// (TimeMap_GetTimeSigAtTime against `timeSigProj` — offline passes nullptr for the
|
// (TimeMap_GetTimeSigAtTime against `timeSigProj` — offline passes nullptr for the
|
||||||
// active project, realtime pins the record's own project), the WAV-aware
|
// active project, realtime pins the record's own project), the WAV-aware
|
||||||
|
|||||||
@@ -184,6 +184,10 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
|
|||||||
request.endSeconds);
|
request.endSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Channel-domain rewrite, after the frame-domain trim so it acts on the final
|
||||||
|
// frame set; it preserves the frame count, so the trimmed length above still holds.
|
||||||
|
collapseCapturedFileToMono(destPath);
|
||||||
|
|
||||||
// Pure recorded-capture -> Sample mapping (identity, bounds echo, tier).
|
// Pure recorded-capture -> Sample mapping (identity, bounds echo, tier).
|
||||||
RecordedCapture cap;
|
RecordedCapture cap;
|
||||||
cap.relativePath = paths.relativePath;
|
cap.relativePath = paths.relativePath;
|
||||||
@@ -194,7 +198,9 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
|
|||||||
cap.wetDry = request.wetDry;
|
cap.wetDry = request.wetDry;
|
||||||
cap.displayName = request.baseName;
|
cap.displayName = request.baseName;
|
||||||
cap.trackGuids = request.trackGuids;
|
cap.trackGuids = request.trackGuids;
|
||||||
cap.channelCount = request.channelCount;
|
// channelCount deliberately left unset here: stampCaptureSample measures it from
|
||||||
|
// the file below. Echoing the request was this path's own defect — it parsed the
|
||||||
|
// recorded layout for the trim and still reported the requested 2.
|
||||||
|
|
||||||
result.status = CaptureStatus::Ok;
|
result.status = CaptureStatus::Ok;
|
||||||
result.sample = sampleFromRecordedCapture(cap);
|
result.sample = sampleFromRecordedCapture(cap);
|
||||||
|
|||||||
@@ -148,8 +148,9 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
|||||||
// no-play — no crash, no retry loop.
|
// no-play — no crash, no retry loop.
|
||||||
if (const SelectedSample* sel = findRef(refs, selId)) {
|
if (const SelectedSample* sel = findRef(refs, selId)) {
|
||||||
// Auto-default: channelModeFor computes the mode from the loaded capture's channel
|
// Auto-default: channelModeFor computes the mode from the loaded capture's channel
|
||||||
// count (always 2 for extension captures; mono only for ingest-imported mono files).
|
// count — 1 for an ingested mono file or a capture whose channels came out
|
||||||
// An unknown count (0) or explicit user choice keeps the mode.
|
// bit-identical and collapsed, 2 otherwise. An unknown count (0) or an explicit
|
||||||
|
// user choice keeps the mode.
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> cm(channelModeMutex_);
|
std::lock_guard<std::mutex> cm(channelModeMutex_);
|
||||||
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
|
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
|
||||||
|
|||||||
@@ -573,8 +573,26 @@ static void testSeamFieldsAdditiveInvariant() {
|
|||||||
CHECK(idx.query("id-z")->rootNote == 60); // move did not disturb seam fields
|
CHECK(idx.query("id-z")->rootNote == 60); // move did not disturb seam fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A collapsed capture is a 1-channel entry, and the JSON is the only thing carrying
|
||||||
|
// that count across a project reload — the instrument's mono/stereo default reads it.
|
||||||
|
static void testMonoChannelCountRoundTrip() {
|
||||||
|
BankModel idx;
|
||||||
|
Sample s = fullSample("mono");
|
||||||
|
s.channelCount = 1;
|
||||||
|
CHECK(idx.add(s) == AddResult::Added);
|
||||||
|
|
||||||
|
const std::string json = idx.serialize();
|
||||||
|
CHECK(json.find("\"channelCount\":1") != std::string::npos);
|
||||||
|
|
||||||
|
auto back = BankModel::deserialize(json);
|
||||||
|
CHECK(back.has_value());
|
||||||
|
CHECK(back && back->query("id-mono") &&
|
||||||
|
back->query("id-mono")->channelCount == 1);
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testFullFieldRoundTrip();
|
testFullFieldRoundTrip();
|
||||||
|
testMonoChannelCountRoundTrip();
|
||||||
testSerializeGoldenLiteral();
|
testSerializeGoldenLiteral();
|
||||||
testDedupByHash();
|
testDedupByHash();
|
||||||
testTierFilterAndMove();
|
testTierFilterAndMove();
|
||||||
|
|||||||
+163
-1
@@ -11,10 +11,14 @@
|
|||||||
// buildFloat32Wav golden header + parse round-trip; hashBytes/hashWavContent
|
// buildFloat32Wav golden header + parse round-trip; hashBytes/hashWavContent
|
||||||
// determinism, metadata-skip, fallback, and domain separation; a golden hash
|
// determinism, metadata-skip, fallback, and domain separation; a golden hash
|
||||||
// literal pinning exact hex output for a fixed input (guards persisted
|
// literal pinning exact hex output for a fixed input (guards persisted
|
||||||
// contentHash values against a silent feed-sequence drift).
|
// contentHash values against a silent feed-sequence drift); and the lossless mono
|
||||||
|
// collapse (bit-identical N-channel fold, the one-sample-differs and signed-zero
|
||||||
|
// declines, already-mono, zero/single-frame, an odd padded leading chunk, and the
|
||||||
|
// content-hash consequence).
|
||||||
|
|
||||||
#include "../src/core/capture/wav_codec.h"
|
#include "../src/core/capture/wav_codec.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
@@ -593,6 +597,155 @@ static void testGoldenHashLiterals() {
|
|||||||
CHECK(hashBytes(wav.data(), wav.size()) == "68d8a193c958fd44");
|
CHECK(hashBytes(wav.data(), wav.size()) == "68d8a193c958fd44");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Lossless mono collapse --------------------------------------------------
|
||||||
|
|
||||||
|
// Every channel carries frame f's value; the collapse must keep those values verbatim
|
||||||
|
// in one channel and leave frame count / rate / bit depth alone.
|
||||||
|
static void testCollapseBitIdenticalStereo() {
|
||||||
|
auto wav = buildFloatWav(2, 48000, 6,
|
||||||
|
[](std::size_t f, std::uint16_t) {
|
||||||
|
return 0.25f * static_cast<float>(f) - 0.5f;
|
||||||
|
});
|
||||||
|
const MonoCollapse c = collapseToMono(wav);
|
||||||
|
CHECK(c.collapsed);
|
||||||
|
|
||||||
|
const WavLayout L = parseWavLayout(c.bytes);
|
||||||
|
CHECK(L.valid); // valid implies float32: the parser rejects anything else
|
||||||
|
CHECK(L.channelCount == 1);
|
||||||
|
CHECK(L.sampleRate == 48000);
|
||||||
|
CHECK(L.frameCount() == 6);
|
||||||
|
|
||||||
|
const auto pcm = extractFloatFrames(c.bytes, L, 0, 6);
|
||||||
|
CHECK(pcm.size() == 6);
|
||||||
|
for (std::size_t f = 0; f < 6 && f < pcm.size(); ++f)
|
||||||
|
CHECK(pcm[f] == 0.25f * static_cast<float>(f) - 0.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testCollapseDeclinesOnOneDifferingSample() {
|
||||||
|
// Identical everywhere except frame 4's right channel, by the smallest step the
|
||||||
|
// format can express near 1.0.
|
||||||
|
auto wav = buildFloatWav(2, 48000, 8,
|
||||||
|
[](std::size_t f, std::uint16_t ch) {
|
||||||
|
float v = 1.0f + static_cast<float>(f);
|
||||||
|
if (f == 4 && ch == 1) v = nextafterf(v, 2.0f);
|
||||||
|
return v;
|
||||||
|
});
|
||||||
|
CHECK(!collapseToMono(wav).collapsed);
|
||||||
|
CHECK(collapseToMono(wav).bytes.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// An already-mono file must come back untouched — a second capture pass over a
|
||||||
|
// collapsed file must not rebuild (and so must not re-hash) it.
|
||||||
|
static void testCollapseDeclinesOnAlreadyMono() {
|
||||||
|
auto wav = buildFloatWav(1, 44100, 4,
|
||||||
|
[](std::size_t f, std::uint16_t) {
|
||||||
|
return static_cast<float>(f);
|
||||||
|
});
|
||||||
|
CHECK(!collapseToMono(wav).collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// N-channel generalization: all-identical collapses to ONE channel, never a partial
|
||||||
|
// fold (4 -> 2). Unreachable from today's capture paths, which always render 2.
|
||||||
|
static void testCollapseFourChannels() {
|
||||||
|
auto same = buildFloatWav(4, 48000, 5,
|
||||||
|
[](std::size_t f, std::uint16_t) {
|
||||||
|
return -0.125f * static_cast<float>(f);
|
||||||
|
});
|
||||||
|
const MonoCollapse c = collapseToMono(same);
|
||||||
|
CHECK(c.collapsed);
|
||||||
|
const WavLayout L = parseWavLayout(c.bytes);
|
||||||
|
CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 5);
|
||||||
|
|
||||||
|
auto oneDiffers = buildFloatWav(4, 48000, 5,
|
||||||
|
[](std::size_t f, std::uint16_t ch) {
|
||||||
|
float v = -0.125f * static_cast<float>(f);
|
||||||
|
if (f == 2 && ch == 3) v += 0.5f;
|
||||||
|
return v;
|
||||||
|
});
|
||||||
|
CHECK(!collapseToMono(oneDiffers).collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testCollapseZeroAndSingleFrame() {
|
||||||
|
// No frame of evidence that the channels agree -> decline rather than rebuild.
|
||||||
|
auto empty = buildFloatWav(2, 48000, 0,
|
||||||
|
[](std::size_t, std::uint16_t) { return 0.0f; });
|
||||||
|
CHECK(parseWavLayout(empty).valid && parseWavLayout(empty).frameCount() == 0);
|
||||||
|
CHECK(!collapseToMono(empty).collapsed);
|
||||||
|
|
||||||
|
auto one = buildFloatWav(2, 48000, 1,
|
||||||
|
[](std::size_t, std::uint16_t) { return 0.75f; });
|
||||||
|
const MonoCollapse c = collapseToMono(one);
|
||||||
|
CHECK(c.collapsed);
|
||||||
|
const WavLayout L = parseWavLayout(c.bytes);
|
||||||
|
CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 1);
|
||||||
|
const auto pcm = extractFloatFrames(c.bytes, L, 0, 1);
|
||||||
|
CHECK(pcm.size() == 1 && pcm[0] == 0.75f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The predicate is over BIT PATTERNS: -0.0f == +0.0f compares equal as floats but is
|
||||||
|
// a different value on disk, so folding it would not be lossless.
|
||||||
|
static void testCollapseSignedZeroIsNotIdentical() {
|
||||||
|
auto wav = buildFloatWav(2, 48000, 3,
|
||||||
|
[](std::size_t, std::uint16_t ch) {
|
||||||
|
return ch == 0 ? 0.0f : -0.0f;
|
||||||
|
});
|
||||||
|
CHECK(!collapseToMono(wav).collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A leading odd-sized chunk exercises the walk's RIFF pad byte; the rebuilt file is
|
||||||
|
// canonical, so that chunk does not survive.
|
||||||
|
static void testCollapseThroughOddPaddedLeadingChunk() {
|
||||||
|
std::vector<std::uint8_t> chunks;
|
||||||
|
putTag(chunks, "LIST");
|
||||||
|
putU32(chunks, 5); // odd body -> one pad byte
|
||||||
|
for (int i = 0; i < 5; ++i) chunks.push_back(0x41);
|
||||||
|
chunks.push_back(0); // the pad
|
||||||
|
putTag(chunks, "fmt ");
|
||||||
|
putU32(chunks, 16);
|
||||||
|
putU16(chunks, 3);
|
||||||
|
putU16(chunks, 2);
|
||||||
|
putU32(chunks, 48000);
|
||||||
|
putU32(chunks, 48000u * 2u * 4u);
|
||||||
|
putU16(chunks, 8);
|
||||||
|
putU16(chunks, 32);
|
||||||
|
putTag(chunks, "data");
|
||||||
|
putU32(chunks, 3u * 2u * 4u);
|
||||||
|
for (std::size_t f = 0; f < 3; ++f)
|
||||||
|
for (int ch = 0; ch < 2; ++ch) putFloat(chunks, 0.5f * static_cast<float>(f));
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> wav;
|
||||||
|
putTag(wav, "RIFF");
|
||||||
|
putU32(wav, static_cast<std::uint32_t>(4 + chunks.size()));
|
||||||
|
putTag(wav, "WAVE");
|
||||||
|
wav.insert(wav.end(), chunks.begin(), chunks.end());
|
||||||
|
|
||||||
|
const MonoCollapse c = collapseToMono(wav);
|
||||||
|
CHECK(c.collapsed);
|
||||||
|
const WavLayout L = parseWavLayout(c.bytes);
|
||||||
|
CHECK(L.valid && L.channelCount == 1 && L.frameCount() == 3);
|
||||||
|
// Canonical rebuild: byte-for-byte what buildFloat32Wav produces for the same PCM.
|
||||||
|
CHECK(c.bytes == buildFloat32Wav(1, 48000, 3, {0.0, 0.5, 1.0}));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testCollapseDeclinesOnUnparseableBytes() {
|
||||||
|
std::vector<std::uint8_t> junk = {'N','O','P','E', 0,0,0,0, 'W','A','V','E'};
|
||||||
|
CHECK(!collapseToMono(junk).collapsed);
|
||||||
|
CHECK(!collapseToMono(std::vector<std::uint8_t>{}).collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stated consequence, pinned: the collapse rewrites both the `fmt ` body and the
|
||||||
|
// `data` payload, so a collapsed capture no longer shares content identity with the
|
||||||
|
// stereo file it came from and will not dedup against one already in the bank.
|
||||||
|
static void testCollapseChangesContentHash() {
|
||||||
|
auto wav = buildFloatWav(2, 48000, 4,
|
||||||
|
[](std::size_t f, std::uint16_t) {
|
||||||
|
return static_cast<float>(f);
|
||||||
|
});
|
||||||
|
const MonoCollapse c = collapseToMono(wav);
|
||||||
|
CHECK(c.collapsed);
|
||||||
|
CHECK(hashWavContent(c.bytes) != hashWavContent(wav));
|
||||||
|
}
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
testParseCanonicalStereo();
|
testParseCanonicalStereo();
|
||||||
testParseMonoAndLeadingChunk();
|
testParseMonoAndLeadingChunk();
|
||||||
@@ -618,6 +771,15 @@ int main() {
|
|||||||
testHashWavContentDomainSeparationFromWholeFile();
|
testHashWavContentDomainSeparationFromWholeFile();
|
||||||
testHashMatchesBuildOutput();
|
testHashMatchesBuildOutput();
|
||||||
testGoldenHashLiterals();
|
testGoldenHashLiterals();
|
||||||
|
testCollapseBitIdenticalStereo();
|
||||||
|
testCollapseDeclinesOnOneDifferingSample();
|
||||||
|
testCollapseDeclinesOnAlreadyMono();
|
||||||
|
testCollapseFourChannels();
|
||||||
|
testCollapseZeroAndSingleFrame();
|
||||||
|
testCollapseSignedZeroIsNotIdentical();
|
||||||
|
testCollapseThroughOddPaddedLeadingChunk();
|
||||||
|
testCollapseDeclinesOnUnparseableBytes();
|
||||||
|
testCollapseChangesContentHash();
|
||||||
|
|
||||||
if (g_fail == 0) std::printf("wav_codec: all tests passed\n");
|
if (g_fail == 0) std::printf("wav_codec: all tests passed\n");
|
||||||
else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail);
|
else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail);
|
||||||
|
|||||||
Reference in New Issue
Block a user