mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
+60
-1
@@ -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
|
||||
|
||||
+23
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+75
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user