Skip to content

Convert linear resampler to integer-rational timestamp/position math #91

@chrisuthe

Description

@chrisuthe

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):

  1. 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.
  2. 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.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions