Delay live visualizer to compensate output latency

- fix(audio): delay live visualizer to compensate output latency
- feat(audio): route-aware visualizer delay (wired vs Bluetooth)
- docs(audio): explain the hardcoded visualizer delay; note auto-detect was rejected
This commit is contained in:
davotoula
2026-06-08 17:46:58 +02:00
parent 0e3c597b96
commit 96237535ec
3 changed files with 158 additions and 1 deletions
@@ -20,10 +20,15 @@
*/
package com.vitorpamplona.amethyst.service.playback.composable.wavefront
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -31,6 +36,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.media3.common.C
@@ -39,6 +45,7 @@ import androidx.media3.common.Tracks
import com.vitorpamplona.amethyst.commons.audio.AudioVisualizer
import com.vitorpamplona.amethyst.commons.audio.Spectrum
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.audio.delayedByFrames
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.service.playback.playerPool.PcmTapRegistry
@@ -46,6 +53,19 @@ import kotlinx.coroutines.flow.emptyFlow
fun Tracks.isAudio() = groups.isNotEmpty() && groups.none { it.type == C.TRACK_TYPE_VIDEO }
// Output-latency compensation. The spectrum is tapped upstream of the AudioTrack output buffer (and,
// over Bluetooth, the codec/transmission lag downstream of it), so the visual leads the speaker and
// must be delayed to match. Each hop is one 1024-sample FFT frame (~23 ms at 44.1 kHz). Tuned on a
// Pixel 9a against the 120-BPM beat of the synthetic demo clip (1 beat = 500 ms): wired/speaker wants
// ~20 hops, Bluetooth ~27 hops (the extra ~7 hops / ~160 ms is the BT codec latency).
//
// DEVICE/CODEC-SPECIFIC tuned constants. Android exposes no reliable output latency, and runtime
// auto-detection from player.currentPosition was tried and rejected (~3.4x off — it measures decode
// buffer depth, not the perceptual sync point). [rememberIsBluetoothOutput] only switches between
// these two presets by route; the BT figure is a per-codec average, so it may be off on other gear.
private const val FEED_VISUALIZER_DELAY_FRAMES = 20
private const val BT_VISUALIZER_DELAY_FRAMES = 27
@Composable
fun AudioPlayingAnimation(
controllerState: MediaControllerState,
@@ -94,12 +114,51 @@ fun AudioPlayingAnimation(
VisualizerStyle.RADIAL,
VisualizerStyle.AURORA,
-> {
val spectrum = remember(mediaId) { PcmTapRegistry.spectrumFor(mediaId) }
val bluetooth by rememberIsBluetoothOutput()
val delayFrames = if (bluetooth) BT_VISUALIZER_DELAY_FRAMES else FEED_VISUALIZER_DELAY_FRAMES
val spectrum = remember(mediaId, delayFrames) { PcmTapRegistry.spectrumFor(mediaId).delayedByFrames(delayFrames) }
AudioVisualizer(style = style, spectrum = spectrum, modifier = drawModifier.audioVisualizerHeight())
}
}
}
/**
* Tracks whether audio is currently routed to a Bluetooth output, updating live as devices connect or
* disconnect. Bluetooth adds codec/transmission latency downstream of AudioTrack, so the visualizer
* needs a larger delay there. Keys off CONNECTED A2DP/LE output devices — a good proxy since A2DP
* captures media playback, and Android exposes no reliable "active media route" before API 31.
*/
@Composable
private fun rememberIsBluetoothOutput(): State<Boolean> {
val context = LocalContext.current
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager }
val isBluetooth = remember { mutableStateOf(audioManager.isBluetoothOutput()) }
DisposableEffect(audioManager) {
val callback =
object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
isBluetooth.value = audioManager.isBluetoothOutput()
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
isBluetooth.value = audioManager.isBluetoothOutput()
}
}
audioManager?.registerAudioDeviceCallback(callback, null)
onDispose { audioManager?.unregisterAudioDeviceCallback(callback) }
}
return isBluetooth
}
private fun AudioManager?.isBluetoothOutput(): Boolean {
if (this == null) return false
return getDevices(AudioManager.GET_DEVICES_OUTPUTS).any { device ->
device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
device.type == AudioDeviceInfo.TYPE_BLE_HEADSET ||
device.type == AudioDeviceInfo.TYPE_BLE_SPEAKER
}
}
/**
* Fills the available height only when the parent gives a bounded height clearly larger than
* [fallback] (the full-screen media dialog); otherwise uses the fixed [fallback] strip. This avoids
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.audio
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.log10
@@ -85,3 +87,24 @@ fun FloatArray.normalizeToPeakInPlace(fromIndex: Int = 0) {
val inv = 1f / peak
for (i in fromIndex until size) this[i] *= inv
}
/**
* Holds back the latest [frames] spectra and emits the one from [frames] hops ago. The PCM tap
* computes each [Spectrum] inside the audio processor chain, upstream of the AudioTrack output
* buffer, so it runs ahead of what's audible by roughly that buffer's depth. Delaying the visual by
* the same number of decoded FFT hops (one [Spectrum] == one hop) realigns it with the speaker, in
* the same decoded-sample units as the lead, so it stays correct across startup and rate jitter.
* [frames] <= 0 is identity (e.g. previews, which carry no output latency).
*/
fun Flow<Spectrum>.delayedByFrames(frames: Int): Flow<Spectrum> =
if (frames <= 0) {
this
} else {
flow {
val queue = ArrayDeque<Spectrum>(frames + 1)
collect { frame ->
queue.addLast(frame)
if (queue.size > frames) emit(queue.removeFirst())
}
}
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.audio
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class SpectrumDelayTest {
private fun frames(n: Int) = (0 until n).map { Spectrum(floatArrayOf(it.toFloat())) }
@Test
fun delaysByFrameCountHoldingBackTheLatest() =
runTest {
// 10 frames in, hold back the latest 3 → 7 emitted, each shifted 3 hops earlier.
val out = frames(10).asFlow().delayedByFrames(3).toList()
assertEquals(7, out.size)
assertEquals(0f, out.first().bins[0])
assertEquals(6f, out.last().bins[0])
}
@Test
fun zeroDelayIsIdentity() =
runTest {
val out = frames(4).asFlow().delayedByFrames(0).toList()
assertEquals(listOf(0f, 1f, 2f, 3f), out.map { it.bins[0] })
}
@Test
fun negativeDelayIsIdentity() =
runTest {
assertEquals(
4,
frames(4)
.asFlow()
.delayedByFrames(-5)
.toList()
.size,
)
}
@Test
fun fewerFramesThanDelayEmitsNothing() =
runTest {
// Everything is still "in the buffer" (not yet audible), so nothing is shown.
assertEquals(
0,
frames(2)
.asFlow()
.delayedByFrames(5)
.toList()
.size,
)
}
}