From 05e55389451bd89592bcc1daf12ed5455a33ceab Mon Sep 17 00:00:00 2001 From: nk Date: Thu, 4 Jun 2026 17:39:09 +0300 Subject: [PATCH] =?UTF-8?q?feat(player):=20=D0=B6=D0=B8=D0=B2=D0=BE=D0=B9?= =?UTF-8?q?=20=D1=8D=D0=BA=D0=B2=D0=B0=D0=BB=D0=B0=D0=B9=D0=B7=D0=B5=D1=80?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC=D1=83=20=D0=B7=D0=B2=D1=83=D0=BA=D1=83=20(FFT-=D1=81?= =?UTF-8?q?=D0=BF=D0=B5=D0=BA=D1=82=D1=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Эквалайзер на плеере больше не декоративная синус-волна — реагирует на реальный звук. Через TeeAudioProcessor подключаемся к декодированному PCM в аудио-конвейере ExoPlayer (без разрешений/микрофона), считаем FFT → лог-полосы (AudioSpectrumAnalyzer), PlayerController отдаёт спектр StateFlow'ом, LiveEqualizer рисует столбики по уровням (с быстрым ростом/плавным спадом). Когда звука нет (пауза/float-выход) — фолбэк на прежнюю синус-волну. Co-Authored-By: Claude Opus 4.8 --- .../java/com/radiola/service/AudioSpectrum.kt | 139 ++++++++++++++++++ .../com/radiola/service/PlayerController.kt | 26 ++++ .../radiola/ui/player/PlayerBottomSheet.kt | 4 +- .../com/radiola/ui/player/PlayerViewModel.kt | 1 + .../main/java/com/radiola/ui/theme/Motion.kt | 18 ++- 5 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/radiola/service/AudioSpectrum.kt diff --git a/app/src/main/java/com/radiola/service/AudioSpectrum.kt b/app/src/main/java/com/radiola/service/AudioSpectrum.kt new file mode 100644 index 0000000..619150d --- /dev/null +++ b/app/src/main/java/com/radiola/service/AudioSpectrum.kt @@ -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 = _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 + } +} diff --git a/app/src/main/java/com/radiola/service/PlayerController.kt b/app/src/main/java/com/radiola/service/PlayerController.kt index 2f88c2d..cd253c9 100644 --- a/app/src/main/java/com/radiola/service/PlayerController.kt +++ b/app/src/main/java/com/radiola/service/PlayerController.kt @@ -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 = 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 = _isPlaying @@ -88,6 +113,7 @@ class PlayerController @Inject constructor( ) private val exoPlayer: ExoPlayer = ExoPlayer.Builder(context) + .setRenderersFactory(renderersFactory) .setMediaSourceFactory(mediaSourceFactory) .setAudioAttributes( AudioAttributes.Builder() diff --git a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt index 1ab2771..0afe3db 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerBottomSheet.kt @@ -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)) diff --git a/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt b/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt index 6891f5a..91341dc 100644 --- a/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/radiola/ui/player/PlayerViewModel.kt @@ -42,6 +42,7 @@ class PlayerViewModel @Inject constructor( val isPlaying: StateFlow = playerController.isPlaying val currentStationPrefix: StateFlow = playerController.currentStationPrefix + val spectrum: StateFlow = playerController.spectrum private val _currentStation = MutableStateFlow(null) val currentStation: StateFlow = _currentStation.asStateFlow() diff --git a/app/src/main/java/com/radiola/ui/theme/Motion.kt b/app/src/main/java/com/radiola/ui/theme/Motion.kt index 5f805c2..a88b02f 100644 --- a/app/src/main/java/com/radiola/ui/theme/Motion.kt +++ b/app/src/main/java/com/radiola/ui/theme/Motion.kt @@ -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