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.Player
import android.util.Log
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
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.extractor.metadata.icy.IcyInfo
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -25,10 +30,30 @@ import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
import javax.inject.Singleton
@UnstableApi
@Singleton
class PlayerController @Inject constructor(
@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)
val isPlaying: StateFlow<Boolean> = _isPlaying
@@ -88,6 +113,7 @@ class PlayerController @Inject constructor(
)
private val exoPlayer: ExoPlayer = ExoPlayer.Builder(context)
.setRenderersFactory(renderersFactory)
.setMediaSourceFactory(mediaSourceFactory)
.setAudioAttributes(
AudioAttributes.Builder()

View File

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

View File

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

View File

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