feat(eq): настоящий эквалайзер + улучшайзеры звука (audiofx)

Раньше пресет эквалайзера в настройках был косметикой (лежал в DataStore, к звуку
не подключён). Теперь — реальные системные эффекты на фикс. аудиосессии плеера:
- AudioEffectsController: графический Equalizer (полосы устройства), BassBoost,
  Virtualizer (объём), LoudnessEnhancer (громкость тихих, до +12 дБ). Привязка к
  generateAudioSessionId() в PlayerController, переживает смену станций. Применение
  в реальном времени, сохранение в DataStore (commit на отпускании слайдера).
- Отдельный экран EqualizerScreen (Настройки → ЗВУК → Эквалайзер): тумблер,
  системные пресеты + «Свой», слайдеры полос (±дБ), bass/virtualizer/loudness.
- Эффекты best-effort: при отсутствии поддержки блок недоступен (null), UI скрывает.
- Убран фейковый чип-пресет Flat/Rock/Pop/Jazz/Bass.
This commit is contained in:
nk
2026-06-06 21:14:38 +03:00
parent 0c01eaab2d
commit e736c2393f
9 changed files with 679 additions and 33 deletions

View File

@@ -0,0 +1,259 @@
package com.radiola.service
import android.content.Context
import android.media.audiofx.BassBoost
import android.media.audiofx.Equalizer
import android.media.audiofx.LoudnessEnhancer
import android.media.audiofx.Virtualizer
import android.util.Log
import com.radiola.domain.repository.SettingsRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/** Одна полоса эквалайзера: центральная частота и текущий/предельный уровень в мБ. */
data class EqBand(
val index: Int,
val centerHz: Int,
val minMb: Int,
val maxMb: Int,
val levelMb: Int
)
/** Снимок состояния эквалайзера и улучшайзеров для UI. */
data class EqState(
val available: Boolean = false,
val enabled: Boolean = false,
val bands: List<EqBand> = emptyList(),
val presets: List<String> = emptyList(),
val currentPreset: Int = -1, // -1 = свой
val hasBass: Boolean = false,
val hasVirtualizer: Boolean = false,
val hasLoudness: Boolean = false,
val bass: Int = 0, // 0..100 %
val virtualizer: Int = 0, // 0..100 %
val loudness: Int = 0 // 0..100 % → 0..+12 дБ
)
/**
* Управляет системными аудиоэффектами (android.media.audiofx), привязанными к
* аудиосессии плеера: графический эквалайзер + Bass Boost + Virtualizer (объём) +
* LoudnessEnhancer (громкость тихих). Применяет в реальном времени, переживает смену
* станции (сессия фиксированная), сохраняет настройки в DataStore. Эффекты — best-effort:
* на устройствах без поддержки соответствующий блок просто недоступен (null).
*/
@Singleton
class AudioEffectsController @Inject constructor(
@ApplicationContext private val context: Context,
private val settings: SettingsRepository
) {
private val tag = "AudioEffects"
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var equalizer: Equalizer? = null
private var bassBoost: BassBoost? = null
private var virtualizer: Virtualizer? = null
private var loudness: LoudnessEnhancer? = null
private var masterEnabled = false
private val _state = MutableStateFlow(EqState())
val state: StateFlow<EqState> = _state.asStateFlow()
/** Привязывает эффекты к аудиосессии и применяет сохранённые настройки. */
fun attach(sessionId: Int) {
release()
equalizer = try {
Equalizer(0, sessionId)
} catch (e: Exception) {
Log.w(tag, "Equalizer недоступен: ${e.message}"); null
}
bassBoost = try {
BassBoost(0, sessionId).let { if (it.strengthSupported) it else { it.release(); null } }
} catch (e: Exception) {
null
}
virtualizer = try {
Virtualizer(0, sessionId).let { if (it.strengthSupported) it else { it.release(); null } }
} catch (e: Exception) {
null
}
loudness = try {
LoudnessEnhancer(sessionId)
} catch (e: Exception) {
null
}
scope.launch { loadAndApply() }
}
private suspend fun loadAndApply() {
val enabled = settings.getEqEnabled().first()
val preset = settings.getEqPreset().first()
val bandsCsv = settings.getEqBands().first()
val bass = settings.getEqBass().first()
val virt = settings.getEqVirtualizer().first()
val loud = settings.getEqLoudness().first()
masterEnabled = enabled
val eq = equalizer
if (eq != null) {
runCatching { eq.enabled = enabled }
val saved = bandsCsv.split(",").mapNotNull { it.trim().toShortOrNull() }
if (preset in 0 until eq.numberOfPresets) {
runCatching { eq.usePreset(preset.toShort()) }
} else if (saved.size == eq.numberOfBands.toInt()) {
saved.forEachIndexed { i, lvl -> runCatching { eq.setBandLevel(i.toShort(), lvl) } }
}
}
applyBassInternal(bass)
applyVirtualizerInternal(virt)
applyLoudnessInternal(loud)
emitState(enabled, preset, bass, virt, loud)
}
// ── Публичные действия (UI) ──
fun setEnabled(on: Boolean) {
masterEnabled = on
runCatching { equalizer?.enabled = on }
val s = _state.value
applyBassInternal(s.bass)
applyVirtualizerInternal(s.virtualizer)
applyLoudnessInternal(s.loudness)
persist { settings.setEqEnabled(on) }
_state.value = s.copy(enabled = on)
}
fun selectPreset(index: Int) {
val eq = equalizer ?: return
if (index !in 0 until eq.numberOfPresets) return
runCatching { eq.usePreset(index.toShort()) }
persist { settings.setEqPreset(index); settings.setEqBands(currentBandsCsv()) }
emitState(_state.value.enabled, index, _state.value.bass, _state.value.virtualizer, _state.value.loudness)
}
// setBand/setBass/... применяют к железу + правят in-memory состояние БЕЗ записи в
// DataStore (вызываются на каждое движение слайдера). Запись — один раз в commit()
// на onValueChangeFinished, чтобы не спамить хранилище десятками правок за драг.
fun setBand(index: Int, levelMb: Int) {
val eq = equalizer ?: return
runCatching { eq.setBandLevel(index.toShort(), levelMb.toShort()) }
val s = _state.value
_state.value = s.copy(
currentPreset = -1, // ручная правка → «свой»
bands = s.bands.map { if (it.index == index) it.copy(levelMb = levelMb) else it }
)
}
fun setBass(value: Int) {
applyBassInternal(value)
_state.value = _state.value.copy(bass = value)
}
fun setVirtualizer(value: Int) {
applyVirtualizerInternal(value)
_state.value = _state.value.copy(virtualizer = value)
}
fun setLoudness(value: Int) {
applyLoudnessInternal(value)
_state.value = _state.value.copy(loudness = value)
}
/** Сохраняет текущее состояние полос/улучшайзеров (вызывать при отпускании слайдера). */
fun commit() {
val s = _state.value
persist {
settings.setEqPreset(s.currentPreset)
settings.setEqBands(currentBandsCsv())
settings.setEqBass(s.bass)
settings.setEqVirtualizer(s.virtualizer)
settings.setEqLoudness(s.loudness)
}
}
// ── Внутреннее применение к железу ──
private fun applyBassInternal(value: Int) {
val bb = bassBoost ?: return
runCatching {
bb.enabled = masterEnabled && value > 0
if (masterEnabled && value > 0) bb.setStrength((value.coerceIn(0, 100) * 10).toShort())
}
}
private fun applyVirtualizerInternal(value: Int) {
val vz = virtualizer ?: return
runCatching {
vz.enabled = masterEnabled && value > 0
if (masterEnabled && value > 0) vz.setStrength((value.coerceIn(0, 100) * 10).toShort())
}
}
private fun applyLoudnessInternal(value: Int) {
val le = loudness ?: return
runCatching {
le.enabled = masterEnabled && value > 0
// 0..100 % → 0..1200 мБ (= 0..+12 дБ)
if (masterEnabled && value > 0) le.setTargetGain(value.coerceIn(0, 100) * 12)
}
}
private fun currentBandsCsv(): String {
val eq = equalizer ?: return ""
return (0 until eq.numberOfBands).joinToString(",") { eq.getBandLevel(it.toShort()).toString() }
}
private fun emitState(enabled: Boolean, preset: Int, bass: Int, virt: Int, loud: Int) {
val eq = equalizer
val bands = if (eq != null) {
val range = eq.bandLevelRange // [min, max] в мБ
(0 until eq.numberOfBands).map { i ->
val b = i.toShort()
EqBand(
index = i,
centerHz = eq.getCenterFreq(b) / 1000, // мГц → Гц
minMb = range[0].toInt(),
maxMb = range[1].toInt(),
levelMb = eq.getBandLevel(b).toInt()
)
}
} else emptyList()
val presets = if (eq != null) {
(0 until eq.numberOfPresets).map { eq.getPresetName(it.toShort()) }
} else emptyList()
_state.value = EqState(
available = eq != null,
enabled = enabled,
bands = bands,
presets = presets,
currentPreset = preset,
hasBass = bassBoost != null,
hasVirtualizer = virtualizer != null,
hasLoudness = loudness != null,
bass = bass,
virtualizer = virt,
loudness = loud
)
}
private fun persist(block: suspend () -> Unit) {
scope.launch { runCatching { block() } }
}
fun release() {
runCatching { equalizer?.release() }
runCatching { bassBoost?.release() }
runCatching { virtualizer?.release() }
runCatching { loudness?.release() }
equalizer = null; bassBoost = null; virtualizer = null; loudness = null
}
}