fix(audio): pace visualizer frames by the audio time they cover, not the display refresh

Releasing one spectrum frame per display refresh still looked jerky on device:
bursts of fast motion with regular freezes two or three times a second.

Measured on a Pixel 9a at 120 Hz with temporary logging: frames reach the UI in
clusters of ~15 roughly every 330 ms, because the audio pipeline fills its
output buffer in chunks. Released one per 8.3 ms refresh, a cluster played out
in ~110 ms (3x too fast) and the screen then froze for ~220 ms until the next
one — 161 stalls over 52.8 s, 3.05 a second, averaging 211 ms.

Each Spectrum now carries the audio time it describes (fft size / sample rate,
~21 ms at 48 kHz) and SpectrumTrail releases a frame only once the previous one
has covered its time, so a cluster spreads across the gap to the next. Time lost
while starved is not owed back: after a gap, frames resume from the current
frame time rather than being dumped at once to catch up. The backlog is capped
at 24 frames (~0.5 s) so 2x playback speed cannot leave the picture drifting
behind the sound; ordinary clusters peak at ~15 and are never trimmed.

The duration defaults to zero, meaning "show on arrival", so producers that do
not pace to audio — the synthetic preview emits one frame per display frame —
keep their existing behaviour.

Same device afterwards: 46.5 frames/s (48 kHz / 1024 = 46.9), median gap 24 ms,
p95 26 ms, 2 gaps over 150 ms in 49.3 s, peak backlog 15.

