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.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()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user