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