Also corrects the PcmTapRegistry comment from the buffering fix, which blamed a
single large decoder buffer. The logs show decoder calls mostly carry one fft
frame; the burst is many calls landing within a few ms, which the main-thread
collector cannot interleave with either way.
This commit is contained in:
davotoula
2026-09-27 08:37:37 +02:00
parent 991687a33e
commit 33f684773b
7 changed files with 226 additions and 72 deletions
@@ -66,6 +66,10 @@ class SpectrumAudioBufferSink(
private var channels = 1
private var encoding = C.ENCODING_PCM_16BIT
// Audio time one fft frame covers: fftSize samples per channel at the stream's sample rate.
// Zero until the first flush reports a rate, which leaves the frame unpaced rather than wrong.
private var frameDurationNanos = 0L
@kotlin.OptIn(ExperimentalCoroutinesApi::class)
override fun flush(
sampleRateHz: Int,
@@ -74,6 +78,7 @@ class SpectrumAudioBufferSink(
) {
this.channels = channelCount.coerceAtLeast(1)
this.encoding = encoding
this.frameDurationNanos = if (sampleRateHz > 0) fftSize * 1_000_000_000L / sampleRateHz else 0L
filled = 0
output?.resetReplayCache()
}
@@ -99,7 +104,7 @@ class SpectrumAudioBufferSink(
// Skip the DC bin (index 0): toLogBins ignores it, so letting a DC/offset component be the
// peak would scale every audible bin toward zero and wash the spectrum out.
mags.normalizeToPeakInPlace(fromIndex = 1)
output?.tryEmit(Spectrum(mags.toLogBins(binCount)))
output?.tryEmit(Spectrum(mags.toLogBins(binCount), frameDurationNanos))
}
}
@@ -113,8 +118,8 @@ class SpectrumAudioBufferSink(
object PcmTapRegistry {
private const val MAX_TRACKED_FLOWS = 64
// Frames buffered per media flow beyond the 1-frame replay. One decoder buffer is typically a
// handful of 1024-sample hops; 63 leaves room for an unusually large one without letting a
// Frames buffered per media flow beyond the 1-frame replay. A cluster is ~15 fft frames (~0.33 s
// of audio, measured on a Pixel 9a); 63 leaves room for an unusually large one without letting a
// stalled UI bank more than ~1.5 s of stale spectrum.
private const val SPECTRUM_BUFFER_FRAMES = 63
@@ -173,12 +178,13 @@ object PcmTapRegistry {
if (!fedByLiveSink && !stillCollected) iter.remove()
}
}
// The audio thread emits every fft frame of a decoder buffer synchronously, with no
// suspension point, while the UI collector sits on the main dispatcher and cannot
// interleave. A 2-slot buffer therefore capped the visualizer at two frames per
// decoder buffer however much audio it carried — the update rate tracked the decoder
// buffer rate (~5 Hz), not the ~43 Hz the fft produces. Hold a whole burst instead,
// and drop the STALEST frame rather than the newest when the UI does fall behind.
// The audio thread emits in clusters — the pipeline fills its output buffer ~3 times a
// second, so ~15 fft frames land within a few ms of each other (one per decoder call,
// or several from one large call) — while the UI collector sits on the main dispatcher
// and cannot run in between. A 2-slot buffer therefore kept only ~2 frames of each
// cluster and dropped the rest. Hold a whole cluster instead, and drop the STALEST
// frame rather than the newest if the UI does fall behind. (Spreading the cluster
// back over time is SpectrumTrail's job, not this flow's.)
MutableSharedFlow(
replay = 1,
extraBufferCapacity = SPECTRUM_BUFFER_FRAMES,
@@ -136,4 +136,29 @@ class SpectrumAudioBufferSinkTest {
assertTrue("non-16-bit PCM must not emit a spectrum", out.replayCache.isEmpty())
}
// Pacing draws each frame for the audio time it covers, so the sink has to say how long that is.
// It is fftSize samples PER CHANNEL: a stereo stream must not report half (or double) the time.
@Test
fun eachFrameCarriesTheAudioTimeItCovers() {
val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount)
val out = MutableSharedFlow<Spectrum>(replay = 1, extraBufferCapacity = 1)
sink.output = out
sink.flush(48000, 1, C.ENCODING_PCM_16BIT)
sink.handleBuffer(monoPcm(sineShorts(k = 2, n = fftSize)))
assertEquals(fftSize * 1_000_000_000L / 48000, out.replayCache.last().durationNanos)
}
@Test
fun stereoFramesCoverTheSameTimeAsMono() {
val sink = SpectrumAudioBufferSink(fftSize = fftSize, binCount = binCount)
val out = MutableSharedFlow<Spectrum>(replay = 1, extraBufferCapacity = 1)
sink.output = out
sink.flush(44100, 2, C.ENCODING_PCM_16BIT)
val tone = sineShorts(k = 2, n = fftSize)
sink.handleBuffer(interleavedStereoPcm(tone, tone))
assertEquals(fftSize * 1_000_000_000L / 44100, out.replayCache.last().durationNanos)
}
}
@@ -26,9 +26,16 @@ import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.log10
/** One frame of frequency-domain magnitudes, ordered low→high Hz and normalized 0f..1f. */
/**
* One frame of frequency-domain magnitudes, ordered low→high Hz and normalized 0f..1f.
*
* [durationNanos] is how much audio the frame describes (fft size / sample rate), which is how long
* it should stay on screen. Zero — the default — means the producer is not pacing to audio (the
* synthetic preview emits one per display frame), so the frame is shown as soon as it arrives.
*/
class Spectrum(
val bins: FloatArray,
val durationNanos: Long = 0L,
)
/**
@@ -21,17 +21,13 @@
package com.vitorpamplona.amethyst.commons.audio
/**
* Spreads a bursty spectrum stream over the frames that draw it.
* The bounded queue between the audio tap and the visualizer.
*
* The decoder hands the pcm tap a whole buffer at once, so spectrum frames arrive in bursts that run
* ahead of what is audible (the tap sits upstream of the audio output — see `delayedByFrames`).
* Delivering that burst straight into Compose state collapses it: every write lands before the next
* vsync, so the burst draws ONCE, showing only its newest frame. The visual then steps at the
* decoder-buffer rate instead of the ~43 Hz the fft produces.
*
* Buffering here and taking exactly one frame per drawn frame turns the burst back into motion.
* Production (~43 Hz) is slower than the display (60 Hz+), so the queue drains and sits near empty;
* [next] then returns null and the caller simply holds the frame it already has.
* Frames reach the UI in clusters, because the audio pipeline fills its output buffer in chunks
* (~15 frames about three times a second, measured on a Pixel 9a). This holds them until
* [SpectrumTrail] releases each one when its audio time comes due. It is bounded and evicts the
* stalest frame, so faster-than-real-time playback or a stalled UI cannot bank an ever-growing
* backlog and leave the picture permanently behind the sound.
*
* Not thread-safe by design: both ends run on the UI dispatcher.
*/
@@ -48,6 +44,8 @@ class SpectrumPacer(
queue.addLast(frame)
}
fun isEmpty(): Boolean = queue.isEmpty()
/** The next frame to draw, or null when the queue is empty and the last frame should persist. */
fun next(): Spectrum? = queue.removeFirstOrNull()
@@ -21,36 +21,68 @@
package com.vitorpamplona.amethyst.commons.audio
/**
* The whole per-displayed-frame step of the spectrum visualizer: take one queued frame, apply the
* decay trail against what is already on screen, and return a fresh array to draw.
* The whole per-displayed-frame step of the spectrum visualizer: release queued frames as their
* audio time comes due, apply the decay trail against what is already on screen, and return a
* fresh array to draw.
*
* Bursty spectrum frames are paced through a [SpectrumPacer] (see its docs for why), and the decay
* gives each bin an instant attack and a gradual release, so bars snap up to a transient and fall
* back smoothly instead of flickering.
* Frames arrive in clusters (see [SpectrumPacer]). Releasing one per display refresh drained a
* ~15-frame cluster in ~110 ms at 120 Hz and then froze until the next one — measured as ~3 stalls
* of ~210 ms every second. Each frame instead stays up for the [Spectrum.durationNanos] of audio it
* describes, so a cluster spreads across the gap to the next. Time lost while starved is not owed
* back: when frames return after a gap they resume from the current frame time rather than being
* dumped at once to catch up.
*
* This lives here, rather than inline in the Compose collector, so the queueing, starvation and
* decay rules are unit-testable without a Compose harness; the composable is left as plumbing.
* The decay gives each bin an instant attack and a gradual release, so bars snap up to a transient
* and fall back smoothly instead of flickering.
*
* This lives here, rather than inline in the Compose collector, so the pacing, starvation and decay
* rules are unit-testable without a Compose harness; the composable is left as plumbing.
*
* Not thread-safe by design: both ends run on the UI dispatcher.
*/
class SpectrumTrail(
private val decay: Float,
capacity: Int = SpectrumPacer.DEFAULT_CAPACITY,
capacity: Int = MAX_BACKLOG_FRAMES,
) {
private val pacer = SpectrumPacer(capacity)
private var drawn = FloatArray(0)
/** Queues a freshly decoded frame, evicting the stalest if the UI has fallen behind. */
// When the frame at the head of the queue may be drawn, in the caller's frame-clock nanos.
private var dueNanos = Long.MIN_VALUE
private var starved = true
/** Queues a freshly decoded frame, evicting the stalest if the backlog is at capacity. */
fun offer(frame: Spectrum) = pacer.offer(frame)
/**
* The next array to draw, or null when no frame is queued and the current one should persist.
* The array to draw at [frameTimeNanos], or null when nothing new is due and the current one
* should persist.
*
* A fresh array each time is intentional: `mutableStateOf` compares by reference, so a new
* instance is what signals Compose to redraw. Do NOT switch to in-place mutation.
*/
fun nextOrNull(): FloatArray? {
val frame = pacer.next() ?: return null
fun nextOrNull(frameTimeNanos: Long): FloatArray? {
if (pacer.isEmpty()) {
starved = true
return null
}
if (starved) {
// Resume from now, but never earlier than the last drawn frame's audio time runs out.
dueNanos = maxOf(dueNanos, frameTimeNanos)
starved = false
}
var result: FloatArray? = null
while (dueNanos <= frameTimeNanos) {
val frame = pacer.next() ?: break
result = decayedFrom(frame)
dueNanos += frame.durationNanos
}
return result
}
private fun decayedFrom(frame: Spectrum): FloatArray {
val prev = drawn
val next =
FloatArray(frame.bins.size) { i ->
@@ -60,4 +92,11 @@ class SpectrumTrail(
drawn = next
return next
}
companion object {
// A cluster is ~15 frames, so 24 (~0.5 s of audio) never trims ordinary playback, while
// capping how far the picture can lag the sound when frames come faster than real time
// (2x playback speed).
const val MAX_BACKLOG_FRAMES = 24
}
}
@@ -24,49 +24,139 @@ import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertNotSame
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The whole per-displayed-frame step the visualizer runs: take one queued frame, apply the decay
* trail against what is already drawn, and hand back a fresh array. Extracted from SpectrumCanvas
* so it is testable without a Compose harness — the composable is left as plumbing around this.
* The per-displayed-frame step of the visualizer: release queued frames as their audio time comes
* due, apply the decay trail against what is drawn, and hand back a fresh array.
*
* Frames reach the UI in clusters (~15 at a time, ~3 times a second, measured on a Pixel 9a) because
* the audio pipeline fills its output buffer in chunks. Releasing one per display refresh drained a
* cluster in ~110 ms at 120 Hz and then froze for ~220 ms. Frames must instead be released at the
* rate of the audio they describe, so a cluster spreads across the gap to the next one.
*/
class SpectrumTrailTest {
private fun frame(vararg bins: Float) = Spectrum(bins)
private val ms = 1_000_000L
private fun frame(
value: Float,
durationMs: Long = 20,
) = Spectrum(floatArrayOf(value), durationNanos = durationMs * ms)
@Test
fun yieldsNothingWhenStarvedSoTheDrawnFrameIsHeld() {
val trail = SpectrumTrail(decay = 0.5f)
assertNull(trail.nextOrNull())
assertNull(trail.nextOrNull(0))
}
@Test
fun theFirstFrameIsDrawnAsIsWithNothingToDecayFrom() {
fun theFirstFrameIsDrawnOnArrivalWithNothingToDecayFrom() {
val trail = SpectrumTrail(decay = 0.5f)
trail.offer(frame(1f, 0.5f, 0f))
trail.offer(frame(1f))
assertContentEquals(floatArrayOf(1f, 0.5f, 0f), trail.nextOrNull())
assertContentEquals(floatArrayOf(1f), trail.nextOrNull(0))
}
@Test
fun aClusterIsSpreadOverTheAudioTimeItCoversNotTheScreenRefresh() {
val trail = SpectrumTrail(decay = 0f)
trail.offer(frame(1f))
trail.offer(frame(2f))
trail.offer(frame(3f))
// 120 Hz refreshes (~8 ms) must not drain a frame that covers 20 ms of audio.
assertContentEquals(floatArrayOf(1f), trail.nextOrNull(0))
assertNull(trail.nextOrNull(8 * ms))
assertNull(trail.nextOrNull(16 * ms))
assertContentEquals(floatArrayOf(2f), trail.nextOrNull(20 * ms))
assertNull(trail.nextOrNull(33 * ms))
assertContentEquals(floatArrayOf(3f), trail.nextOrNull(40 * ms))
assertNull(trail.nextOrNull(48 * ms))
}
@Test
fun afterStarvingTheNextClusterStartsWhenItArrivesInsteadOfBeingDumpedToCatchUp() {
val trail = SpectrumTrail(decay = 0f)
trail.offer(frame(1f))
assertContentEquals(floatArrayOf(1f), trail.nextOrNull(0))
assertNull(trail.nextOrNull(100 * ms)) // starved for a while
trail.offer(frame(2f))
trail.offer(frame(3f))
// The idle time is not owed: frame 2 shows now and frame 3 a full frame later.
assertContentEquals(floatArrayOf(2f), trail.nextOrNull(300 * ms))
assertNull(trail.nextOrNull(308 * ms))
assertContentEquals(floatArrayOf(3f), trail.nextOrNull(320 * ms))
}
@Test
fun aFrameArrivingBeforeThePreviousOneElapsedStillWaitsItsTurn() {
val trail = SpectrumTrail(decay = 0f)
trail.offer(frame(1f))
assertContentEquals(floatArrayOf(1f), trail.nextOrNull(0))
assertNull(trail.nextOrNull(8 * ms)) // queue momentarily empty
trail.offer(frame(2f))
assertNull(trail.nextOrNull(16 * ms)) // frame 1 still covers until 20 ms
assertContentEquals(floatArrayOf(2f), trail.nextOrNull(24 * ms))
}
@Test
fun aSlowFrameClockJumpsToTheLatestDueFrame() {
val trail = SpectrumTrail(decay = 0f)
trail.offer(frame(1f))
trail.offer(frame(2f))
trail.offer(frame(3f))
assertContentEquals(floatArrayOf(1f), trail.nextOrNull(0))
// A janky 45 ms frame: frames 2 (due 20) and 3 (due 40) are both due; show the newest.
assertContentEquals(floatArrayOf(3f), trail.nextOrNull(45 * ms))
}
@Test
fun aFrameWithNoDurationIsShownOnArrival() {
// Producers that do not declare a duration (the synthetic preview emits one per display
// frame) keep the old behaviour: whatever is queued is current.
val trail = SpectrumTrail(decay = 0f)
trail.offer(Spectrum(floatArrayOf(1f)))
trail.offer(Spectrum(floatArrayOf(2f)))
assertContentEquals(floatArrayOf(2f), trail.nextOrNull(0))
}
@Test
fun aBacklogIsCappedByDroppingTheStalestFrame() {
// Faster-than-real-time playback (2x speed) produces frames faster than they come due;
// the cap bounds how far the picture can lag the sound.
val trail = SpectrumTrail(decay = 0f, capacity = 2)
trail.offer(frame(1f))
trail.offer(frame(2f))
trail.offer(frame(3f)) // evicts frame 1
assertContentEquals(floatArrayOf(2f), trail.nextOrNull(0))
assertContentEquals(floatArrayOf(3f), trail.nextOrNull(20 * ms))
assertNull(trail.nextOrNull(40 * ms))
}
@Test
fun aRisingBinTakesItsNewValueImmediately() {
val trail = SpectrumTrail(decay = 0.5f)
trail.offer(frame(0.2f))
trail.nextOrNull()
trail.nextOrNull(0)
trail.offer(frame(0.9f))
assertContentEquals(floatArrayOf(0.9f), trail.nextOrNull())
assertContentEquals(floatArrayOf(0.9f), trail.nextOrNull(20 * ms))
}
@Test
fun aFallingBinDecaysFromWhatWasDrawnRatherThanSnapping() {
val trail = SpectrumTrail(decay = 0.5f)
trail.offer(frame(1f))
trail.nextOrNull()
trail.nextOrNull(0)
trail.offer(frame(0f))
// 1f * 0.5 decay beats the new 0f, so the bar falls gradually.
assertContentEquals(floatArrayOf(0.5f), trail.nextOrNull())
assertContentEquals(floatArrayOf(0.5f), trail.nextOrNull(20 * ms))
}
@Test
@@ -75,22 +165,10 @@ class SpectrumTrailTest {
trail.offer(frame(1f))
trail.offer(frame(1f))
val first = trail.nextOrNull()
val second = trail.nextOrNull()
val first = trail.nextOrNull(0)
val second = trail.nextOrNull(20 * ms)
// mutableStateOf compares by reference; reusing one array would never trigger a redraw.
assertNotSame(first, second)
}
@Test
fun aBacklogIsBoundedByDroppingTheStalestFrame() {
val trail = SpectrumTrail(decay = 0f, capacity = 2)
trail.offer(frame(1f))
trail.offer(frame(2f))
trail.offer(frame(3f)) // evicts the 1f frame
assertContentEquals(floatArrayOf(2f), trail.nextOrNull())
assertContentEquals(floatArrayOf(3f), trail.nextOrNull())
assertTrue(trail.nextOrNull() == null)
}
}
@@ -26,6 +26,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameMillis
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.drawscope.DrawScope
import kotlinx.coroutines.flow.Flow
@@ -35,10 +36,10 @@ import kotlinx.coroutines.flow.Flow
* then calls [draw] inside the Canvas draw lambda. The fast-changing state is read
* ONLY in the draw lambda, so new frames trigger the draw phase, never recomposition.
*
* Frames are paced one per displayed frame through [SpectrumTrail] — they arrive from the decoder in
* bursts, and writing a burst straight into state would collapse it into a single draw. The pacing
* loop runs every frame but only writes state when a spectrum frame is actually queued, so the redraw
* rate still tracks the ~43 Hz the fft produces rather than the display.
* Frames are released through [SpectrumTrail] as the audio they describe comes due — they arrive
* in clusters, and neither dumping a cluster into state nor draining it one per vsync looks live.
* The pacing loop runs every frame but only writes state when a frame is due, so the redraw rate
* tracks the ~43-47 Hz the fft produces rather than the display.
*
* Pass [animated] = false for non-time-varying styles (bars, radial): that drops the monotonic clock,
* whose whole purpose is to redraw every frame even when the spectrum has not moved.
@@ -54,10 +55,10 @@ fun SpectrumCanvas(
) {
val smoothed = remember { mutableStateOf(FloatArray(0)) }
// Frames arrive in decoder-sized bursts that run ahead of the audio, so they are queued and drawn
// one per displayed frame. Writing a whole burst straight into `smoothed` would collapse it into a
// single draw at the next vsync and the visual would step at the decoder-buffer rate. Queueing and
// decay live in SpectrumTrail so they are testable without a Compose harness.
// Frames arrive in clusters, so they are queued and released as their audio time comes due.
// Writing a cluster straight into `smoothed` collapses it into one draw; releasing one per vsync
// races through it and then freezes. Pacing and decay live in SpectrumTrail so they are testable
// without a Compose harness.
val trail = remember(spectrum, decay) { SpectrumTrail(decay) }
LaunchedEffect(spectrum, trail) {
spectrum.collect { trail.offer(it) }
@@ -65,10 +66,10 @@ fun SpectrumCanvas(
LaunchedEffect(trail) {
while (true) {
withFrameMillis { }
// Null means starved. Production (~43 Hz) is slower than the display, so this is the common
// case between frames: hold what is drawn rather than redrawing identical bins.
smoothed.value = trail.nextOrNull() ?: continue
val frameTimeNanos = withFrameNanos { it }
// Null means nothing new is due. Frames cover ~21 ms of audio against an 8-16 ms refresh,
// so this is the common case: hold what is drawn rather than redrawing identical bins.
smoothed.value = trail.nextOrNull(frameTimeNanos) ?: continue
}
}