22 KiB
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.cppVoice::advanceFrame(Preserve branch, ~577–589) +src/vst/pitch_shift.cppPitchShifter::splice. - Mechanism: a stereo Preserve voice owns two
PitchShifters, each running its own correlation search on its own channel's PCM.bestLag + fracdiffer 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 + fracand splice schedule to both channels. Standard practice for stereo SOLA. It reshapes thePitchShifterseam 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.cppsplice()fade-length cap (~301–311) +process()tap advance (~360–366). - Mechanism:
fadeLen_is capped from the drain headroom at splice time using the then-currentratio_. The pitch envelope legitimately slews the ratio per frame (setShiftRatiomid-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 setsfadeLen_ = window/4with 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 thedLow≈ 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 (thefreezeTail()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.cppVoice::startprime block (~375–400) vs. the GA3feedBoundinadvanceFrame(~568–572). - Mechanism: the per-frame feed treats
playEnd_(Trigger) /frameCountas source exhaustion and freezes the writer so "no padding ever enters the ring" (GA3). Butstart()primes a full window frompcm[q]bounded only byframeCount— not byplayEnd_— and pads with zeros past the sample end while declaring the whole windowfilled_. 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) beforereadPos_ ≥ 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 callfreezeTail()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 inVoice::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.cppVoice::advanceFrameloop 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.cppVarispeed read (~607–624);src/vst/pitch_shift.cppreadTap(~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.cppsplice()search loops (~240–257) andconfigure()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- 1is "one sample of conservative margin" — but-1permits 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 atd + jump + maxLag + 1(the interpolator'si1 = i0 + 1read-ahead moves younger, not deeper), so the code's-1is exactly tight and the comment's-2double-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.cppAdsrEnvelope::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), butdeclickR_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·refis 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.cppplanWavTruncate(~151),capture_realtime.cpptrimAutoTailInPlace. - 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 fromkAutoTrimThresholdDb, 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) andsrc/capture_realtime.cpp(~120–123). - Mechanism: the uniqueness tag is
std::time(nullptr)— 1 s resolution. Two captures of the samebaseNamewithin 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 viabatch_capturedriving 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).columnMinMaxmirrors the partition with 64-bit products and the enclosing-bin fallback.lastFrameAboveThresholdscans 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 viamemcpy(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.evalonce per note-on keeps it off the per-frame path.sampler_corevoice/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-framestd::powwhen the pitch envelope is active is bounded and acceptable.pitch_shiftcore 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).