diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt index fbfc02d5bf..d82dbc17d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/AudioPlayingAnimation.kt @@ -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 { + 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?) { + isBluetooth.value = audioManager.isBluetoothOutput() + } + + override fun onAudioDevicesRemoved(removedDevices: Array?) { + 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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioSpectrum.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioSpectrum.kt index 2129c70f91..86eb82822c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioSpectrum.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/audio/AudioSpectrum.kt @@ -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.delayedByFrames(frames: Int): Flow = + if (frames <= 0) { + this + } else { + flow { + val queue = ArrayDeque(frames + 1) + collect { frame -> + queue.addLast(frame) + if (queue.size > frames) emit(queue.removeFirst()) + } + } + } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/SpectrumDelayTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/SpectrumDelayTest.kt new file mode 100644 index 0000000000..b6f97804af --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/audio/SpectrumDelayTest.kt @@ -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, + ) + } +}