Play cross-rate captures at their own pitch and length
The voice now converts the file's rate to the host's at note-on instead of assuming they match. Stage times resolve at the host rate, the filter solves at it, and the bake follows. Same-rate renders are unchanged bit for bit.
This commit is contained in:
@@ -2,20 +2,6 @@
|
||||
|
||||
Forward-looking follow-ups. Deferred by decision, not oversight — each entry records why it was deferred and what "done" looks like.
|
||||
|
||||
## The per-voice filter is solved against the WAV's sample rate, not the render rate
|
||||
|
||||
**Context (what shipped — Θ-W2-T1, the filter in the voice path).** `Voice::start` sets `filterRate_ = sample.sampleRate` — the rate read off the **decoded WAV header** — and hands it to `VoiceFilter::prepare()` and every later `setCutoffNorm()`. But the voice emits exactly one frame per **host** frame, so the rate the corner should be solved against is the project/render rate the processor already latches in `setupProcessing` (`ReaSamplerProcessor::sampleRate_`), not the file's. The rate enters the DSP only through `g = tan(pi*fc/sr)` (`engine/filter/CLAUDE.md`), so a wrong `sr` scales the realized corner by exactly the ratio of the two rates.
|
||||
|
||||
**The wart.** When capture rate ≠ project rate, the corner lands at the wrong frequency, by that ratio. A 44.1 kHz capture in a 48 kHz project puts the corner roughly **1.5 semitones sharp** (48000/44100 ≈ 1.088×); the same capture in a 96 kHz project is roughly **13.5 semitones off**. The Nyquist clamp (`kFilterNyquistFraction`) measures against the wrong Nyquist for the same reason. This falsifies the guarantee `filter_params.h` states in its own words — that the persisted value is a normalized knob position precisely so one preset does not sound different at 44.1k and 96k. The control law honors that; the solve defeats it.
|
||||
|
||||
**Intended fix.** Thread the host render rate onto `SampleData` and set `filterRate_` from it. The processor already holds `sampleRate_` from `setupProcessing` and already guards on it being non-zero before building, so the value is available at exactly the point `SampleData` is constructed — this is a plumbing change, not a new mechanism.
|
||||
|
||||
**The constraint the fix MUST handle.** The engine **already conflates the two rates everywhere** — `sample_map` resolves the AHDSR's stored seconds at the WAV's own rate, and nothing resamples the source — so a cross-rate capture already plays back sharp *and* short by the same ratio. This is an inherited assumption, not a defect introduced by the filter; the filter is simply the first module where it lands as an audible **frequency** error rather than a timing one. A fix that corrects only the filter leaves the filter rate-correct while envelope timing stays rate-wrong. That is strictly less wrong and defensible, but it splits one assumption into two, and the split must be a deliberate choice rather than a side effect of fixing the loudest symptom. Second constraint: `filterRate_ <= 0` must keep meaning **bypass** — the filter module forbids a reference, calibration, or fallback rate anywhere in itself, and a plumbing fix must not smuggle one in as a default.
|
||||
|
||||
**Priority / risk.** Deferred by ruling — Daniel, 2026-07-30: *"record this and proceed."* Inaudible whenever capture rate == project rate, which is the common case for captures this tool made in the project they belong to. Audible and large on an imported or cross-rate capture, and worse the further the two rates diverge.
|
||||
|
||||
**Done looks like.** The realized filter corner matches `filterCutoffHzFromNorm(pos)` within measurement tolerance at every combination of capture rate and project rate; the Nyquist clamp measures against the render rate; and the decision about whether envelope timing follows the same correction is recorded rather than left implicit.
|
||||
|
||||
## Filter ring-out is truncated on the source-exhaustion path
|
||||
|
||||
**Context (what shipped — Θ-W2-T1).** The per-voice filter runs between the pitch stage and the amp multiply. When `readPos_` runs past the end of the sample with no usable loop, `Voice::advanceFrame` latches `active_ = false` and returns 0 — the voice stops feeding, and whatever energy remains in the filter's two integrators is discarded rather than rung out.
|
||||
|
||||
Reference in New Issue
Block a user