feat(player): живой эквалайзер по реальному звуку (FFT-спектр)

Эквалайзер на плеере больше не декоративная синус-волна — реагирует на
реальный звук. Через TeeAudioProcessor подключаемся к декодированному PCM в
аудио-конвейере ExoPlayer (без разрешений/микрофона), считаем FFT → лог-полосы
(AudioSpectrumAnalyzer), PlayerController отдаёт спектр StateFlow'ом, LiveEqualizer
рисует столбики по уровням (с быстрым ростом/плавным спадом). Когда звука нет
(пауза/float-выход) — фолбэк на прежнюю синус-волну.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-04 17:39:09 +03:00
parent a46e437351
commit 05e5538945
5 changed files with 183 additions and 5 deletions

View File

@@ -0,0 +1,139 @@
package com.radiola.service
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.audio.TeeAudioProcessor
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.cos
import kotlin.math.ln
import kotlin.math.sqrt
/** Итеративный radix-2 FFT (in-place). Размер — степень двойки. */
internal object Fft {
fun transform(re: FloatArray, im: FloatArray) {
val n = re.size
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) {
j = j xor bit
bit = bit shr 1
}
j = j or bit
if (i < j) {
var t = re[i]; re[i] = re[j]; re[j] = t
t = im[i]; im[i] = im[j]; im[j] = t
}
}
var len = 2
while (len <= n) {
val ang = -2.0 * Math.PI / len
val wr = cos(ang).toFloat()
val wi = kotlin.math.sin(ang).toFloat()
var i = 0
while (i < n) {
var curR = 1f
var curI = 0f
val half = len / 2
for (k in 0 until half) {
val reK = re[i + k + half]
val imK = im[i + k + half]
val bR = reK * curR - imK * curI
val bI = reK * curI + imK * curR
val aR = re[i + k]
val aI = im[i + k]
re[i + k] = aR + bR
im[i + k] = aI + bI
re[i + k + half] = aR - bR
im[i + k + half] = aI - bI
val nCurR = curR * wr - curI * wi
curI = curR * wi + curI * wr
curR = nCurR
}
i += len
}
len = len shl 1
}
}
}
/**
* Подключается к декодированному PCM в аудио-конвейере ExoPlayer (через
* TeeAudioProcessor — без изменения звука и без разрешений) и считает спектр
* (FFT → лог-полосы) для «живого» эквалайзера, реагирующего на реальный звук.
*/
@OptIn(UnstableApi::class)
class AudioSpectrumAnalyzer(
private val bands: Int = 32,
) : TeeAudioProcessor.AudioBufferSink {
private val _spectrum = MutableStateFlow(FloatArray(bands))
val spectrum: StateFlow<FloatArray> = _spectrum
private val fftSize = 1024
private val sample = FloatArray(fftSize)
private val re = FloatArray(fftSize)
private val im = FloatArray(fftSize)
private val window = FloatArray(fftSize) { 0.5f * (1f - cos(2.0 * Math.PI * it / (fftSize - 1)).toFloat()) }
private val smoothed = FloatArray(bands)
private var filled = 0
private var channelCount = 2
private var pcm16 = true
private var lastEmit = 0L
override fun flush(sampleRateHz: Int, channelCount: Int, encoding: Int) {
this.channelCount = channelCount.coerceAtLeast(1)
this.pcm16 = encoding == C.ENCODING_PCM_16BIT
filled = 0
}
override fun handleBuffer(buffer: ByteBuffer) {
if (!pcm16) return
val b = buffer.duplicate().order(ByteOrder.LITTLE_ENDIAN)
val ch = channelCount
while (b.remaining() >= 2 * ch) {
var sum = 0f
for (c in 0 until ch) sum += b.short.toFloat()
sample[filled++] = (sum / ch) / 32768f
if (filled >= fftSize) {
compute()
filled = 0
}
}
}
private fun compute() {
val now = System.nanoTime()
if (now - lastEmit < 33_000_000L) return // ~30 кадров/с
lastEmit = now
for (i in 0 until fftSize) {
re[i] = sample[i] * window[i]
im[i] = 0f
}
Fft.transform(re, im)
val half = fftSize / 2
val out = FloatArray(bands)
for (band in 0 until bands) {
// Лог-распределение полос по бинам (1..half).
val lo = Math.pow(half.toDouble(), band.toDouble() / bands).toInt().coerceIn(1, half - 1)
val hi = Math.pow(half.toDouble(), (band + 1.0) / bands).toInt().coerceIn(lo + 1, half)
var mag = 0f
for (bin in lo until hi) {
val m = sqrt(re[bin] * re[bin] + im[bin] * im[bin])
if (m > mag) mag = m
}
val v = (ln(1f + mag * 10f) / ln(11f)).coerceIn(0f, 1f)
// Быстрый рост, плавный спад — как у настоящего эквалайзера.
val prev = smoothed[band]
smoothed[band] = if (v > prev) v else prev * 0.80f + v * 0.20f
out[band] = smoothed[band]
}
_spectrum.value = out
}
}

View File

