1) 公放歌曲按「切歌」不再停止 原先 advancePlayback 在已点队列为空时把「用户主动切歌」一律当作停止(进入空闲态), 导致公放(随机)歌曲播放中按切歌就停了。现在: 若当前曲目属于公放清单 (RandomPlayQueue.isCurrentTrack), 按切歌直接接着播清单里的下一首; 不调用 markFinished(跳过不等于播完, 不该 +1), 但它已在 markStarted 计入本轮已播, 所以本轮不会重复。playRandomLocalSong 新增 skipped 参数用于提示文案。 2) 「调音」弹窗重构为 App 内唯一音控入口(播放界面 + 主界面右上角同一弹窗) 自上而下: 音量(横向控制杆, 即时生效) → 均衡器开关(默认关闭) → 均衡器预设 (自定义/流行/摇滚/舞曲, 手改频段自动转「自定义」并保存) → EQ 频段(竖向控制杆)。 全部即时生效, 因此没有「完成/确认」按钮, 只有关闭。 新增自绘控件 VerticalEqBar(SeekBar 只支持横向): 触摸拖动 + 遥控器上下键步进, 量程端点放行按键以便焦点离开, 焦点态自绘橙色边框并 suppressFill。 均衡器预设改为 标准(仅供远程"恢复标准", 不在 App 行内)/流行/摇滚/舞曲, eqPreset 默认 "自定义"。 清除旧 UI 与代码: 删除 dialog_tuning.xml / dialog_audio_control.xml / CircularVolumeView.kt, 移除 showAudioControlDialog / showAlignedTuningDialog / showEqualizerDialog / showFullScreenVolumeDialog / bindAtmospherePreset / updateToggleState 及 fullScreenVolume* / fullScreenToneControl / hideFullScreenVolumeIndicatorRunnable 等字段与 dispatchKeyEvent 中的面板分支。 设置页「音频控制」改为指向调音弹窗(麦克风音量仍提示由功放硬件调节; 升降调保留在下一页「音调控制」)。 3) 远程点歌页面均衡器界面不变, 均衡器文字栏改为折叠按钮(默认收起)。 版本号 1.3.4 -> 1.3.5(versionCode 10 -> 11)。按约定本次不发布 Release, 仅本地打包测试。
317 lines
12 KiB
Kotlin
317 lines
12 KiB
Kotlin
package com.local.ktv
|
||
|
||
import android.content.Context
|
||
import android.media.AudioManager
|
||
import android.media.audiofx.Equalizer
|
||
import android.util.Log
|
||
import kotlin.math.min
|
||
import kotlin.math.round
|
||
|
||
/**
|
||
* 音频均衡器内核。
|
||
*
|
||
* 实现参考开源项目 **OpenEQ**(https://github.com/Turbofan3360/OpenEQ ,GPL-3.0)的
|
||
* `audioprocessing/EQUtils.kt`:
|
||
* - 直接使用 Android 系统自带的 `android.media.audiofx.Equalizer`,不引入第三方 DSP;
|
||
* - **按设备真实支持的频段数与增益范围自适应**(很多设备宣称的频段数并不真正可用),
|
||
* 频段标签生成规则与 OpenEQ 的 `eqFrequenciesToLabels()` 一致(60 / 230 / 910 / 3.6K …)。
|
||
*
|
||
* 与 OpenEQ 原版的差异(本 App 是播放器而不是独立均衡器):
|
||
* - OpenEQ 挂「全局混音(会话 0)」或自己生成的会话;本 App 需要作用于**自己的播放器**,
|
||
* 因此默认把 EQ 挂到实际播放会话上:主播放器与内嵌副音轨取 IJKPlayer 的 AudioTrack 会话
|
||
* (`IjkMediaPlayer.getAudioSessionId()`,原生实现读的就是真实 AudioTrack 会话),
|
||
* 外挂音轨取 `MediaPlayer.audioSessionId`。
|
||
* - 会话随歌曲不断变化(每次 prepare 可能新建 AudioTrack),所以提供 [syncSessions]
|
||
* 由界面层周期性对齐:新会话自动挂上,消失的会话延迟释放。
|
||
* - 设备拒绝按会话挂载时,可退化为「全局混音」(会话 0)——代价是会影响本机所有声音,
|
||
* 因此做成显式开关而不是静默回退。
|
||
*/
|
||
object KtvEqualizer {
|
||
private const val TAG = "KtvEqualizer"
|
||
|
||
/** dB → 毫贝(mB),Android Equalizer 的增益单位。 */
|
||
private const val DB_TO_MB = 100f
|
||
|
||
/** 毫赫兹(mHz) → Hz,`getCenterFreq` 的单位。 */
|
||
private const val MHZ_TO_HZ = 1000f
|
||
|
||
/** 全局混音会话 id。 */
|
||
const val GLOBAL_SESSION = 0
|
||
|
||
/** 同时挂载的会话数上限(主播放器 + 副音轨 + 外挂音轨,正常 1~3 个)。 */
|
||
private const val MAX_SESSIONS = 8
|
||
|
||
/** 会话连续多少个同步周期未出现才释放(同步周期 500ms → 3s)。 */
|
||
private const val STALE_TICKS = 6
|
||
|
||
/** 探测不到设备能力时的兜底频段(常见 5 段)。 */
|
||
private val FALLBACK_FREQUENCIES = listOf(60f, 230f, 910f, 3600f, 14000f)
|
||
|
||
/** 设备均衡器能力。 */
|
||
data class Capabilities(val frequencies: List<Float>, val minDb: Float, val maxDb: Float) {
|
||
val bandCount: Int get() = frequencies.size
|
||
}
|
||
|
||
/**
|
||
* 预设曲线:以「频段在可用范围内的相对位置 0..1」为横轴、以最大增益的比例 -1..1 为纵轴。
|
||
* 这样同一套预设能自适应任意频段数的设备。
|
||
*
|
||
* `标准` 不显示在 App 的预设行里(App 用「自定义」表示用户自己调的状态),
|
||
* 但保留给远程页面的「恢复标准」按钮使用。
|
||
*/
|
||
private val PRESETS: LinkedHashMap<String, List<Pair<Float, Float>>> = linkedMapOf(
|
||
"标准" to listOf(0f to 0f),
|
||
"流行" to listOf(0f to 0.35f, 0.25f to -0.15f, 0.6f to 0.1f, 1f to 0.35f),
|
||
"摇滚" to listOf(0f to 0.7f, 0.3f to -0.4f, 1f to 0.6f),
|
||
"舞曲" to listOf(0f to 0.85f, 0.22f to 0.2f, 0.5f to -0.35f, 0.78f to 0.25f, 1f to 0.65f),
|
||
)
|
||
|
||
/** 手动改过频段后使用的预设名。 */
|
||
const val CUSTOM_PRESET = "自定义"
|
||
|
||
/** App 内「调音」弹窗预设行的顺序(含「自定义」)。 */
|
||
fun appPresetNames(): List<String> = listOf(CUSTOM_PRESET, "流行", "摇滚", "舞曲")
|
||
|
||
@Volatile var enabled: Boolean = false
|
||
private set
|
||
|
||
@Volatile var globalMix: Boolean = false
|
||
private set
|
||
|
||
@Volatile var preset: String = CUSTOM_PRESET
|
||
private set
|
||
|
||
private var levels: FloatArray = FloatArray(0)
|
||
|
||
@Volatile private var cachedCaps: Capabilities? = null
|
||
|
||
private class Entry(val effect: Equalizer) {
|
||
var missingTicks = 0
|
||
}
|
||
|
||
private val effects = LinkedHashMap<Int, Entry>()
|
||
|
||
// ─── 设备能力 ───────────────────────────────────────
|
||
|
||
fun presetNames(): List<String> = PRESETS.keys.toList()
|
||
|
||
/**
|
||
* 探测设备支持的频段与增益范围(结果缓存;失败则返回常见 5 段兜底)。
|
||
*/
|
||
fun capabilities(context: Context): Capabilities {
|
||
cachedCaps?.let { return it }
|
||
synchronized(this) {
|
||
cachedCaps?.let { return it }
|
||
val probeSession = runCatching {
|
||
(context.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
|
||
.generateAudioSessionId()
|
||
}.getOrDefault(GLOBAL_SESSION)
|
||
val caps = probe(probeSession) ?: probe(GLOBAL_SESSION)
|
||
?: Capabilities(FALLBACK_FREQUENCIES, -15f, 15f)
|
||
cachedCaps = caps
|
||
ensureLevelsFor(caps.bandCount)
|
||
Log.i(
|
||
TAG,
|
||
"capabilities bands=${caps.bandCount} range=${caps.minDb}..${caps.maxDb} " +
|
||
"freq=${caps.frequencies}",
|
||
)
|
||
return caps
|
||
}
|
||
}
|
||
|
||
private fun probe(session: Int): Capabilities? = runCatching {
|
||
val eq = Equalizer(0, session)
|
||
try {
|
||
val bandCount = eq.numberOfBands.toInt()
|
||
if (bandCount <= 0) return@runCatching null
|
||
val frequencies = (0 until bandCount).map { eq.getCenterFreq(it.toShort()) / MHZ_TO_HZ }
|
||
val range = eq.bandLevelRange
|
||
Capabilities(frequencies, range[0] / DB_TO_MB, range[1] / DB_TO_MB)
|
||
} finally {
|
||
runCatching { eq.release() }
|
||
}
|
||
}.getOrNull()
|
||
|
||
/** 本机是否允许挂「全局混音」(会话 0)。 */
|
||
fun globalMixSupported(): Boolean = runCatching {
|
||
val eq = Equalizer(0, GLOBAL_SESSION)
|
||
runCatching { eq.release() }
|
||
true
|
||
}.getOrDefault(false)
|
||
|
||
/** 频段中心频率的短标签,规则同 OpenEQ:1kHz 以下直接显示,以上显示 `3.6K` 形式。 */
|
||
fun bandLabels(caps: Capabilities): List<String> = caps.frequencies.map { freq ->
|
||
val text = if (freq < 1000f) {
|
||
freq.toString()
|
||
} else {
|
||
(freq / 1000f).toString() + "K"
|
||
}
|
||
text.dropLastWhile { it == '0' }.dropLastWhile { it == '.' }.ifEmpty { "0" }
|
||
}
|
||
|
||
// ─── 状态读写 ───────────────────────────────────────
|
||
|
||
fun levelsSnapshot(): FloatArray = levels.copyOf()
|
||
|
||
fun setEnabled(value: Boolean) {
|
||
enabled = value
|
||
effects.values.forEach { entry -> runCatching { entry.effect.enabled = value } }
|
||
Log.i(TAG, "enabled=$value sessions=${effects.size}")
|
||
}
|
||
|
||
/** 切换全局混音(会话 0)。切换会重建所有效果实例。 */
|
||
fun setGlobalMix(value: Boolean) {
|
||
if (globalMix == value) return
|
||
globalMix = value
|
||
releaseAll()
|
||
Log.i(TAG, "globalMix=$value")
|
||
}
|
||
|
||
fun setLevels(newLevels: FloatArray) {
|
||
levels = newLevels.copyOf()
|
||
effects.values.forEach { applyTo(it.effect) }
|
||
}
|
||
|
||
/** 恢复当前目标频段为全 0(不平坦化)。 */
|
||
fun resetLevels() {
|
||
preset = "标准"
|
||
setLevels(FloatArray(levels.size))
|
||
}
|
||
|
||
fun applyPreset(name: String) {
|
||
val shape = PRESETS[name] ?: return
|
||
preset = name
|
||
setLevels(shapeToLevels(shape, capabilitiesIfKnown()))
|
||
}
|
||
|
||
/** 用户手动调整了某个频段:预设切换为「自定义」(不改变频段值)。 */
|
||
fun markCustomPreset() {
|
||
preset = CUSTOM_PRESET
|
||
effects.values.forEach { applyTo(it.effect) }
|
||
}
|
||
|
||
private fun capabilitiesIfKnown(): Capabilities =
|
||
cachedCaps ?: Capabilities(FALLBACK_FREQUENCIES, -15f, 15f)
|
||
|
||
/** 把 0..1 的预设曲线插值到当前设备的频段上。 */
|
||
private fun shapeToLevels(shape: List<Pair<Float, Float>>, caps: Capabilities): FloatArray {
|
||
val bandCount = caps.bandCount.coerceAtLeast(1)
|
||
val maxGain = caps.maxDb.coerceAtLeast(0f)
|
||
val minGain = caps.minDb.coerceAtMost(0f)
|
||
if (shape.size == 1) return FloatArray(bandCount) { 0f }
|
||
return FloatArray(bandCount) { index ->
|
||
val position = if (bandCount == 1) 0f else index.toFloat() / (bandCount - 1)
|
||
var upper = 1
|
||
while (upper < shape.size - 1 && shape[upper].first < position) upper++
|
||
val (leftPos, leftValue) = shape[upper - 1]
|
||
val (rightPos, rightValue) = shape[upper]
|
||
val ratio = if (rightPos - leftPos <= 0f) 0f else (position - leftPos) / (rightPos - leftPos)
|
||
val value = leftValue + (rightValue - leftValue) * ratio.coerceIn(0f, 1f)
|
||
(if (value >= 0f) value * maxGain else value * -minGain)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 载入已保存的设置(App 启动时调用一次)。
|
||
*/
|
||
fun restore(enabled: Boolean, presetName: String, savedLevels: FloatArray, useGlobalMix: Boolean) {
|
||
globalMix = useGlobalMix
|
||
this.enabled = enabled
|
||
if (savedLevels.isNotEmpty()) {
|
||
levels = savedLevels.copyOf()
|
||
}
|
||
preset = presetName.ifBlank { "标准" }
|
||
}
|
||
|
||
/**
|
||
* 载入设置后把频段对齐到设备实际频段数。
|
||
*
|
||
* 预设(非自定义)直接按预设曲线在本机频段上重新生成,避免把「在别的设备上算好的
|
||
* 频段值」硬套到本机;自定义则保留用户保存的原始数值。
|
||
*/
|
||
fun alignToDevice(caps: Capabilities) {
|
||
cachedCaps = caps
|
||
ensureLevelsFor(caps.bandCount)
|
||
if (preset != CUSTOM_PRESET) {
|
||
applyPreset(preset)
|
||
}
|
||
}
|
||
|
||
private fun ensureLevelsFor(bandCount: Int) {
|
||
val target = bandCount.coerceAtLeast(1)
|
||
if (levels.size == target) return
|
||
val old = levels
|
||
levels = FloatArray(target) { index -> old.getOrElse(index) { 0f } }
|
||
}
|
||
|
||
// ─── 会话挂载 ───────────────────────────────────────
|
||
|
||
/**
|
||
* 对齐当前存活的播放会话。
|
||
*
|
||
* @param liveSessions 主播放器 / 内嵌副音轨 / 外挂音轨当前的音频会话 id(无效的传 0)
|
||
*/
|
||
fun syncSessions(liveSessions: List<Int>) {
|
||
val targets: Set<Int> = if (globalMix) {
|
||
setOf(GLOBAL_SESSION)
|
||
} else {
|
||
liveSessions.filter { it > 0 }.toSet()
|
||
}
|
||
val iterator = effects.entries.iterator()
|
||
while (iterator.hasNext()) {
|
||
val (session, entry) = iterator.next()
|
||
if (targets.contains(session)) {
|
||
entry.missingTicks = 0
|
||
continue
|
||
}
|
||
entry.missingTicks++
|
||
if (entry.missingTicks >= STALE_TICKS) {
|
||
runCatching { entry.effect.release() }
|
||
iterator.remove()
|
||
Log.i(TAG, "released stale session=$session")
|
||
}
|
||
}
|
||
targets.forEach { attach(it) }
|
||
}
|
||
|
||
private fun attach(session: Int) {
|
||
val existing = effects[session]
|
||
if (existing != null) {
|
||
applyTo(existing.effect)
|
||
return
|
||
}
|
||
if (effects.size >= MAX_SESSIONS) {
|
||
Log.w(TAG, "skip session=$session: too many effects")
|
||
return
|
||
}
|
||
runCatching {
|
||
val eq = Equalizer(0, session)
|
||
ensureLevelsFor(eq.numberOfBands.toInt())
|
||
eq.enabled = enabled
|
||
applyTo(eq)
|
||
effects[session] = Entry(eq)
|
||
Log.i(TAG, "attached session=$session bands=${eq.numberOfBands} enabled=$enabled")
|
||
}.onFailure { Log.w(TAG, "attach session=$session failed: ${it.message}") }
|
||
}
|
||
|
||
private fun applyTo(eq: Equalizer) {
|
||
runCatching {
|
||
val loopLimit = min(levels.size, eq.numberOfBands.toInt())
|
||
for (index in 0 until loopLimit) {
|
||
eq.setBandLevel(
|
||
index.toShort(),
|
||
round(levels[index] * DB_TO_MB).toInt().toShort(),
|
||
)
|
||
}
|
||
}.onFailure { Log.w(TAG, "apply levels failed: ${it.message}") }
|
||
}
|
||
|
||
fun releaseAll() {
|
||
effects.values.forEach { runCatching { it.effect.release() } }
|
||
effects.clear()
|
||
}
|
||
|
||
/** 已挂载的会话数(供日志/诊断)。 */
|
||
fun attachedSessionCount(): Int = effects.size
|
||
}
|