Background
`pkg/audio/resample.go` uses `float64` ratio + position for linear interpolation:
```go
ratio: float64(inputRate) / float64(outputRate)
position: float64 // cumulative over each Resample() call
r.position += r.ratio
// end of call: r.position -= float64(int(r.position)) // keep fractional part only
```
This path is used in the server's Opus encode flow (pkg/sendspin/server_stream.go) whenever the source rate isn't 48 kHz, since Opus is locked to 48 k.
Why it's not a visible bug today
After comparing against aiosendspin#217 (which tracked a 17-minute audio-drift cliff to integer-truncated timestamp math in Python's server-side transformer pipeline):
- Server-side per-chunk timestamps come from `getClockMicros()` on every tick (`server_stream.go:31`), not from `pending_ts += chunkDurationUs` accumulation — so resampler-internal drift can't leak into wire timestamps.
- The resampler's `position` is reset to its fractional part at every chunk boundary, bounding per-chunk error to <1 sample period (~21µs at 48kHz) rather than unboundedly accumulating.
- At the common Opus transcode case (44.1k → 48k), the ratio (48000/44100) × 882 samples-per-20ms-chunk = 960 exactly, so the common code path produces integer output counts with no observable drift.
Net result: the bug class does not manifest under any currently-supported combination of chunk size, sample rate, and codec.
Why it's still worth fixing
- Defense-in-depth: any future change that introduces a fractional-samples-per-chunk condition (e.g., 25ms chunks, non-standard sample rates, or a cumulative-timestamp 'optimization' in the send path) would surface drift through this code.
- Float64 accumulation of a non-representable ratio (e.g., 96000/44100) technically accumulates ULP-sized error over long playback; bounded per chunk today, but not zero.
- Matches the pattern SendspinKit (AudioPlayer.swift:92-106) and aiosendspin#217 converged on: integer rational with `divmod(samples * outRate + residue, inRate)` so cumulative counts are exact.
Acceptance criteria
- `Resampler` tracks position as `position_num int / position_den int` (or equivalent integer-rational representation) rather than `float64`.
- `Resample()` output sample counts are a deterministic function of cumulative input samples, provably equal to `total_in_samples * outputRate / inputRate` (integer div with tracked residue).
- `Reset()` clears both the position and any residue state.
- Table test: sum of output samples across N `Resample()` calls matches closed-form expected for at least 10k calls at (44.1k→48k), (48k→96k), (96k→44.1k), (88.2k→48k).
- Regression test mirroring aiosendspin#217's approach: zero drift after 40,800 frames (17-minute window at 25ms chunks) across all codec × rate combos.
- No change to observable behavior at the common 44.1k↔48k Opus case.
Non-goals
- Replacing the linear interpolation itself (still linear, just with exact position math).
- Higher-quality resampling algorithms (separate concern).
- Touching the client-side receiver — this issue is server-side only.
References
- aiosendspin#217: fix(audio): drift-free timestamp arithmetic in resampler + transformers
- SendspinKit AudioPlayer.swift:92-106 — prior art for the divmod pattern
Background
`pkg/audio/resample.go` uses `float64` ratio + position for linear interpolation:
```go
ratio: float64(inputRate) / float64(outputRate)
position: float64 // cumulative over each Resample() call
r.position += r.ratio
// end of call: r.position -= float64(int(r.position)) // keep fractional part only
```
This path is used in the server's Opus encode flow (
pkg/sendspin/server_stream.go) whenever the source rate isn't 48 kHz, since Opus is locked to 48 k.Why it's not a visible bug today
After comparing against aiosendspin#217 (which tracked a 17-minute audio-drift cliff to integer-truncated timestamp math in Python's server-side transformer pipeline):
Net result: the bug class does not manifest under any currently-supported combination of chunk size, sample rate, and codec.
Why it's still worth fixing
Acceptance criteria
Non-goals
References