@@ -13,9 +13,14 @@ import androidx.media3.common.MediaMetadata
import androidx.media3.common.Metadata import androidx.media3.common.Metadata
import androidx.media3.common.Player import androidx.media3.common.Player
import android.util.Log import android.util.Log
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.audio.TeeAudioProcessor
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.extractor.metadata.icy.IcyInfo import androidx.media3.extractor.metadata.icy.IcyInfo
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@@ -25,10 +30,30 @@ import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@UnstableApi
@Singleton @Singleton
class PlayerController @Inject constructor( class PlayerController @Inject constructor(
@ApplicationContext context: Context @ApplicationContext context: Context
) { ) {
// Анализатор спектра реального звука — для «живого» эквалайзера.
private val spectrumAnalyzer = AudioSpectrumAnalyzer()
val spectrum: StateFlow<FloatArray> = spectrumAnalyzer.spectrum
// RenderersFactory, который вставляет наш tee-процессор в аудио-конвейер
// (читает декодированный PCM, не меняя звук).
private val renderersFactory = object : DefaultRenderersFactory(context) {
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioTrackPlaybackParams: Boolean,
): AudioSink {
return DefaultAudioSink.Builder(context)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
.setAudioProcessors(arrayOf(TeeAudioProcessor(spectrumAnalyzer)))
.build()
}
}
private val _isPlaying = MutableStateFlow(false) private val _isPlaying = MutableStateFlow(false)
val isPlaying: StateFlow<Boolean> = _isPlaying val isPlaying: StateFlow<Boolean> = _isPlaying
@@ -88,6 +113,7 @@ class PlayerController @Inject constructor(
) )
private val exoPlayer: ExoPlayer = ExoPlayer.Builder(context) private val exoPlayer: ExoPlayer = ExoPlayer.Builder(context)
.setRenderersFactory(renderersFactory)
.setMediaSourceFactory(mediaSourceFactory) .setMediaSourceFactory(mediaSourceFactory)
.setAudioAttributes( .setAudioAttributes(
AudioAttributes.Builder() AudioAttributes.Builder()

View File

@@ -83,6 +83,7 @@ fun PlayerBottomSheet(
var showLyrics by remember { mutableStateOf(false) } var showLyrics by remember { mutableStateOf(false) }
var showQuality by remember { mutableStateOf(false) } var showQuality by remember { mutableStateOf(false) }
val currentQuality by viewModel.currentQuality.collectAsState() val currentQuality by viewModel.currentQuality.collectAsState()
val spectrum by viewModel.spectrum.collectAsState()
Column( Column(
modifier = modifier modifier = modifier
@@ -189,7 +190,8 @@ fun PlayerBottomSheet(
.fillMaxWidth() .fillMaxWidth()
.height(40.dp), .height(40.dp),
playing = isPlaying, playing = isPlaying,
color = colors.accent color = colors.accent,
levels = spectrum
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))

View File

@@ -42,6 +42,7 @@ class PlayerViewModel @Inject constructor(
val isPlaying: StateFlow<Boolean> = playerController.isPlaying val isPlaying: StateFlow<Boolean> = playerController.isPlaying
val currentStationPrefix: StateFlow<String?> = playerController.currentStationPrefix val currentStationPrefix: StateFlow<String?> = playerController.currentStationPrefix
val spectrum: StateFlow<FloatArray> = playerController.spectrum
private val _currentStation = MutableStateFlow<Station?>(null) private val _currentStation = MutableStateFlow<Station?>(null)
val currentStation: StateFlow<Station?> = _currentStation.asStateFlow() val currentStation: StateFlow<Station?> = _currentStation.asStateFlow()

View File

@@ -67,7 +67,10 @@ fun LiveEqualizer(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
barCount: Int = 36, barCount: Int = 36,
color: Color = Accent, color: Color = Accent,
playing: Boolean = true playing: Boolean = true,
// Реальный спектр звука (0..1 по полосам). Если задан — рисуем по нему,
// иначе — декоративная синус-волна.
levels: FloatArray? = null
) { ) {
val transition = rememberInfiniteTransition(label = "eq") val transition = rememberInfiniteTransition(label = "eq")
val phase by transition.animateFloat( val phase by transition.animateFloat(
@@ -79,15 +82,22 @@ fun LiveEqualizer(
), ),
label = "eqPhase" label = "eqPhase"
) )
val lv = levels
val live = playing && lv != null && lv.isNotEmpty()
Canvas(modifier = modifier) { Canvas(modifier = modifier) {
val gap = 3.dp.toPx() val gap = 3.dp.toPx()
val barWidth = (size.width - gap * (barCount - 1)) / barCount val barWidth = (size.width - gap * (barCount - 1)) / barCount
val maxH = size.height val maxH = size.height
for (i in 0 until barCount) { for (i in 0 until barCount) {
val seed = (i * 0.7f) val seed = (i * 0.7f)
val wave = if (playing) { val wave = when {
0.45f + 0.55f * abs(kotlin.math.sin(phase + seed)) live -> {
} else 0.25f val idx = (i * lv!!.size / barCount).coerceIn(0, lv.size - 1)
0.12f + 0.88f * lv[idx].coerceIn(0f, 1f)
}
playing -> 0.45f + 0.55f * abs(kotlin.math.sin(phase + seed))
else -> 0.25f
}
val h = maxH * wave val h = maxH * wave
val x = i * (barWidth + gap) val x = i * (barWidth + gap)
val y = (maxH - h) / 2f val y = (maxH - h) / 2f