# Q-W0 Track 3 — env-coupled-constant domain-modeling audit

Static analysis, 2026-07-28, branch `pq-w0-audit`. Scope: any value stored in an
environment-coupled domain — frames, sample rate, DPI, pixels, tick cadence — that should be
stored **rate-free / device-free and resolved at the point of use** (`docs/product/code-organization.md`
§2c.3 env-coupled bullet; the load-bearing `sample_map` seconds invariant, documented in
`src/core/instrument/CLAUDE.md`). Findings are domain-modeling calls, not "rescale by rate" patches. The judgment bar
applied: a finding requires (a) an env-coupled *stored* domain AND (b) an environment that can
actually change under it. Frame counts computed transiently from seconds at the use site are
correct and are not reported.

Waves referenced for disposition: Q-W1..Q-W6 open `bank_panel.cpp`, `main.cpp`, `actions.cpp`,
`persist.cpp`, and relocate the clean pure libs — **no downstream wave opens
`reasampler_processor.cpp` / `sampler_core.h` / `reasampler_editor.cpp` for logic change**, so
fix-now findings in those files must be remediated in Q-W0 itself.

---

## Findings

### T3-01 — master-gain ramp step is a frame-domain constant anchored to 48 kHz

- **Location:** `src/vst/reasampler_processor.cpp:46-51` (`kGainRampRate = 1.0f / 960.0f`,
  `kGainRampSnap`), applied per-sample at `:1069-1085` (stereo) and `:1114-1126` (mono).
- **Stored vs. correct domain:** stored as a **per-sample linear step** — `960` is literally
  20 ms × 48 000 Hz, and the comment says so ("960 samples @ 48 kHz ≈ 20 ms"). The intended
  quantity is a **wall-clock ramp time** (~20 ms); the correct model is a seconds/ms constant
  with the per-sample step derived from `sampleRate_` at `setupProcessing` — exactly the
  pattern the same file already uses two paragraphs away for `kPreserveWindowMs`
  (`:618-623`, `:733-735`).
- **What breaks when the environment shifts:** the ramp's wall-clock length halves at 96 kHz
  (~10 ms) and quarters at 192 kHz (~5 ms); at 44.1 kHz it stretches to ~21.8 ms. The FB1
  "no zipper" contract degrades silently as the host rate rises. Not persisted, so no on-disk
  breakage — but it is a live hardcoded-rate assumption in `src/`, which Daniel's standing
  ruling forbids.
- **Severity:** Med.
- **Disposition:** **fix-now, remediated in Q-W0.** Rationale: trivial, isolated,
  behavior-identical at 48 kHz; no downstream wave opens this file, so deferring means keeping
  a named violation of the no-hardcoded-rate ruling through the whole reorg. Store
  `kGainRampSeconds = 0.020`, derive the step from the live rate where `sampleRate_` is set.

### T3-02 — takeover-declick decay is a per-frame coefficient (documented deliberate)

- **Location:** `src/vst/sampler_core.h:409-410` (`kDeclickDecay = 0.95`,
  `kDeclickFloor = 1e-4`), applied per output frame in `sampler_core.cpp:514-519`, `:643-645`.
- **Stored vs. correct domain:** a per-output-frame exponential coefficient; the implied
  wall-clock decay-to-floor is ~4.2 ms at 44.1 kHz and ~1.9 ms at 96 kHz. The strict
  domain-model form would be a time constant in seconds resolved to a coefficient at engine
  build.
- **What breaks when the environment shifts:** the takeover-blend residue fades ~2× faster at
  96 kHz. Audibly negligible for a declick micro-ramp — and the in-code comment
  (`sampler_core.h:397-401`) **already documents this as a deliberate per-frame DSP micro-ramp,
  "not a stored wall-clock quantity"**, with the 44.1–96 kHz variance stated and accepted.
- **Severity:** Low.
- **Disposition:** **document-and-defer.** Rationale: the coupling is already an explicit,
  written, bounded design decision in the code; converting it buys no audible improvement.
  Triage should ratify the in-code note as the record.

### T3-03 — Trigger-fade UI throw ceiling hardcodes 2 s × 44 100 as `88200.0` frames

- **Location:** `src/vst/reasampler_editor.cpp:395`
  (`constexpr double kFadeMaxFrames = 88200.0`), used by `controlValue`/the commit path to
  normalize the Trigger fade-in/out knobs.
- **Stored vs. correct domain:** the fade **storage** domain (int64 SOURCE frames, persisted in
  the zones payload) is settled and correct — a source-timeline fact, invariant under project-
  rate change (`docs/ARCHIVE.md` §S15). The *UI ceiling*, however, encodes a wall-clock intent ("2-second
  max fade throw") as a frame count at an assumed 44.1 kHz source. `88200` is a rate-derived
  literal in `src/`, brushing the no-hardcoded-rate ruling even though it never touches disk.
- **What breaks when the environment shifts:** the environment here is the **source file's
  rate**: a 96 kHz capture's maximum fade throw is ~0.92 s; a 22.05 kHz file gets 4 s. The knob's
  full-scale meaning silently varies per loaded sample.
- **Severity:** Low (UI-only, not persisted, comment flags it as a "build-time residual — one
  place to retune").
- **Disposition:** **fix-now, remediated in Q-W0.** Rationale: small and contained — replace
  with `kFadeMaxSeconds = 2.0` resolved against the loaded source's rate at the two normalize
  sites (the editor already threads `frameCount + rate` through the pack/unpack path,
  `envelope_edit.cpp:148`); storage domain unchanged. If triage prefers zero UI-feel change,
  the fallback is document-and-defer with the comment amended to name the 44.1 k assumption.

### T3-04 — drop-hint banner duration stored in sync-timer ticks

- **Location:** `src/vst/reasampler_editor.cpp:2920-2923` (`dropHintTicks_ = 6`), decayed in
  `onSyncTimer` (`:244-245`); field at `reasampler_editor.h:457`.
- **Stored vs. correct domain:** a wall-clock intent ("a few seconds of banner") stored as a
  **count of `kSyncTimerIntervalMs` ticks** (6 × 500 ms). Correct model: a duration in ms,
  ticks derived — or a `GetTickCount`-style deadline like the bank panel's tooltip already
  uses.
- **What breaks when the environment shifts:** retuning the sync cadence (a plausible perf
  tweak — the 500 ms value is itself a tuning constant) silently changes the banner duration.
  The comment does state the coupling.
- **Severity:** Low.
- **Disposition:** **document-and-defer.** Rationale: cosmetic, self-documenting at the single
  site, and the cadence and hint decay live three lines apart; a fix is fine to fold in
  opportunistically if the file is ever opened, but does not justify a Q-W0 edit on its own.

### T3-05 — systemic: no DPI/content-scale support in either UI surface

- **Location:** systemic. VST3 editor: `reasampler_editor.cpp:150-153` (`ViewRect(0,0,840,620)`
  default and the size floor at `:816`), all `editor_geometry` / `knob_deck` / `curve_popup` /
  `envelope_overlay` px constants (e.g. the 8 px node min-separation, the 28×28 curve button),
  cached font sizes in `draw_kit`. Extension side: the LICE-drawn `bank_panel` dock and its
  geometry modules. No implementation of VST3's `IPlugViewContentScaleSupport` anywhere in
  `src/vst/` (grep: zero hits for content-scale/DPI), no scale factor threaded through the
  pure geometry modules.
- **Stored vs. correct domain:** every layout constant is a **physical device pixel** that
  silently assumes ~96 DPI. Correct model: logical units × one scale factor resolved at draw
  time (the pure geometry modules take widths/heights as parameters already, so a scale factor
  threads through cleanly — the constants are centralized, which is the good news).
- **What breaks when the environment shifts:** on a 150–200 % Windows display the editor and
  dock render physically small (or get bitmap-stretched by the host, blurring text); hit
  targets like the 8 px min node separation shrink below comfortable pointer accuracy.
  Usability, not correctness — nothing mis-plays and nothing persisted is wrong.
- **Severity:** Med (usability on modern displays; Windows-only product makes high-DPI common).
- **Disposition:** **document-and-defer.** Rationale: a proper UI-scaling pass is a feature
  wave of its own (scale plumbing through ~15 geometry modules + font cache + both shells),
  far outside Q-W0's remediation budget; deferral should be recorded as a named future phase,
  and Q-W1's relocation of the geometry modules should keep the constants centralized so the
  eventual scale factor lands in one place.

### T3-06 — legacy v3 zone-payload lift divides by the *current* project rate

- **Location:** `src/vst/sample_map.cpp:601-605` (v3 lift inside `readZonesPayload`), format
  note at `sample_map.h:439-456`, `:510-513`.
- **Stored vs. correct domain:** the v3 blobs (Daniel's beta projects) stored wall-clock times
  as frames — **the prior incident itself**. The lift converts frames → seconds by dividing by
  the live `projectRate` threaded in at read time. That is exact only if the project rate today
  equals the rate in effect when the S15/S16 editor wrote the frames; the write-era rate was
  never recorded, so a project whose rate changed since lifts skewed times (old/new ratio,
  e.g. ~8.8 % for 44.1→48 k).
- **What breaks when the environment shifts:** already broken by construction for
  rate-changed-since-write projects; a one-time lift residue, after which v5+ re-saves in
  seconds and the skew is frozen in, silently.
- **Severity:** Low (legacy-only, beta-project blobs, envelope-time magnitudes; unrecoverable
  in principle — the missing datum was never written).
- **Disposition:** **document-and-defer.** Rationale: no better conversion exists; this is the
  documented residue of the incident that motivated the seconds invariant. Worth one sentence
  in the code-quality-audit report so the skew is a recorded known, not a mystery bug later.

### T3-07 — SOLA correlation-segment cap of 512 frames (deliberate CPU bound; cross-ref T1)

- **Location:** `src/vst/pitch_shift.cpp:72`
  (`corrFrames_ = max(1, min(dLow_ - 1, 512))`; rationale comment at `:64-67`).
- **Stored vs. correct domain:** borderline by design. The quantity being bounded is **work per
  splice** (multiply-accumulates), which is genuinely frame-domain — a CPU bound *should* be in
  frames. The side effect is that the correlation segment's wall-clock span halves at 96 kHz
  (512 frames ≈ 11.6 ms at 44.1 k, ≈ 5.3 ms at 96 k), raising the lowest frequency the
  alignment search can lock onto at high rates. All other shifter geometry correctly derives
  from `kPreserveWindowMs` resolved at the live rate.
- **What breaks when the environment shifts:** alignment quality for low-frequency content
  degrades somewhat at high host rates; no correctness or persistence impact.
- **Severity:** Low.
- **Disposition:** **document-and-defer**, and hand to the T1 DSP audit for the quality call.
  Rationale: the frame domain is arguably correct for a compute bound; whether 512 is the right
  *number* is an algorithm-quality question (T1's territory), not a domain-modeling one.

---

## Surfaces checked clean

- **`sample_map` v5+ persistence (the reference implementation):** AHDSR + pitch-env times as
  SECONDS doubles; no rate constant anywhere in the read/write paths (`kLegacyV3NominalRate`
  deliberately does not exist); legacy v3 lift takes the rate as a parameter. Clean.
- **ComponentState envelope v6–v11 fields:** channel mode, assign generation, preview velocity,
  voice count/mode/trigger, `masterGainLinear` (dimensionless linear), explicit flag,
  `SampleRefs` (paths + root/loop/channels intrinsics), `instanceGuid` — all rate-free or
  file-fact domains. Clean.
- **Trigger `fadeInFrames`/`fadeOutFrames`/`startPoint`/`SampleLoop.start/end` persisted as
  int64 SOURCE frames:** deliberate, settled source-timeline facts (`docs/ARCHIVE.md` §S15;
  `bank_model.h:66-72` documents the loop rationale) — frames *of the file* are invariant under
  project-rate change; the file's own rate is stored alongside and resolved at decode. Correct
  domain, not a finding.
- **`trigger_seam`:** frames↔fraction with `startFrame` threaded both directions; the overlay's
  fraction domain is expressly rate-invariant. Clean.
- **`kPreserveWindowMs` (50 ms):** resolved to frames against the live host rate at both call
  sites (`reasampler_processor.cpp:618-623`, `:733-735`) — the correct pattern, cited here as
  the model T3-01 should copy.
- **`pitch_shift` internal geometry:** ring length, fade, lag band, delay band all derived from
  the rate-resolved `window_`; ratio-scaled live fade length. Clean (T3-07 cap noted above).
- **Tail system:** `TailSetting.manualMs` persisted in **ms**; `kMaxTailSeconds`/`kMaxTailMs`
  wall-clock; trim threshold in **dB** with the linear ratio derived
  (`render_settings.h:59-86`); the realtime decay scan resolves frames against the **file's own
  authoritative rate** (`capture_realtime.cpp:384-408`). Clean.
- **`wav_trim`:** frame counts are parsed file facts and transient truncate plans. Clean.
- **`bank_model` persisted metadata:** source bounds in seconds + PPQ (both stored, each for
  its consumer); loudness in dB; `sampleRate`/`channelCount` are *recorded facts about the
  file*, not assumptions; capture tempo + meter stamped at capture time deliberately so the
  bars.beats read-out is stable under later project meter changes (`card_meta`). Clean.
- **Ext-state wires** (`banks` JSON, view-mode model, owned manifest, `assignment_request`,
  `rsusage_*`): no frame-domain values; the rate field in the assign wire is a recorded fact.
  Clean.
- **Envelope schematic (`envelope_overlay`/`envelope_edit`):** param-domain px↔seconds scale
  derived from the live rect (`gatePxPerSecond`), sample-length-free; editor time-slider
  ceiling is `kEnvTimeMaxSeconds = 2.0` (seconds). Clean (pixel constants themselves fall under
  the systemic T3-05).
- **Timers:** bank_panel tooltip delay uses `GetTickCount()` ms against `kTooltipDelayMs = 500`
  (wall-clock — the pattern T3-04 should copy); editor sync timer is a 500 ms `SetTimer`
  interval (ms, not ticks); the new-content detector is an event diff per tick with no
  wall-clock meaning encoded in tick counts; `retireIdleDrain` is idleness-driven, not
  time-driven. Clean.
- **`peaks` / `waveform_view` / `master_gain` / `velocity_curve` / `keyboard_strip`:** bins and
  columns derived from rects at use; dB↔linear taper and curve math dimensionless; key rects
  from the passed strip rect. Clean.

## Summary

| ID | Location | Stored domain | Severity | Disposition |
|----|----------|---------------|----------|-------------|
| T3-01 | `reasampler_processor.cpp:46-51` gain-ramp step | per-sample step (20 ms @ 48 k baked in) | Med | **Fix-now (Q-W0)** — store seconds, derive step from `sampleRate_` |
| T3-02 | `sampler_core.h:409-410` declick decay | per-frame coefficient | Low | Document-and-defer — deliberate, already documented in-code |
| T3-03 | `reasampler_editor.cpp:395` fade throw ceiling | 88200 source frames (2 s @ 44.1 k) | Low | **Fix-now (Q-W0)** — seconds ceiling resolved vs. source rate at use |
| T3-04 | `reasampler_editor.cpp:2923` drop-hint duration | sync-timer ticks | Low | Document-and-defer — cosmetic, coupling stated in-code |
| T3-05 | systemic (both UI surfaces) | physical px, ~96 DPI assumed; no content-scale | Med | Document-and-defer — a UI-scaling phase of its own; keep geometry constants centralized through Q-W1 |
| T3-06 | `sample_map.cpp:601-605` v3 legacy lift | frames ÷ *current* project rate | Low | Document-and-defer — unrecoverable legacy residue; record as known skew |
| T3-07 | `pitch_shift.cpp:72` correlation cap | 512 frames (CPU bound) | Low | Document-and-defer — frame domain arguably correct for a compute bound; hand to T1 for the quality call |

Two fix-now findings (T3-01, T3-03), both assigned to **Q-W0 itself** — no downstream wave
opens those files for logic change. Five deferrals, each with a recorded rationale. The
persistence surfaces — the highest-stakes case — are clean: every wall-clock quantity written
to disk since the S12 remediation is in seconds or ms, and every frame-domain persisted value
is a source-file fact whose rate travels with it.
