Раньше пресет эквалайзера в настройках был косметикой (лежал в DataStore, к звуку не подключён). Теперь — реальные системные эффекты на фикс. аудиосессии плеера: - AudioEffectsController: графический Equalizer (полосы устройства), BassBoost, Virtualizer (объём), LoudnessEnhancer (громкость тихих, до +12 дБ). Привязка к generateAudioSessionId() в PlayerController, переживает смену станций. Применение в реальном времени, сохранение в DataStore (commit на отпускании слайдера). - Отдельный экран EqualizerScreen (Настройки → ЗВУК → Эквалайзер): тумблер, системные пресеты + «Свой», слайдеры полос (±дБ), bass/virtualizer/loudness. - Эффекты best-effort: при отсутствии поддержки блок недоступен (null), UI скрывает. - Убран фейковый чип-пресет Flat/Rock/Pop/Jazz/Bass.
260 lines
9.9 KiB
Kotlin
260 lines
9.9 KiB
Kotlin
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
|
|
}
|
|
}
|