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:
139
app/src/main/java/com/radiola/service/AudioSpectrum.kt
Normal file
139
app/src/main/java/com/radiola/service/AudioSpectrum.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user