Files
reasampler/docs/product/audit-notes/q-w0-t2-architecture.md
T

334 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Q-W0 Track 2 — architecture-smell audit (functional lens)
Static analysis of the whole `src/` tree (extension + `src/vst/`), 2026-07-28, branch
`pq-w0-audit`. Complement to the grep-verified SOLID audit (§2) and naming audit (§2b) in
`docs/product/code-organization.md` — this track reports the **functional** smells those did not
target: duplicated *algorithms* (not merely duplicated responsibilities), reinvented wheels,
poor abstractions, and leaky pure/shell boundaries. Findings already catalogued there (the four
god-modules, the 4× JSON `Parser`, fat headers, `promptText`/`mintBankId` duplication, namespace
flatness, naming families) are **not restated**; where a finding below touches the same file it
is because the functional mechanism is new.
Every claim below was verified by grep/read of the actual tree. Line numbers are as of this
audit's snapshot. Wave assignments reference PLAN.md §Q-W1..Q-W6.
---
## Findings
### T2-01 — 3× copy-pasted length-prefixed wire `Cursor`, with security-hardening drift
**Location:** `src/provenance.cpp:56137`, `src/assignment_request.cpp:25121`,
`src/sample_usage.cpp:2589` (plus a fourth sibling: `src/vst/bank_sync.cpp:1128`
`parseBankGeneration` re-rolls the same guarded decimal accumulate).
**Mechanism.** The `<len>':'<bytes>` ext-state wire idiom ("one grammar across every ext-state
seam", per sample_usage's own comment) is implemented as three near-identical `putField` +
`Cursor` copies — and they have **drifted on the hardening**. The two newer copies
(`assignment_request`, `sample_usage`) carry a 20-digit length cap and an overflow guard
(`len > (SIZE_MAX - digit) / 10 → fail`) plus the subtraction-first bounds check
(`len > s_.size() - start`). The oldest copy (`provenance.cpp:6582`) has **neither**: a crafted
long digit run wraps `len` silently, and the additive bounds check `start + len > s_.size()`
can itself wrap, letting a wrapped length pass. Downstream, `parseFingerprint`
(`provenance.cpp:197199`) calls `r.trackGuids.reserve(guidCount)` on an **unbounded** count
parsed by the equally unguarded `fieldSizeT` — a corrupt/crafted `Sample.provenance` string in
the bank JSON can drive `reserve(huge)` into `std::length_error`/`bad_alloc` thrown through the
shell. (`sample_usage` fixed exactly this with its `count > wire.size()/4 + 1` sanity bound,
`sample_usage.cpp:121`; the fix was never backported.) `std::string::assign` clamping keeps the
wrap short of UB, but the parse-integrity promise ("never UB, never a partial value") is upheld
in two copies and eroded in the third — the textbook cost of a duplicated algorithm.
**Severity:** High (the drift already produced a concrete robustness gap on a persisted,
user-editable input; the class of bug will recur with every new wire seam).
**Disposition:** **fix-now, split:** (a) backport the hardened `field()` + a count sanity bound
to `provenance.cpp` **in Q-W0** — small, pure, existing `provenance_tests` covers round-trip and
malformed-input paths; (b) the structural collapse (one shared `wire` codec module beside
`core/json`, all three seams + `parseBankGeneration` consuming it) belongs to **Q-W1**, which is
already the serialization-extraction wave. Rationale: the hazard is cheap to close now; the
dedup is a relocation-adjacent move that should ride the wave already creating `core/`.
### T2-02 — a FIFTH hand-rolled JSON decoder the §2 audit did not count
**Location:** `src/tail_control.cpp:88130` (`valueAfterKey` + `deserializeTailSetting`).
**Mechanism.** The catalogued DRY violation is "JSON `Parser` duplicated 4×" (`bank_model`,
`bank_book`, `view_mode_model`, `owned_manifest`). `tail_control` carries a fifth, structurally
different JSON decode: a substring-scan reader (`json.find("\"key\"")` → skip ws → parse token).
It is correct for the flat single-object payload it reads (the file argues this honestly), but
it is a fifth place JSON-reading behavior is defined, with different tolerance semantics (a key
found inside a *string value* would match — impossible today only because the writer is its own
sole producer). If Q-W1 extracts `core/json` from the four `Parser`s and misses this site, the
"one JSON path" goal is silently not achieved.
**Severity:** Med (no live bug; a completeness gap in the already-planned fix).
**Disposition:** **fix-now, folded into Q-W1** — add `tail_control` to the Q-W1 consumer list
explicitly. Rationale: zero extra cost when `core/json` lands; a stray fifth decoder afterward
would be a defect of the wave.
### T2-03 — `readFileBytes` hand-rolled five times, both sides of the artifact split
**Location:** `src/capture.cpp:232`, `src/capture_realtime.cpp:329` (as `readAllBytes`),
`src/ingest.cpp:86` (comment admits: "of capture.cpp's readFileBytes"),
`src/vst/reasampler_processor.cpp:72`, and inline in `src/vst/reasampler_editor.cpp:749756`.
**Mechanism.** The identical ifstream-binary-ate/tellg/read whole-file loader exists five times
(two spellings, one anonymous inline). Well past extract-on-third-occurrence, and the copies
already disagree cosmetically (name, empty-on-failure comment placement) — the next divergence
will be behavioral (e.g. one copy gaining a size ceiling the others lack).
**Severity:** Med.
**Disposition:** **fix-now, folded into Q-W1** — a trivial pure `readFileBytes` helper in the
`core/` utility home Q-W1 creates; both CMake targets link it. Rationale: five occurrences of a
ten-line function is pure debt with a zero-risk fix, but creating its home is exactly Q-W1's
job — doing it days earlier in the flat tree would just move the file twice.
### T2-04 — the growing `GetProjExtState` read loop, three copies, pure half only half-used
**Location:** `src/persist.cpp:140157` (`getProjExtStateString`),
`src/vst/reaper_bridge.cpp:93107` (self-described "mirrors persist.cpp's growing strategy"),
`src/usage_scan.cpp:168181` (self-described "the persist.cpp idiom").
**Mechanism.** The grow-buffer-until-it-fits retry loop over `GetProjExtState` is implemented
three times, in three TUs, on both sides of the split. The fiddly part — interpreting the int
return against the filled buffer — is *already extracted pure* as
`bridge_marshal::decodeGetProjExtState`, but only `reaper_bridge` consumes it; `persist` and
`usage_scan` interpret `rv` inline with their own conventions (persist: `rv <= 0` → absent;
bridge: return AND non-empty buffer). The absent-vs-truncated-vs-empty semantics are precisely
the kind of edge that drifts when defined thrice. `usage_scan`'s copy is prune-safety-adjacent
(an unreadable usage record must abort the prune) — its read loop deserves the tested pure
decode, not an inline reimplementation.
**Severity:** Med.
**Disposition:** **fix-now, assigned to the downstream wave that opens `persist`**
(Q-W4 per the current wave map; whichever wave splits `persist.cpp` is the moment). Generalize
the retry policy (next-capacity/done decision) into `bridge_marshal` (or its `core/` successor)
and make all three loops consume it. Rationale: touching persist's session machinery outside
its own wave risks the highest-traffic shell for a dedup that has no live bug today.
### T2-05 — 19 rect structs + ~15 inline point-in-rect predicates across the pure UI family
**Location (structs):** `action_bar.h:61`, `bank_grid.h:23`, `card_drag.h:109`,
`component_geometry.h:28,53,106`, `drag_out.h:40`, `footer_bar.h:41`, `mode_switch.h:21,35`,
`overflow_menu.h:23,39`, `prune_button.h:32,46`, `tab_strip.h:24,51`, `tooltip.h:20`,
`vst/editor_geometry.h:19`, `vst/velocity_curve.h:124`.
**Location (predicates):** inline half-open `px >= r.x && px < r.x + r.width && …` re-typed in
`action_bar.cpp`, `bank_grid.cpp`, `card_drag.cpp` (×2), `component_geometry.cpp` (×2),
`drag_out.cpp`, `footer_bar.cpp`, `mode_switch.cpp`, `overflow_menu.cpp`, `prune_button.cpp`,
`tab_strip.cpp` (×2), `bank_panel.cpp:1049`, plus `vst/editor_geometry.cpp:20`.
**Mechanism.** §2b.2 catalogued the *naming/collision* half of this (the `footer_bar.h` "NAME
NOTE" hand-checking smell). The functional half is uncatalogued: nineteen structurally identical
axis-aligned `{x, y, w, h}` record types, each with its own hand-typed containment predicate.
Every new pure-UI module re-mints both. This is Daniel's heuristic (b) verbatim: N near-identical
concrete implementations that one shared type collapses at compile time — one `ui::Rect` + one
`contains(Rect, x, y)` free function (both already exist in embryo as `vst/editor_geometry`'s
`Rect`/`contains`), with per-module aliases or thin wrappers only where a struct carries extra
fields (e.g. `TabRect::index`). Zero runtime cost; deletes ~15 chances for the next half-open/
closed-interval inconsistency to slip in.
**Severity:** Med.
**Disposition:** **fix-now, folded into Q-W2** (the ui/ relocation wave) — collapsing the type
zoo is nearly free precisely when every one of these files is being moved and re-namespaced;
doing it pre-reorg would churn 19 headers twice. Rationale: same-moment-as-relocation is the
stated principle for renames (§2b intro); it holds identically for type unification.
### T2-06 — pure-computable layout math stranded in the VST editor shell (the §2 scope gap)
**Location:** `src/vst/reasampler_editor.cpp``SampleFaceLayout` (~line 922) and its builder,
`ClusterLayout` (~line 964), `zonesStripArea`/`noteEntryFieldsArea`/`noteEntryFieldRect`/
`zonesControlPanel`/`zonesDeckArea`/`zonesCurveButton` (lines 9921042), the channel-toggle
segment rects (~line 1065), banner rect math (~line 1195), among ~49 inline geometry
computations across the 3,065-LOC TU.
**Mechanism.** The codebase's own grammar homes exactly this class of math in pure modules
(`editor_geometry`, `knob_deck`, `curve_popup`, `capture_browser`, …), yet the editor shell has
accreted a second, untested layout layer: whole named layout structs and pure `Rect → Rect`
functions that take only ints and rects, compiled into the one TU that cannot be unit-tested
without a host window. This is the mirrored form of the pure/shell leak (algorithm math living
untestable in a shell). Note also the audit-scope gap this exposes: §2's god-module catalogue
covered the extension tree only — `reasampler_editor.cpp` (3,065 LOC) and
`reasampler_processor.cpp` (1,164 LOC) repeat the bank_panel pattern on the VST side and appear
in no existing finding.
**Severity:** Med (no correctness bug found in the stranded math; the cost is untestability and
the growth trajectory — the editor gained ~500 LOC/phase through r11).
**Disposition:** **document-and-defer, with a named reshape:** Q-W0 should surface a downstream
point (the wave that opens `src/vst/`, or a new one) hoisting the Sample-face/Zone-panel layout
into the existing pure homes (`editor_geometry` is the natural owner). Rationale: a hoist is a
behavior-preserving mechanical move best done under the reorg's test discipline, not pre-reorg;
but it must be a recorded point or the layer keeps growing.
### T2-07 — the extension links the entire voice engine to serialize one preset blob
**Location:** `CMakeLists.txt:383385` (`instrument_drop` → PUBLIC `sample_map`);
`src/instrument_drop.cpp` includes `vst/sample_map.h`, which pulls `sampler_core.h`
`pitch_shift.h` + `velocity_curve.h`.
**Mechanism.** `instrument_drop` (extension side) deliberately reuses
`sample_map::serializeComponentState` so the `.vstpreset` payload and the instrument's own
reader cannot drift — the right DRY call, explicitly documented in CMake. But the shared writer
lives *inside* the module that also owns zone resolution, WAV decode plumbing, and (via header
fan-in) the whole voice engine — so `reaper_reasampler` compiles and links `sampler_core`,
`pitch_shift`, and `velocity_curve` object code it never executes. The abstraction is right; its
*granularity* is wrong: the ComponentState codec is not separable from the engine stack today.
**Severity:** Low (dead weight in the binary and a misleading dependency edge; no runtime cost —
heuristic (c) is about call chains, which this does not add).
**Disposition:** **document-and-defer to Q-W1/Q-W2 module-homing:** when serialization gets its
`core/` home, split a `component_state` codec module (types + serialize/deserialize only) out of
`sample_map`; both artifacts link the codec, only the VST links the engine. Rationale: purely
structural, zero behavior change, and exactly the kind of module-boundary decision the reorg
waves exist to make once, deliberately.
### T2-08 — WAV/RIFF byte-format knowledge spread across four modules, two chunk walkers
**Location:** `src/wav_trim.cpp` (canonical parse: `parseWavLayout`/`extractFloatFrames`),
`src/capture_paths.cpp:31115` (a second, independent RIFF chunk walker for content hashing),
`src/ingest.cpp:108160` (hand-built 32f WAV writer), `src/capture_realtime.cpp:423` (in-place
RIFF/data size patch).
**Mechanism.** The tree is disciplined about *decoding* ("no third WAV reader" — sample_map,
editor, processor all route through `wav_trim`), but RIFF *container* knowledge is still minted
per site: `capture_paths` walks chunks with its own tag/size/pad-byte logic to hash `fmt `+`data`
while skipping metadata; `wav_trim` walks the same container shape for layout; `ingest` writes
headers by hand; `capture_realtime` patches sizes by offset. Four places know the RIFF framing
rules (even-byte padding, chunk-header arithmetic); a drift in any one (e.g. pad-byte handling)
would desynchronize hashing from decoding — the dedup-by-hash and null-test invariants both sit
on this.
**Severity:** Low (all four are currently correct against each other by inspection; the smell is
the maintenance surface, not a live divergence).
**Disposition:** **document-and-defer** — consolidate into a `core/wav` home (walker + layout +
writer + patch) when the reorg assigns module homes. Rationale: pre-reorg consolidation churns
the capture hot path (§3 guardrail) for no functional gain; the reorg wave that relocates
`wav_trim` is the natural moment.
### T2-09 — the two capture backends' Sample-stamping epilogue is copy-paste with silent divergences
**Location:** `src/capture.cpp:490537` vs `src/capture_realtime.cpp:505540`.
**Mechanism.** The finished-capture metadata stamp — `trackGuids`, `channelCount`, `sampleRate`,
`Master_GetTempo`, the `TimeMap_GetTimeSigAtTime` block, the WAV-aware `hashWavContent` content
hash (comment block duplicated verbatim, ~10 lines), `createdTimestamp` — is written twice, once
per backend. The copies have already diverged in quiet ways: offline passes `proj = nullptr`
(active project) to `TimeMap_GetTimeSigAtTime` while realtime pins `st.proj_`; the sampleRate
fallback logic differs in shape; realtime overrides `lengthSeconds` post-hoc. Some divergence is
semantic (realtime's tail-trim length), but the shared stamp is one concept — a future field
(e.g. a new provenance stamp) must currently be added in two places, and the time-sig
active-project vs pinned-project asymmetry is exactly the kind of drift that produces a
wrong-project stamp during a background-project capture.
**Severity:** Med.
**Disposition:** **fix-now, folded into Q-W3** (the wave already hoisting capture orchestration
out of `main.cpp` / right-sizing `capture.h`). Extract a `stampCaptureSample(Sample&, const
CaptureRequest&, ReaProject*)` shared helper; the divergent bits (length override) stay in the
realtime caller. Rationale: the fix touches both backend TUs, which Q-W3 opens anyway; doing it
there keeps one review of the precision-invariant-adjacent code.
### T2-10 — the two thumbnail pipelines' cache-invalidation strategies have drifted
**Location:** `src/bank_panel.cpp:422483` (extension: `computeThumbnail` + pure
`ThumbnailKey{id, width, generation}` via `bank_grid::thumbnailKeyString`) vs
`src/vst/reasampler_editor.cpp:715789` (VST: `monoPcmFor` keyed by bare `sampleId`,
`thumbnailFor` keyed by ad-hoc `sampleId + "|" + binCount`, invalidated by wholesale
`clear()` at lines 159160/894).
**Mechanism.** The dock panel and the VST browser render the same thumbnails through the shared
`peaks::computeEnvelope`, but the caching layer around it was re-designed independently on each
side: the extension bakes the bank generation into a *pure, tested* key type; the editor
hand-concats a string key with no generation and relies on call-site `clear()`s (bank-refresh,
resize). Both are correct **today** — but correctness on the editor side is distributed across
remembering every clear site, and the bin-clamp guard comment ("computeEnvelope pads binCount >
frameCount…") is duplicated verbatim in both TUs (`bank_panel.cpp:462`,
`reasampler_editor.cpp:780`), marking the copied design. A future refresh path that forgets the
clear shows stale waveforms with no test to catch it.
**Severity:** Low.
**Disposition:** **document-and-defer** — when T2-06's layout hoist opens the editor, adopt the
pure `ThumbnailKey` (or a shared `thumb_cache` helper) on the VST side. Rationale: no live bug;
unifying cache policy is a natural rider on the editor wave, pointless as standalone churn.
### T2-11 — ComponentState v1→v11 deserialize chain: sound, but the legacy branches triplicate the shared read
**Location:** `src/vst/sample_map.cpp:775945` (`deserializeComponentState`).
**Mechanism.** Audited the full lift chain for functional soundness: the bounded `ByteReader`
latches on truncation, every version's tail fields carry per-field corrupt fallbacks
(previewVelocity → mid default, voiceCount → default-not-clamp, gain → unity, refs → keep-parsed
prefix), and the strict-prefix envelope discipline is honest. **No correctness finding.** The
smell is shape: the v3, v4, and v5 branches each re-implement the mode-byte → marker → idLen/id
→ zones read sequence that the v6+ shared path also implements (three near-copies of the same
cursor walk, lines 806841 vs 851+), and each new envelope version adds another
`version >= kꞏꞏꞏV*Version` stanza to a function already ~170 lines long.
**Severity:** Low.
**Disposition:** **document-and-defer, explicitly.** The legacy branches are frozen back-compat
contract code with saved-project blobs as their only callers; rewriting them into a table-driven
lift risks the one thing they must never break, for zero user-visible gain. Record the pattern
so the *next* envelope bump (v12) prefers extending the shared path over minting another branch.
(The unbounded-suffix version-constant naming is §2b territory; not restated.)
---
## Surfaces checked and found clean
Recorded per the wave's no-silent-omission rule; each was read/grepped this audit.
- **Pure-module include hygiene, both trees.** Every module CLAUDE.md claims pure was scanned
for REAPER/SWELL/WDL/LICE/VST3-SDK includes: all clean, `src/` and `src/vst/` both. The one
grep hit in `pitch_shift.h` is a comment (the S16 WDL-exclusion note), not an include. The one
cross-tree include (`instrument_drop``vst/sample_map.h`) is pure-to-pure — see T2-07 for
the granularity concern; it is not a boundary violation.
- **`bank_sync`** — the generation/consume decision rules are pure, explicit, and exhaustively
commented (rules 14); `parseBankGeneration` is overflow-guarded (its duplication is rolled
into T2-01's family, not a separate defect).
- **`bridge_marshal`** — one honest job, done pure, with the S1 string-scan JSON reader
documented as retired (verified: no second JSON parser on the VST side; `sample_map` routes
through `BankBook::deserialize`).
- **The realtime record lifecycle** — *not* an implicit state machine: `RecordPhase` is an
explicit enum with pure per-tick transitions in `realtime_record.h`; `main.cpp` holds only the
handle + project pointer. (Its *residence* in main.cpp is catalogued §2.1; nothing functional
to add.)
- **Project-identity transitions** — `classifyProjectTransition` is pure (capture_paths), the
shell passes `sameProjectObject` as a bool to keep it so; the GUID-primary layering is
decision-tabled in one place.
- **`usage_scan`** — every decision delegated to pure `sample_usage`; container recursion is
depth-bounded with a protect-on-truncation fail-safe; the `std::function` parm-getter
indirection is prune-scan-cold (heuristic (c) satisfied — no hot-path chain).
- **Exception boundaries** — the three `catch (...)` sites (`bank_book.cpp:794` stol guard,
`instrument_drop_win.cpp:74` REAPER-callback boundary, `render_settings.cpp:180` stod guard)
are all documented, narrow, and non-swallowing in intent (each converts to an explicit
failure value). No silent error swallowing found.
- **`FxBypassGuard` (main.cpp) vs `view.cpp` park/restore** — both are snapshot-mutate-restore
over track flags and *look* like a dedup candidate; they are deliberately not one. Different
flag sets, different invariants (precision-neutralization vs Design-View parking), different
failure postures. Duplication of shape, not of concept — correctly left separate.
- **Path resolution** — `capture_paths::resolveBankFile` is the single resolver on both sides
of the split (panel, insert, drag_out, editor, processor). No parallel path logic.
- **WAV decode on the play path** — `wav_trim` is genuinely the only decoder (T2-08 concerns
the *container* knowledge spread, not a second decoder).
- **Draw layer** — the VST editor/embed compile the same `draw_kit`/`theme`/`component_geometry`
the extension uses (verified in CMake + includes); no parallel draw vocabulary grew on the
VST side.
- **Interface cost audit (heuristic (c))** — `ICaptureBackend` is the tree's only virtual
interface; two real implementations, dispatched once per capture (cold). No hot-path virtual
or std::function chain found in `sampler_core`/`pitch_shift`/`sample_map` (all static calls).
No interface-with-one-implementation found anywhere.
- **Boolean-parameter proliferation** — swept `src/` headers for multi-bool signatures; the only
hit is `bank_grid::applyClick(…, bool ctrl, bool shift, …)`, which mirrors physical modifier
keys and reads fine at call sites. Not a finding.
## Cross-checks against the §2/§2b audits (gaps noted, not restated)
- §2's evidence base is scoped to the extension tree ("45 files / ~19,800 LOC"); the full tree
is now ~39,000 LOC. The VST shells repeat the god-module pattern uncatalogued
(`reasampler_editor.cpp` 3,065 LOC, `reasampler_processor.cpp` 1,164 LOC) — carried here as
T2-06's scope note so the reorg waves size the `src/vst/` work realistically.
- §2.1's "4× JSON Parser" undercounts by one — T2-02 (`tail_control`).
- §2b.2's shared-rect naming hazard has an uncatalogued functional twin — T2-05.
## Summary table
| ID | Finding | Severity | Disposition | Where |
|-------|---------------------------------------------------------------|----------|-------------------|--------------|
| T2-01 | Wire `Cursor` ×3 with hardening drift; provenance unguarded | High | fix-now (split) | Q-W0 backport + Q-W1 dedup |
| T2-02 | Fifth JSON decoder in `tail_control` | Med | fix-now | Q-W1 |
| T2-03 | `readFileBytes` ×5 across both artifacts | Med | fix-now | Q-W1 |
| T2-04 | Growing ext-state read loop ×3; pure decode half-adopted | Med | fix-now | persist's wave (Q-W4) |
| T2-05 | 19 rect structs + ~15 inline point-in-rect predicates | Med | fix-now | Q-W2 |
| T2-06 | Layout math stranded in VST editor shell (+§2 scope gap) | Med | document-and-defer (named reshape) | src/vst wave |
| T2-07 | Extension links voice engine to share the preset serializer | Low | document-and-defer | Q-W1/Q-W2 homing |
| T2-08 | RIFF container knowledge in 4 modules / 2 chunk walkers | Low | document-and-defer | core/wav homing |
| T2-09 | Capture backends' Sample-stamp epilogue copy-paste w/ drift | Med | fix-now | Q-W3 |
| T2-10 | Thumbnail cache-invalidation strategies drifted across split | Low | document-and-defer | editor wave |
| T2-11 | ComponentState legacy lift branches triplicate the shared read | Low | document-and-defer | (pattern note for v12) |