280 lines
22 KiB
Markdown
280 lines
22 KiB
Markdown
# 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, ~577–589) +
|
||
`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/|ratio−1| 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 (~301–311) + `process()` tap
|
||
advance (~360–366).
|
||
- **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 (~375–400) vs. the GA3
|
||
`feedBound` in `advanceFrame` (~568–572).
|
||
- **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 (~493–498, 610–612).
|
||
- **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 (~607–624); `src/vst/pitch_shift.cpp`
|
||
`readTap` (~172–185).
|
||
- **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 (~240–257) and `configure()`
|
||
geometry (~68–72).
|
||
- **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` ~196–210.
|
||
- **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` (~131–170).
|
||
- **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` (~598–599, 639–646, 515–518).
|
||
- **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' =
|
||
(1−w)·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` (~224–227) and `src/capture_realtime.cpp` (~120–123).
|
||
- **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 Fritsch–Carlson 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).
|