Skip to content

Android: keep one output rate so joins across sample rates stay gapless - #237

Merged
ingoau merged 2 commits into
mainfrom
t3/automix-sample-rate-gap
Nov 10, 2026
Merged

ingoau merged 2 commits into
mainfrom
t3/automix-sample-rate-gap

Conversation

@ingoau

@ingoau ingoau commented Nov 10, 2026 •

Copy link
Copy Markdown
Owner

Why

AutoMix across sample rates still left a short gap now and then. The cause is ExoPlayer's DefaultAudioSink, not the mixer: it reuses its AudioTrack only when the output format is unchanged (canReuseAudioOutput). At a 44.1 ↔ 48 kHz join it played A out to silence, released the track and built a new one. That leaves a gap between tracks, and in the middle of A's audio when the join is a mix.

What

MixProcessor now always outputs at one rate, so every join is an ordinary gapless join and the sink keeps its track.

  • Output rate (OutputRate.kt): the first stream's rate after a reset when it is 44.1 or 48 kHz, else the device's (PROPERTY_OUTPUT_SAMPLE_RATE). A library at one rate is never resampled and stays bit-exact. There's no extra CPU and no double resampling over 44.1 kHz Bluetooth.
  • Resampler (crates/hocket-android/src/resampler.rs, JNI): a Kaiser-windowed sinc with exact taps per output phase (160 rows for 44.1 ↔ 48 kHz, ~80 dB stopband). Output frame j is input frame j × in / out exactly. It holds back only the kernel's ~1.5 ms lookahead, not a 1024-frame chunk. It runs after the mixer, so the mixer's sample-exact item accounting is unchanged.
  • Seams:
    • A gapless join at the same format keeps going through the same resampler, so there's no seam inside continuous audio.
    • When the next stream is at another format (configured while A plays), A's end of stream lets out A's last frames. When the mix follows, it uses the armed tail's after (A's real continuation) as lookahead. B's resampler is primed with the tail's lead, so the switch from A to the tail is as clean as at one rate.
  • Tail render: automix_prepare_tail decodes from 60 ms before the tail start. That gives the tail's first frames filter history; before, they were resampled from silence. It returns lead and after along with the tail.

Testing

  • cargo test -p hocket-android (26 tests). New resampler tests cover:
    • exact output lengths
    • sine accuracy and no delay
    • alias rejection
    • chunking invariance
    • finish with lookahead and prime with history both matching an uncut stream
    • bounded memory
    • lead/after alignment in prepare_tail
  • :playback:testDebugUnitTest: new OutputRateTest (11 tests) covers:
    • latching
    • passthrough
    • a same-format join continuing
    • a seek restarting
    • exact finish with lookahead
    • priming
    • the final track coming out whole at the end of the playlist
    • a lead still priming a resampler that hasn't had input yet
    • in MixProcessor: a fixed output format across items, end of stream holding isEnded until A's last frames are out, and the resampler staying open at a same-format end of stream
  • Not tried on a device or emulator yet.

Known limits

  • A channel-count change (mono ↔ stereo) still rebuilds the AudioTrack.

🤖 Generated with Claude Code

ingoau and others added 2 commits November 10, 2026 13:12
ExoPlayer's DefaultAudioSink can only reuse its AudioTrack when the output
format is unchanged. At a join between tracks at different sample rates it
played A out to silence, released the track and built a new one, which left a
short gap: between tracks, and in the middle of A's audio when AutoMix mixed
across rates.

MixProcessor now leaves at one rate (the first stream's when it is 44.1 or
48 kHz, else the device's), converting other streams after the mixer with a
new streaming resampler in hocket-android (JNI): a Kaiser-windowed sinc with
exact per-phase taps, output frame j at input j * in / out, ~1.5 ms lookahead.

- A gapless join at the same format continues through the same resampler.
- When the next stream is at another format, A's end of stream emits A's last
  frames (with the armed tail's continuation of A as lookahead), and B's
  resampler is primed with the tail's lead, so the switch from A to the tail
  has no seam.
- prepare_tail decodes from 30 ms before the tail start and returns the lead
  and lookahead alongside the tail; it now uses the same resampler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The end of the playlist (MixClockSink.playToEndOfStream) finishes a
  resampled stream, so its held-back last ~1.5 ms is played.
- A lead still primes a resampler that hasn't had input (a change of channel
  count flushes twice before the mix starts).
- The finish path reads the output buffer after it may have grown.
- prepare_tail floors the context in frames like the decoder and the clip
  point, sizes its buffer in u64, and the tail render decodes 60 ms of
  context so short-step rate pairs still get a lead.
- The resampler JNI refuses one buffer as both input and output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ingoau
ingoau merged commit 7cc925d into main Nov 10, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant