Files

280 lines
22 KiB
Markdown
Raw Permalink 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 1 — DSP / audio algorithm-quality audit (findings)
Static analysis only; no code changed. Surfaces per the track brief: `src/vst/pitch_shift.{h,cpp}`
(highest priority — GA correlation-aligned SOLA rewrite + GA2 prime + GA3 freeze, never audited),
`src/vst/sampler_core.{h,cpp}`, `src/peaks.*`, `src/wav_trim.*`, the capture/tail paths
(`src/capture.cpp`, `src/capture_realtime.cpp`, `src/realtime_record.h`), `src/vst/master_gain.*`,
`src/vst/velocity_curve.*`.
Dispositions follow Q-11 (SETTLED): default document-and-defer; a bounded SOLA fix is weighed
before any technique replacement; technique replacement is Daniel's decision at triage. Note on
"assigned wave": **no structural wave Q-W1..Q-W6 opens the `src/vst/` DSP files** (they target
`core/json`, `bank_panel`, `main`, `actions`, `persist`, registration tables) — so any DSP finding
triaged fix-now is remediated in Q-W0 itself or folded in as a new point before Q-W1 begins.
I cannot listen; every artifact below is stated as mechanism + predicted audible consequence.
Perceptual materiality is Daniel's call.
---
## Overall verdict on the pitch engine (Q-11 framing)
The correlation-aligned SOLA in `pitch_shift` is **not a reinvented wheel in the pejorative
sense** — single-tap SOLA with normalized cross-correlation splice alignment, parabolic sub-sample
peak refinement, and amplitude-complementary raised-cosine fades *is* an established time-domain
technique family (SOLA/TD-PSOLA lineage), and the implementation is unusually well-defended:
normalized (not raw) correlation, ratio-scaled fade lengths with a drain-headroom derivation,
prime-with-real-content onset, frozen-writer tail, filled-span clamping, and double-before-int64
clamps at the overflow-prone spots. The RT discipline holds throughout: `process()` does no
allocation, no locks; the splice burst is bounded and fires once per splice cadence, not per frame.
**No technique replacement (phase-vocoder / WSOLA) is warranted on this evidence.** The findings
below are bounded-fix candidates and documented limits within the existing approach, exactly the
Q-11 escalation ladder's first rung.
---
## Findings
### T1-01 — Stereo Preserve: per-channel independent splice alignment decorrelates L/R
- **Location:** `src/vst/sampler_core.cpp` `Voice::advanceFrame` (Preserve branch, ~577589) +
`src/vst/pitch_shift.cpp` `PitchShifter::splice`.
- **Mechanism:** a stereo Preserve voice owns two `PitchShifter`s, each running its **own**
correlation search on its own channel's PCM. `bestLag + frac` differ per channel at every splice,
so after the first splice the two read taps sit at different ring positions — an inter-channel
time offset of up to ±`maxLag` (= window/4 ≈ **12.5 ms** at the 50 ms window), re-drawn at every
splice (cadence ≈ window/|ratio1| frames). The splice *schedules* also diverge (delay drift
depends on tap position), so L and R fade at different times.
- **Predicted artifact:** on genuinely stereo captures played through Preserve off-root: stereo
image wander / widening that changes at the splice cadence, and comb-filter coloration on any
mono sum. Correlated stereo content (the common case for a captured bus) is the worst case.
Dual-mono (mono sample in stereo bus) is unaffected — the code correctly mirrors one shifter.
- **Severity:** **High** (Preserve is the product-default pitch engine and the output bus is
permanently stereo with channel mode auto-defaulting from the capture — this hits the flagship
path on stereo material).
- **Disposition proposal:** this is the strongest candidate for a **bounded SOLA fix** (Q-11 rung
1): link the channels — run the correlation search once (on the L+R mid signal, or on L as
master) and apply the same `bestLag + frac` and splice schedule to both channels. Standard
practice for stereo SOLA. It reshapes the `PitchShifter` seam slightly (splice decision must be
computable once and applied to two rings — e.g. a lag-provider hook or a two-channel shifter),
but no technique change and no new dependency. Recommend **fix-now in Q-W0** pending Daniel's
triage call; if deferred, record it as the known stereo-Preserve limitation.
### T1-02 — Ratio slew mid-fade can drain the outgoing tap past the writer
- **Location:** `src/vst/pitch_shift.cpp` `splice()` fade-length cap (~301311) + `process()` tap
advance (~360366).
- **Mechanism:** `fadeLen_` is capped from the drain headroom **at splice time** using the
then-current `ratio_`. The pitch envelope legitimately slews the ratio per frame
(`setShiftRatio` mid-fade). A ratio that **rises** after the splice (pitch-env attack toward a
positive peak, or attack from a negative dip back to base) drains tap B faster than the cap
assumed; the code comment claims the 2-frame margin covers "any realistic per-frame bias", but
the margin is absolute, not slew-proportional: e.g. a splice at ratio ≈ 1 sets
`fadeLen_ = window/4` with no cap (drainRate ≈ 0), and a pitch-env attack ramping to +24 st
(ratio 4) within those ~12 ms drains tap B ≈ 3·window/4 — far past the `dLow` ≈ window/4
headroom. Tap B laps the parked/advancing writer and reads ring-length-stale content at up to
~half fade gain.
- **Predicted artifact:** a periodic click/garble burst at the splice cadence during fast upward
pitch-envelope ramps on Preserve voices. Only reachable with the AD pitch envelope enabled and
steep; base transpositions (constant ratio) are correctly covered by the existing cap.
- **Severity:** Med.
- **Disposition proposal:** document-and-defer (needs pitch-env + Preserve + steep attack to
trigger), with a cheap bounded fix noted for whenever the file is opened: re-tighten `fadeLen_`
when the ratio increases mid-fade (the `freezeTail()` re-anchor block is the exact pattern to
reuse), or clamp tap B's delay to ≥ 2 during a fade.
### T1-03 — Preserve prime ignores the Trigger play-end bound (short-span rings hold cut content)
- **Location:** `src/vst/sampler_core.cpp` `Voice::start` prime block (~375400) vs. the GA3
`feedBound` in `advanceFrame` (~568572).
- **Mechanism:** the per-frame feed treats `playEnd_` (Trigger) / `frameCount` as source
exhaustion and freezes the writer so "no padding ever enters the ring" (GA3). But `start()`
primes a **full window** from `pcm[q]` bounded only by `frameCount` — not by `playEnd_` — and
pads with zeros past the sample end while declaring the whole window `filled_`. Two
consequences for spans shorter than the 50 ms window: (a) a Trigger zone's ring holds real PCM
**past the user's chosen stop**, which an up-shifted tap can reach and play (transposed) before
`readPos_ ≥ playEnd_` frees the voice; (b) a sample shorter than the window gets zero padding
inside the ring as declared-valid history, so splices can land in silence — a bounded re-entry
of exactly the burst/gap onset artifact GA2/GA3 eliminated, scoped to sub-50 ms material (short
drum one-shots are realistic content).
- **Severity:** Med (bounded to short spans / short samples in Preserve; inaudible for spans ≥ one
window).
- **Disposition proposal:** bounded fix candidate — prime `min(window, span-to-feedBound)` frames
and call `freezeTail()` immediately after prime when the span is shorter than a window (the GA3
machinery then recycles the real short tail, which is its designed behavior). Small and
contained in `Voice::start`. Recommend fix-now in Q-W0 if Daniel agrees the short-one-shot case
matters; else document-and-defer with this note as the record.
### T1-04 — No sustain-loop crossfade (hard loop seam)
- **Location:** `src/vst/sampler_core.cpp` `Voice::advanceFrame` loop wrap (~493498, 610612).
- **Mechanism:** the sustain loop wraps by subtracting the loop length (phase-preserving) and the
interpolation partner wraps `i1 → loop.start`, giving one-sample continuity only. There is no
crossfade region: unless the user's loop points sit at amplitude/slope-matched positions, every
loop pass produces a step discontinuity — a click at the loop rate. `waveform_view`'s
zero-crossing snap on the loop markers mitigates but does not remove it (zero crossings with
mismatched slopes still click). Established samplers crossfade the loop seam (equal-power over a
user- or fixed-length region).
- **Severity:** Med (musically prominent when it hits, fully user-avoidable with careful loop
placement).
- **Disposition proposal:** document-and-defer — a loop-crossfade is a *feature* (needs a
crossfade-length parameter and UI surface), not a bug fix; wrong scope for a reorg phase. Record
as a known limitation beside the zone-loop spec.
### T1-05 — Linear interpolation + no band-limiting on repitch (both engines)
- **Location:** `src/vst/sampler_core.cpp` Varispeed read (~607624); `src/vst/pitch_shift.cpp`
`readTap` (~172185).
- **Mechanism:** all fractional reads are first-order (linear). Linear interpolation's frequency
response rolls off highs and leaks imaging sidebands (the interpolation image spectrum is
attenuated only ~12 dB/oct); Varispeed up-shifts additionally alias (reading faster than 1× with
no pre-filter folds source content above the post-shift Nyquist back into band). This is classic
hardware-sampler behavior — often accepted, sometimes desired — and both engines share it
consistently.
- **Severity:** Low (quality ceiling, not a defect; deterministic and stable).
- **Disposition proposal:** document-and-defer as a recorded trade-off. If a quality bump is ever
wanted, a 4-point cubic Hermite read is a drop-in bounded upgrade at both call sites (no
structural change); band-limited varispeed is a much bigger lift and not recommended.
### T1-06 — Correlation search: coarse step 4 can mis-lock on very high fundamentals; maxLag bounds alignment to ≥ ~80 Hz
- **Location:** `src/vst/pitch_shift.cpp` `splice()` search loops (~240257) and `configure()`
geometry (~6872).
- **Mechanism:** two documented-by-construction limits. (a) The coarse search samples the
correlation every 4 lags and refines ±3 around the coarse best — full integer coverage only
*near* the coarse winner. For content whose correlation oscillates with period < ~8 samples
(fundamentals above ~5.5 kHz at 44.1k), the coarse grid can alias and lock a non-optimal region;
the splice then lands up to half a period misaligned. (b) `maxLag = window/4` (~12.5 ms) cannot
span a full period below ~80 Hz, so deep-bass fundamentals cannot be period-aligned and splices
degrade toward unaligned OLA there. Both are inherent range/cost trades every SOLA makes; the
in-code comment already states (b).
- **Severity:** Low (edge content: pure tones > 5 kHz, fundamentals < 80 Hz).
- **Disposition proposal:** document-and-defer; record both bounds as the engine's stated
operating range. No change recommended — widening either costs splice-burst CPU linearly.
### T1-07 — `splice()` up-jump clamp comment contradicts the code (margin direction)
- **Location:** `src/vst/pitch_shift.cpp` ~196210.
- **Mechanism:** the comment derives the "tight cap" as `filled_ - d - maxLag_ - 2`, then says the
code's `- 1` is "one sample of conservative margin" — but `-1` permits a *larger* jump than
`-2`, i.e. the code is *less* restrictive than the comment's own derivation; the sentence has
the direction backwards. Re-deriving: the deepest probe is the parabola's outer lag at
`d + jump + maxLag + 1` (the interpolator's `i1 = i0 + 1` read-ahead moves *younger*, not
deeper), so the code's `-1` is exactly tight and the comment's `-2` double-counts the
interpolator. No out-of-range read either way; the comment is wrong, not the code.
- **Severity:** Low (doc-only; misleads the next maintainer of a safety-critical clamp).
- **Disposition proposal:** fix-now (comment rewrite, zero behavior change) — fold into whichever
Q-W0 remediation touches `pitch_shift`; if none does, a standalone one-line doc fix in Q-W0.
### T1-08 — Linear-in-amplitude ADSR decay/release segments
- **Location:** `src/vst/sampler_core.cpp` `AdsrEnvelope::tick` (~131170).
- **Mechanism:** decay and release ramp linearly in amplitude. Constant-slope amplitude is
constant-dB-rate nowhere: a long release spends most of its wall-clock at perceptually loud
levels then collapses abruptly (in dB terms the curve is logarithmic-late). Classic samplers use
exponential (constant-ratio) segments for decay/release. The evaluator itself is correct and
well-tested (release-from-current-level, hold-0 byte-compat re-dispatch are both right).
- **Severity:** Low (character, not correctness; the perceptual judgment is Daniel's).
- **Disposition proposal:** document-and-defer. An exponential-segment option is a contained
evaluator change but alters every existing instrument's envelope feel — a product decision, not
a Q-W0 cleanup.
### T1-09 — Takeover-declick: `declickR_` is dead state
- **Location:** `src/vst/sampler_core.cpp` (~598599, 639646, 515518).
- **Mechanism:** both channels deliberately share one blend weight (`declickL_` — commented), but
`declickR_` is still seeded and decayed every frame and never read for output. Dead state that
invites a future L/R-weight divergence bug. The blend itself audits **clean**: `out' =
(1w)·out + w·ref` is a convex combination for w ∈ [0,1], so `|out'| ≤ max(|out|,|ref|)` — the
rev-2 boundedness claim is mathematically sound, the boundary-frame identity holds, and the
ring-out path on voice end is handled (the peer-path symmetry is present).
- **Severity:** Low (hygiene; no audio effect).
- **Disposition proposal:** fix-now-trivial (delete the field or rename the shared weight) — fold
into any Q-W0 edit of `sampler_core`; not worth its own change otherwise.
### T1-10 — `planWavTruncate` silently drops chunks located after `data`
- **Location:** `src/wav_trim.cpp` `planWavTruncate` (~151), `capture_realtime.cpp`
`trimAutoTailInPlace`.
- **Mechanism:** the plan truncates the file at `dataByteOffset + keptDataBytes`. Any RIFF chunk
REAPER wrote *after* the data chunk (bext/iXML/smpl orderings vary by writer) is discarded; the
RIFF size is patched consistently so the result is a valid WAV, but metadata is lost without a
trace. The PCM and the trim boundary math themselves audit clean (file-rate-authoritative frame
math, scan confined to the tail region, -72 dB threshold single-sourced from
`kAutoTrimThresholdDb`, one-frame-past-last-audible per spec, no-trim fallbacks total).
- **Severity:** Low (metadata only; audio unaffected; trim is a convenience path).
- **Disposition proposal:** document-and-defer — note the behavior in the header's FORMAT
ASSUMPTION block when the file is next touched. Preserving trailing chunks would complicate the
single-truncating-write design for no audio benefit.
### T1-11 — `makeUniqueTag` has one-second resolution (collision window)
- **Location:** `src/capture.cpp` (~224227) and `src/capture_realtime.cpp` (~120123).
- **Mechanism:** the uniqueness tag is `std::time(nullptr)` — 1 s resolution. Two captures of the
same `baseName` within the same wall-clock second derive the same file stem: the offline path
would overwrite the first render's file and mint two Samples with colliding ids. Reachable in
practice via `batch_capture` driving several short renders back-to-back. The realtime path
can't self-collide (transport exclusivity) but shares the pattern. DSP-adjacent rather than
DSP; recorded here because the capture paths are this track's surface — Track 2 may claim it.
- **Severity:** Low-Med (silent data loss on collision; narrow window).
- **Disposition proposal:** fix-now candidate, trivial: append a per-session monotonic counter to
the tag (both call sites). Belongs wherever Track 2/triage routes capture-path hygiene; Q-W3
(main/orchestration split) is the nearest wave that opens the extension capture flow, else Q-W0.
---
## Surfaces that came back clean
- **`src/peaks.*` — clean.** The bin partition `[b·frames/binCount, (b+1)·frames/binCount)` is
exact integer math, remainder-distributing, no dropped tail; overflow guarded; short-buffer
clamped; per-channel with no fold (invariant honored). `columnMinMax` mirrors the partition with
64-bit products and the enclosing-bin fallback. `lastFrameAboveThreshold` scans backward with a
correct strictly-greater test and no wrap hazard.
- **`src/wav_trim.*` — clean** except T1-10 (metadata note). Chunk walk is bounds-checked and
total; even-byte padding honored; extensible-format float discrimination via the SubFormat GUID
leading tag is correct; LE reads via `memcpy` (no aliasing UB); truncate plan never grows.
- **`src/capture.cpp` (offline) — clean** from the algorithm-quality lens except T1-11. Exact
unrounded bounds, dither forced off (bit-identical repeats), float32-only with ground-truth
format blob, surgical trim-end normalize only in Auto, full snapshot/restore RAII. The
precision-invariant plumbing is disciplined.
- **`src/capture_realtime.cpp` + `src/realtime_record.h` — clean** except T1-11 (shared) and the
already-in-code DAW-verify flags (take/frame-0 alignment assumption for the trim; abort()'s
best-effort finalize racing the flush — both explicitly documented in place, correctly scoped).
The record state machine's decisions are pure and ceiling-bounded; the trim is best-effort and
never eats the range body.
- **`src/vst/master_gain.*` — clean.** Taper endpoints single-sourced; norm-0 true-zero detent
with the finite 60 dB floor; unity at ≈ 0.714 as documented; inverse collapses sub-floor values
to the detent (documented); non-finite input clamped. The dB↔linear math is correct.
- **`src/vst/velocity_curve.*` — clean.** The FritschCarlson tangent is the standard
weighted-harmonic-mean form (w₁ = 2h₂ + h₁, w₂ = h₂ + 2h₁), which bounds m ≤ 3·min(d₁,d₂) —
monotonicity and no-overshoot inside [0,1] hold as claimed; sign-change/flat neighbors pin to 0;
zero-span steps and coincident-X knots are handled; deserialize repairs the invariant
defensively. `eval` once per note-on keeps it off the per-frame path.
- **`sampler_core` voice/steal/mono machinery — clean** (beyond the findings above): the steal
policy is deterministic and as documented; the mono held-stack has correct range guards against
uint8 aliasing, order-preserving removal, per-note velocity for retrigger fallback, and CC 123
as its only reset path; `soundingNote()` correctly excludes ring-out tails from the Preserve cap
and legato predicates; the two-tier panic semantics are right; both render overloads share one
summation discipline with no allocation; envelope/keymap resolution honors the rate-free-seconds
invariant (frames resolved at keymap build against the live rate — the prior frame-domain
incident is not repeated here). The per-frame `std::pow` when the pitch envelope is active is
bounded and acceptable.
- **`pitch_shift` core machinery — clean** (beyond the findings above): the safe-band geometry,
filled-span clamps, normalized correlation, parabolic sub-sample refinement, complementary
raised-cosine fade (correct for phase-aligned content; the 6 dB midpoint on uncorrelated
content is a documented, benign trade), `freezeTail`'s fade re-anchor continuity, and the
down-shift ring-lap margin all audit sound. Down-shift writer-lap is unreachable for any ratio
above ≈ 109 st; up-shift fade drain is covered to +24 st and beyond by the ratio-scaled cap
(T1-02 is the slew case only).
---
## Summary table
| ID | Surface | Finding | Severity | Disposition proposal |
|-------|----------------------------------|------------------------------------------------------------|----------|---------------------------------------------------|
| T1-01 | pitch_shift + sampler_core | Stereo Preserve: independent L/R splice alignment | High | Bounded SOLA fix (linked lag) — recommend fix-now in Q-W0; Daniel's call |
| T1-02 | pitch_shift | Ratio slew mid-fade can lap the outgoing tap | Med | Document-and-defer; bounded re-cap noted |
| T1-03 | sampler_core (Preserve prime) | Prime ignores Trigger playEnd / pads short samples | Med | Bounded fix candidate in Q-W0; else defer w/ note |
| T1-04 | sampler_core (loop) | No sustain-loop crossfade (hard seam) | Med | Document-and-defer (feature, not reorg scope) |
| T1-05 | sampler_core + pitch_shift | Linear interp, no band-limiting on repitch | Low | Document-and-defer (recorded trade-off) |
| T1-06 | pitch_shift | Coarse-search HF mis-lock; ≥ ~80 Hz alignment bound | Low | Document-and-defer (stated operating range) |
| T1-07 | pitch_shift | maxJump clamp comment contradicts code | Low | Fix-now (comment-only), in Q-W0 |
| T1-08 | sampler_core (ADSR) | Linear-amplitude decay/release segments | Low | Document-and-defer (product decision) |
| T1-09 | sampler_core (declick) | `declickR_` dead state | Low | Fix-now-trivial, fold into any Q-W0 edit |
| T1-10 | wav_trim | Truncate drops post-`data` chunks (metadata) | Low | Document-and-defer (header note) |
| T1-11 | capture.cpp + capture_realtime | 1 s-resolution unique tag → batch collision window | Low-Med | Fix-now candidate (monotonic counter); route at triage |
Clean surfaces: `peaks`, `master_gain`, `velocity_curve` (fully); `wav_trim`, offline + realtime
capture paths, and the non-flagged machinery of `sampler_core` / `pitch_shift` (clean with the
noted exceptions above).