From 4c22826ea108b624da2cc8714516b66f31907e2e Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Sun, 20 Sep 2026 12:02:07 +0800 Subject: [PATCH] =?UTF-8?q?V1.3.4:=20=E6=96=B0=E5=A2=9E=E9=9F=B3=E9=A2=91?= =?UTF-8?q?=E5=9D=87=E8=A1=A1=E5=99=A8(=E5=8F=82=E8=80=83=20OpenEQ)=20+=20?= =?UTF-8?q?=E9=9A=8F=E6=9C=BA=E7=BB=AD=E6=92=AD=E6=94=B9=E4=B8=BA=E4=BC=AA?= =?UTF-8?q?=E9=9A=8F=E6=9C=BA=E5=85=AC=E6=94=BE=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) 音频均衡器 - 新增 KtvEqualizer:参考开源项目 OpenEQ(Turbofan3360/OpenEQ, GPL-3.0) 的 EQUtils, 直接用系统 android.media.audiofx.Equalizer, 按设备真实频段数与增益范围自适应 (频段动态生成, 频段标签规则同 OpenEQ: 60/230/910/3.6K ...)。 - 挂载方式改为按"实际播放会话": player.audioSessionId() / vocalPlayer.audioSessionId / embeddedVocalEngine.audioSessionId()(IJK 原生 IjkMediaPlayer_getAudioSessionId 即真实 AudioTrack 会话)。会话随歌曲变化, 由 500ms lyricTicker -> syncEqualizerSessions() 周期对齐, 消失会话延迟 3s 释放; 设备不支持按会话挂载时可显式开启 globalMix(会话 0, 默认关闭)。 - UI(showEqualizerDialog)与调音弹窗同风格: 深色圆角面板/紫色频段卡/橙色焦点反馈; 滑杆固定 30 档、步进由设备增益范围均分(保证 0 dB 可选中), 5 个预设 标准/流行/抒情/摇滚/唱将, 手动调整后转"自定义"。 - 四个入口: 主页顶栏「调音」弹窗、播放界面「调音」面板、声音设置「均衡器入口」、 远程点歌页面「均衡器」卡片(PATCH /api/v1/player/equalizer, 状态随 GET /api/v1/state 的 equalizer 下发)。 - 设置持久化: KtvStore.eqEnabled/eqPreset/eqLevels/eqGlobalMix。 2) 随机续播改为伪随机公放清单 - 新增 RandomPlayQueue(纯内存): 随机续播一次性生成打乱顺序的临时清单, 一轮内不重复; 播完一首 markFinished +1; next() 每次重新比对曲库, 自动吸纳新增歌曲、剔除已删除歌曲; 一轮播完自动开新一轮(尽量不把刚播完的放开头)。 - 不显示(界面不展示)、不关联已点清单(orderQueue 完全独立, 随机歌曲不入队)、 App 退出即清理(onDestroy 里 isFinishing 时 clear(), 不落盘)。 版本号 1.3.3 -> 1.3.4(versionCode 9 -> 10)。按约定本次不发布 Release, 仅本地打包测试。 --- AGENTS.md | 18 +- app/build.gradle | 4 +- app/src/main/assets/mobile/app.css | 2 +- app/src/main/assets/mobile/app.js | 69 ++- app/src/main/assets/mobile/index.html | 11 + .../main/java/com/local/ktv/KtvEqualizer.kt | 311 ++++++++++++ app/src/main/java/com/local/ktv/KtvStore.kt | 14 + .../main/java/com/local/ktv/MainActivity.kt | 450 +++++++++++++++++- .../java/com/local/ktv/RandomPlayQueue.kt | 153 ++++++ .../java/com/local/ktv/player/KtvVideoView.kt | 9 + app/src/main/res/layout/dialog_tuning.xml | 14 +- 11 files changed, 1043 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/local/ktv/KtvEqualizer.kt create mode 100644 app/src/main/java/com/local/ktv/RandomPlayQueue.kt diff --git a/AGENTS.md b/AGENTS.md index b571c1d..aff7752 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,7 +103,7 @@ $BT/apksigner verify 目标名.apk ### 版本号 -- `app/build.gradle`:`versionName "1.3.3"` / `versionCode 9` +- `app/build.gradle`:`versionName "1.3.4"` / `versionCode 10` - App 内显示统一用纯版本号(不加 `(versionCode)` 括号) ## 三、关键实现约束(避免踩坑) @@ -153,4 +153,20 @@ $BT/apksigner verify 目标名.apk `showAudioControlDialog()` 的音乐音量滑杆)、声音设置-歌曲最大音量、远程控制-音乐音量 (`remoteAudio()` 的 `musicVolume`)。曲线是纯衰减(100% 即 unity gain),不做系统级增益; 设备最终响度仍取决于系统音量,App 不再替用户改动它 +- **音频均衡器**:内核 `KtvEqualizer`(参考开源项目 OpenEQ,`Turbofan3360/OpenEQ`,GPL-3.0 的 + `EQUtils.kt`)——直接用系统 `android.media.audiofx.Equalizer`,**按设备真实频段数与增益范围自适应** + (频段是动态生成的,不要写死 5 段)。默认把 EQ 挂到**实际播放会话**: + `player.audioSessionId()` / `vocalPlayer.audioSessionId` / `embeddedVocalEngine.audioSessionId()` + (IJK 的原生 `IjkMediaPlayer_getAudioSessionId` 返回真实 AudioTrack 会话)。会话会随歌曲变化, + 因此由 500ms 的 `lyricTicker` → `syncEqualizerSessions()` 周期对齐,消失的会话延迟 3s 释放。 + `globalMix`(会话 0)是设备不支持按会话挂载时的显式开关,会影响本机所有声音,默认关闭。 + 四个入口:主页顶栏「调音」弹窗里的「均衡器」、播放界面「调音」面板里的「均衡器」、 + 声音设置「均衡器入口」、远程点歌页面「均衡器」卡片(`PATCH /api/v1/player/equalizer`, + 状态随 `GET /api/v1/state` 的 `equalizer` 字段下发)。设置持久化在 `KtvStore.eq*`; + **滑杆刻度固定 30 档**(`eqSliderSteps`),步进由设备增益范围均分,保证 0 dB 一定可选中 +- **公放随机清单**:`RandomPlayQueue`(纯内存单例)——随机续播不再「每次现随机挑一首」, + 而是一次性生成打乱顺序的临时清单,一轮内不重复;播完一首 `markFinished` +1; + `next()` 每次都重新比对曲库,**自动吸纳新增歌曲**、剔除已删除歌曲;一轮播完自动开新一轮。 + **不显示**(界面不展示)、**不关联已点清单**(`orderQueue` 完全独立,随机歌曲不入队)、 + **App 退出即清理**(`onDestroy` 里 `isFinishing` 时 `clear()`,不落盘) - **仓库可见性**:Gitea 仓库保持 **public**(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403) diff --git a/app/build.gradle b/app/build.gradle index 1073789..9bc9bf8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.local.ktv" minSdk 21 targetSdk 25 - versionCode 9 - versionName "1.3.3" + versionCode 10 + versionName "1.3.4" ndk { // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 diff --git a/app/src/main/assets/mobile/app.css b/app/src/main/assets/mobile/app.css index ecb64c2..88e867b 100644 --- a/app/src/main/assets/mobile/app.css +++ b/app/src/main/assets/mobile/app.css @@ -16,4 +16,4 @@ .rank-pager b{min-width:64px;text-align:center;color:#b9a9c9;font-size:11px} .empty.compact{padding:42px 15px} -.audio-panel label.mic-hw{opacity:.55}.audio-panel label.mic-hw>small{grid-column:1/3;color:#9f8eb0;font-size:10px;margin-top:2px}.audio-panel label.mic-hw input[type=range]{background:rgba(255,255,255,.14)} \ No newline at end of file +.audio-panel label.mic-hw{opacity:.55}.audio-panel label.mic-hw>small{grid-column:1/3;color:#9f8eb0;font-size:10px;margin-top:2px}.audio-panel label.mic-hw input[type=range]{background:rgba(255,255,255,.14)}.eq-panel{padding:18px;margin-top:14px}.eq-switch{grid-template-columns:1fr 1fr}.eq-bands{margin-top:6px}.eq-band{display:grid;grid-template-columns:1fr auto;align-items:center;margin-top:14px;font-size:12px;color:#e9dfef}.eq-band>span{color:#cbbad9}.eq-band>b{color:var(--gold);font-size:12px}.eq-band b em{font-style:normal;font-size:14px}.eq-presets{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin-top:17px}.eq-preset{height:40px;border-radius:13px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.09);color:#d9cce4;font-size:12px}.eq-preset.active{color:#36123d;background:linear-gradient(135deg,#ffdf7f,#ffb84a);font-weight:700}.eq-hint{margin:14px 0 0;color:#9f8eb0;font-size:10px;line-height:1.5} diff --git a/app/src/main/assets/mobile/app.js b/app/src/main/assets/mobile/app.js index 4df24c7..96ea2f1 100644 --- a/app/src/main/assets/mobile/app.js +++ b/app/src/main/assets/mobile/app.js @@ -3,7 +3,7 @@ const $$ = (selector) => document.querySelectorAll(selector); const uiState = { page: 1, query: "", searchMode: "singer", busy: false, hotPage: 1, hotPageSize: 10, hotBusy: false, - visible: true, connected: false + visible: true, connected: false, eqEnabled: false }; function escapeHtml(value) { @@ -81,11 +81,75 @@ async function refresh() { $("#original").classList.toggle("active", data.vocalMode === "original"); $("#accompany").classList.toggle("active", data.vocalMode !== "original"); $("#nextSong").textContent = data.next?.title || "队列为空"; + renderEqualizer(data.equalizer); } catch (_) { setConnected(false); } } +// ─── 均衡器(与设备端 KtvEqualizer 同步,频段由设备真实能力决定)─── +function renderEqualizer(eq) { + if (!eq) return; + uiState.eqEnabled = !!eq.enabled; + const state = $("#eqState"); + if (state) state.textContent = eq.enabled ? (eq.preset || "自定义") : "已关闭"; + const toggle = $("#eqToggle"); + if (toggle) { + toggle.textContent = eq.enabled ? "关闭均衡器" : "开启均衡器"; + toggle.classList.toggle("active", !!eq.enabled); + } + const hint = $("#eqHint"); + if (hint) { + hint.textContent = eq.globalMix + ? "当前作用于全局混音(会影响本机所有声音)" + : "均衡器作用于本机播放输出,不改动系统音量"; + } + const bands = Array.isArray(eq.bands) ? eq.bands : []; + const container = $("#eqBands"); + if (container && container.childElementCount !== bands.length) { + container.innerHTML = bands.map((band, index) => ``).join(""); + bands.forEach((band, index) => { + const input = $("#eqBand" + index); + if (!input) return; + input.addEventListener("input", () => { + const value = $("#eqValue" + index); + if (value) value.textContent = Number(input.value).toFixed(1); + }); + input.addEventListener("change", () => updateEqualizer({ levels: currentEqLevels() })); + }); + } else if (container) { + bands.forEach((band, index) => { + const input = $("#eqBand" + index); + // 正在拖动的那根滑杆不要被轮询覆盖。 + if (input && document.activeElement !== input) input.value = band.level; + const value = $("#eqValue" + index); + if (value) value.textContent = Number(band.level).toFixed(1); + }); + } + const presets = Array.isArray(eq.presets) ? eq.presets : []; + const presetBox = $("#eqPresets"); + if (presetBox && presetBox.childElementCount !== presets.length) { + presetBox.innerHTML = presets.map((name) => ``).join(""); + presetBox.querySelectorAll(".eq-preset").forEach((button) => { + button.addEventListener("click", () => updateEqualizer({ preset: button.dataset.preset })); + }); + } + presetBox?.querySelectorAll(".eq-preset").forEach((button) => { + button.classList.toggle("active", button.dataset.preset === eq.preset); + }); +} + +function currentEqLevels() { + return Array.from($$("#eqBands input[type=range]")).map((input) => Number(input.value)); +} + +async function updateEqualizer(values) { + try { + await api("/api/v1/player/equalizer", { method: "PATCH", body: JSON.stringify(values) }); + await refresh(); + } catch (error) { toast(error.message); } +} + async function playerAction(action) { try { await api("/api/v1/player/actions", { method: "POST", body: JSON.stringify({ action }) }); @@ -208,6 +272,9 @@ $("#hotNext").addEventListener("click", () => loadHot(uiState.hotPage + 1)); // 音乐音量可调; 麦克风音量由功放硬件控制, 不再向设备发送无效果请求。 $("#musicVolume").addEventListener("input", (event) => { $("#musicValue").textContent = event.target.value; }); $("#musicVolume").addEventListener("change", (event) => updateAudio({ musicVolume: Number(event.target.value) })); +// 均衡器入口(与设备端弹窗等价): 开关 / 恢复标准 +$("#eqToggle")?.addEventListener("click", () => updateEqualizer({ enabled: !uiState.eqEnabled })); +$("#eqReset")?.addEventListener("click", () => updateEqualizer({ preset: "标准" })); document.addEventListener("visibilitychange", () => { uiState.visible = !document.hidden; if (uiState.visible) refresh(); }); window.setInterval(() => { if (uiState.visible) refresh(); }, 1000); refresh(); diff --git a/app/src/main/assets/mobile/index.html b/app/src/main/assets/mobile/index.html index e06c007..ebcaba0 100644 --- a/app/src/main/assets/mobile/index.html +++ b/app/src/main/assets/mobile/index.html @@ -40,6 +40,17 @@ +
+
≡均衡器
已关闭
+
+ + +
+
+
+

均衡器作用于本机播放输出,不改动系统音量

+
+
02
下一首队列为空
diff --git a/app/src/main/java/com/local/ktv/KtvEqualizer.kt b/app/src/main/java/com/local/ktv/KtvEqualizer.kt new file mode 100644 index 0000000..81f5258 --- /dev/null +++ b/app/src/main/java/com/local/ktv/KtvEqualizer.kt @@ -0,0 +1,311 @@ +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, val minDb: Float, val maxDb: Float) { + val bandCount: Int get() = frequencies.size + } + + /** + * 预设曲线:以「频段在可用范围内的相对位置 0..1」为横轴、以最大增益的比例 -1..1 为纵轴。 + * 这样同一套预设能自适应任意频段数的设备。 + */ + private val PRESETS: LinkedHashMap>> = 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.1f, 0.35f to 0.25f, 0.7f to 0.15f, 1f to -0.05f), + "摇滚" to listOf(0f to 0.7f, 0.3f to -0.4f, 1f to 0.6f), + "唱将" to listOf(0f to -0.2f, 0.5f to 0.55f, 0.8f to 0.3f, 1f to 0.05f), + ) + + /** 手动改过频段后使用的预设名。 */ + const val CUSTOM_PRESET = "自定义" + + @Volatile var enabled: Boolean = false + private set + + @Volatile var globalMix: Boolean = false + private set + + @Volatile var preset: String = "标准" + 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() + + // ─── 设备能力 ─────────────────────────────────────── + + fun presetNames(): List = 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 = 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>, 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) { + val targets: Set = 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 +} diff --git a/app/src/main/java/com/local/ktv/KtvStore.kt b/app/src/main/java/com/local/ktv/KtvStore.kt index 7365ef1..2c5c2d2 100644 --- a/app/src/main/java/com/local/ktv/KtvStore.kt +++ b/app/src/main/java/com/local/ktv/KtvStore.kt @@ -47,6 +47,12 @@ class KtvStore { @JvmField var tableBroadcastEnabled = true @JvmField var tableBroadcastSeconds = 8 @JvmField var tableBroadcastText = "欢迎光临" + // ===== 均衡器(KtvEqualizer)===== + @JvmField var eqEnabled = false + @JvmField var eqPreset = "标准" + /** 各频段增益(dB),逗号分隔;不同设备频段数可能不同,加载后会按实际频段数对齐。 */ + @JvmField var eqLevels = "" + @JvmField var eqGlobalMix = false @JvmField val orderQueue = mutableListOf() @JvmField val sangHistory = mutableListOf() @JvmField val scoreHistory = mutableListOf() @@ -96,6 +102,10 @@ class KtvStore { tableBroadcastEnabled = root.optBoolean("tableBroadcastEnabled", true) tableBroadcastSeconds = root.optInt("tableBroadcastSeconds", 8) tableBroadcastText = root.optString("tableBroadcastText", "欢迎光临") + eqEnabled = root.optBoolean("eqEnabled", false) + eqPreset = root.optString("eqPreset", "标准") + eqLevels = root.optString("eqLevels", "") + eqGlobalMix = root.optBoolean("eqGlobalMix", false) readSongs(root.optJSONArray("orderQueue"), orderQueue) readSongs(root.optJSONArray("sangHistory"), sangHistory) root.optJSONArray("scoreHistory").copyStringsTo(scoreHistory) @@ -164,6 +174,10 @@ class KtvStore { put("tableBroadcastEnabled", tableBroadcastEnabled) put("tableBroadcastSeconds", tableBroadcastSeconds) put("tableBroadcastText", tableBroadcastText) + put("eqEnabled", eqEnabled) + put("eqPreset", eqPreset) + put("eqLevels", eqLevels) + put("eqGlobalMix", eqGlobalMix) put("orderQueue", writeSongs(orderQueue)) put("sangHistory", writeSongs(sangHistory)) put("scoreHistory", JSONArray(scoreHistory.take(100))) diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt index dcd6151..3e400f9 100644 --- a/app/src/main/java/com/local/ktv/MainActivity.kt +++ b/app/src/main/java/com/local/ktv/MainActivity.kt @@ -246,6 +246,8 @@ class MainActivity : AppCompatActivity() { override fun run() { updateFullScreenProgress() updateLyricView() + // 播放会话会随歌曲变化,均衡器需要周期性对齐到当前实际播放的会话。 + syncEqualizerSessions() // 播放界面左上角的「当前播放 / 下一首」实时刷新: 无论队列是本地点歌、 // 手机扫码点歌还是自动切歌而变化, 500ms 内必然同步到界面。 refreshNowPlayingLabels() @@ -694,6 +696,12 @@ class MainActivity : AppCompatActivity() { micActivator.stop() releaseVocalPlayer() if (::playbackEngine.isInitialized) playbackEngine.release() + KtvEqualizer.releaseAll() + if (isFinishing) { + // 公放随机清单只在内存中,App 退出即清理(不属于已点清单,也不落盘)。 + RandomPlayQueue.clear() + MiliLog.info("RANDOM-CLEAR app exit") + } remoteServer.stop() library.muse.close() stateIo.shutdown() @@ -2503,9 +2511,13 @@ class MainActivity : AppCompatActivity() { } showVolumeResult() } - listOf(minus, plus, mute).forEachIndexed { index, button -> - button.nextFocusLeftId = listOf(minus, plus, mute).getOrNull(index - 1)?.id ?: button.id - button.nextFocusRightId = listOf(minus, plus, mute).getOrNull(index + 1)?.id ?: button.id + // 播放界面「调音」面板里的均衡器入口(与主页顶栏「调音」弹窗里的入口等价)。 + val equalizer = volumeButton("均衡器") + equalizer.setOnClickListener { showEqualizerDialog() } + val volumeButtons = listOf(minus, plus, mute, equalizer) + volumeButtons.forEachIndexed { index, button -> + button.nextFocusLeftId = volumeButtons.getOrNull(index - 1)?.id ?: button.id + button.nextFocusRightId = volumeButtons.getOrNull(index + 1)?.id ?: button.id button.nextFocusUpId = fullScreenToneControl?.id ?: button.id button.nextFocusDownId = button.id buttons.addView(button, LinearLayout.LayoutParams(dp(136), dp(58)).apply { @@ -2744,6 +2756,11 @@ class MainActivity : AppCompatActivity() { bindAtmospherePreset(root, R.id.btn_preset_lyric, "抒情") bindAtmospherePreset(root, R.id.btn_preset_rock, "摇滚") bindAtmospherePreset(root, R.id.btn_preset_singer, "唱将") + // 均衡器入口:主页顶栏「调音」/播放界面「调音」/控制层「音量」都走本弹窗。 + root.findViewById(R.id.btn_tuning_equalizer)?.setOnClickListener { + dialog.dismiss() + showEqualizerDialog() + } dialog.showForTv(afterShow = { dialog.window?.apply { @@ -2764,6 +2781,314 @@ class MainActivity : AppCompatActivity() { }) } + // ===== 均衡器 ===== + + /** 把持久化的 "1.0,-2.0,..." 解析成频段增益数组。 */ + private fun parseEqLevels(raw: String?): FloatArray { + if (raw.isNullOrBlank()) return FloatArray(0) + return raw.split(',').mapNotNull { it.trim().toFloatOrNull() }.toFloatArray() + } + + /** + * 均衡器滑杆的刻度数。 + * + * 步进 = 设备增益范围 / 该刻度数,这样刻度会随设备自适应,同时保证 **0 dB 一定落在 + * 某个刻度上**(若写死 2 dB 步进,-15~+15 的范围里就没有 0 dB 这一档,无法调到"平坦")。 + */ + private val eqSliderSteps = 30 + + private fun eqLevelText(level: Float): String = + (if (level > 0f) "+" else "") + String.format(Locale.ROOT, "%.1f", level) + + /** + * 让均衡器挂到当前所有实际播放会话上。 + * + * 会话 id 随歌曲变化(IJK 每次 prepare 都可能重建 AudioTrack),因此由 500ms 的 + * `lyricTicker` 周期调用:新会话自动挂上,消失的会话延迟释放。 + */ + private fun syncEqualizerSessions() { + if (KtvEqualizer.attachedSessionCount() == 0 && !KtvEqualizer.enabled) return + KtvEqualizer.syncSessions( + listOf( + player?.audioSessionId() ?: 0, + vocalPlayer?.audioSessionId ?: 0, + embeddedVocalEngine?.audioSessionId() ?: 0, + ), + ) + } + + /** 均衡器开关/参数变化后的统一收尾:立刻挂载 + 落盘。 */ + private fun applyEqualizerSettings() { + syncEqualizerSessions() + saveState() + } + + /** + * 均衡器弹窗 —— App 内统一入口(主页顶栏「调音」、播放界面「调音」、 + * 声音设置「均衡器入口」,以及远程点歌页面的同名入口)。 + * + * 内核见 [KtvEqualizer]:参考开源项目 OpenEQ,直接使用系统 `audiofx.Equalizer`, + * 并按设备真实支持的频段数与增益范围自适应,因此频段是动态生成的。 + * UI 与「调音」弹窗保持一致:深色圆角面板 + 紫色频段卡 + 橙色焦点反馈。 + */ + private fun showEqualizerDialog() { + val caps = KtvEqualizer.capabilities(this) + val bandLabels = KtvEqualizer.bandLabels(caps) + val working = KtvEqualizer.levelsSnapshot().let { saved -> + FloatArray(caps.bandCount) { saved.getOrElse(it) { 0f } } + } + val globalSupported = KtvEqualizer.globalMixSupported() + + val panel = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + background = getDrawable(R.drawable.bg_tuning_dialog) + setPadding(dp(26), dp(16), dp(26), dp(18)) + } + TvFocusStyler.suppressFill(panel) + + // ── 标题 ── + val titleRow = FrameLayout(this) + titleRow.addView( + label("均衡器", 24, Color.WHITE).apply { gravity = Gravity.CENTER }, + FrameLayout.LayoutParams(-1, dp(46)), + ) + val close = label("×", 22, Color.argb(153, 255, 255, 255)).apply { + gravity = Gravity.CENTER + isClickable = true + isFocusable = true + TvFocusStyler.installAction(this) + } + titleRow.addView( + close, + FrameLayout.LayoutParams(dp(44), dp(40), Gravity.END or Gravity.CENTER_VERTICAL), + ) + panel.addView(titleRow, LinearLayout.LayoutParams(-1, dp(46))) + + // ── 状态行:总开关 / 全局混音 / 重置 ── + val switchRow = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + fun pill(text: String, action: () -> Unit): TextView = TextView(this).apply { + this.text = text + textSize = 16f + setTextColor(Color.WHITE) + gravity = Gravity.CENTER + background = getDrawable(R.drawable.bg_btn_glass) + isClickable = true + isFocusable = true + setPadding(dp(10), dp(6), dp(10), dp(6)) + setOnClickListener { action() } + TvFocusStyler.installAction(this) + } + lateinit var enabledPill: TextView + lateinit var globalPill: TextView + fun renderPills() { + enabledPill.text = if (KtvEqualizer.enabled) "均衡器:开启" else "均衡器:关闭" + enabledPill.setTextColor(if (KtvEqualizer.enabled) Color.rgb(39, 204, 164) else Color.WHITE) + globalPill.text = if (!globalSupported) { + "全局混音:不支持" + } else if (KtvEqualizer.globalMix) { + "全局混音:开" + } else { + "全局混音:关" + } + globalPill.alpha = if (globalSupported) 1f else 0.45f + } + enabledPill = pill(if (KtvEqualizer.enabled) "均衡器:开启" else "均衡器:关闭") { + KtvEqualizer.setEnabled(!KtvEqualizer.enabled) + renderPills() + applyEqualizerSettings() + toast(if (KtvEqualizer.enabled) "均衡器已开启" else "均衡器已关闭") + } + if (KtvEqualizer.enabled) enabledPill.setTextColor(Color.rgb(39, 204, 164)) + globalPill = pill("全局混音:关") { + if (!globalSupported) { + toast("本机不支持全局混音") + return@pill + } + KtvEqualizer.setGlobalMix(!KtvEqualizer.globalMix) + renderPills() + applyEqualizerSettings() + toast(if (KtvEqualizer.globalMix) "均衡器已作用于全局混音" else "均衡器只作用于本 App") + } + // 「重置」要回到『标准』预设并刷新滑杆,但滑杆此时还没建好,用 lateinit 延后绑定。 + lateinit var resetEqualizer: () -> Unit + val resetPill = pill("重置") { resetEqualizer() } + renderPills() + switchRow.addView(enabledPill, LinearLayout.LayoutParams(-2, dp(40)).apply { marginEnd = dp(8) }) + switchRow.addView(globalPill, LinearLayout.LayoutParams(-2, dp(40)).apply { marginEnd = dp(8) }) + switchRow.addView(resetPill, LinearLayout.LayoutParams(-2, dp(40))) + panel.addView(switchRow, LinearLayout.LayoutParams(-1, dp(48))) + + // ── 设备能力提示 ── + val hint = label( + "本机支持 ${caps.bandCount} 段 · ${String.format(Locale.ROOT, "%.0f", caps.minDb)} ~ " + + "${String.format(Locale.ROOT, "%.0f", caps.maxDb)} dB" + + if (globalSupported) "" else " · 本机不支持全局混音", + 13, + Color.argb(150, 255, 255, 255), + ) + panel.addView(hint, LinearLayout.LayoutParams(-1, dp(26))) + + // ── 频段滑杆 ── + val bars = ArrayList(caps.bandCount) + val valueViews = ArrayList(caps.bandCount) + // 刻度换算:0 dB 必定落在某个刻度上(见 eqSliderSteps 说明)。 + val stepDb = (caps.maxDb - caps.minDb) / eqSliderSteps + fun toProgress(level: Float): Int = + Math.round((level - caps.minDb) / stepDb).coerceIn(0, eqSliderSteps) + fun fromProgress(progress: Int): Float = + (caps.minDb + progress * stepDb).coerceIn(caps.minDb, caps.maxDb) + // 预设按钮与高亮渲染先声明(频段滑杆回调里要用来刷新"自定义"高亮)。 + val presetButtons = LinkedHashMap() + fun renderPresets() { + presetButtons.forEach { (name, view) -> + val active = name == KtvEqualizer.preset + view.setTextColor(if (active) Color.rgb(255, 204, 92) else Color.WHITE) + view.alpha = if (active) 1f else 0.82f + } + } + /** 把内核当前频段值回灌到滑杆与数值文本。 */ + fun syncBarsFromEqualizer() { + val applied = KtvEqualizer.levelsSnapshot() + for (index in working.indices) { + working[index] = applied.getOrElse(index) { 0f } + bars[index].progress = toProgress(working[index]) + valueViews[index].text = eqLevelText(working[index]) + valueViews[index].setTextColor( + if (working[index] == 0f) Color.argb(170, 255, 255, 255) + else Color.rgb(255, 204, 92), + ) + } + } + fun applyBandLevels() { + KtvEqualizer.setLevels(working.copyOf()) + KtvEqualizer.markCustomPreset() + syncBarsFromEqualizer() + applyEqualizerSettings() + } + + caps.frequencies.forEachIndexed { index, _ -> + val row = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + row.addView( + label(bandLabels.getOrElse(index) { "${index + 1}" } + "Hz", 15, Color.WHITE).apply { + gravity = Gravity.CENTER + }, + LinearLayout.LayoutParams(dp(74), dp(38)), + ) + val seek = SeekBar(this).apply { + max = eqSliderSteps + isFocusable = true + isFocusableInTouchMode = true + setPadding(0, 0, 0, 0) + TvFocusStyler.installAction(this) + setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(bar: SeekBar?, progress: Int, fromUser: Boolean) { + if (!fromUser) return + working[index] = fromProgress(progress) + valueViews[index].text = eqLevelText(working[index]) + valueViews[index].setTextColor( + if (working[index] == 0f) Color.argb(170, 255, 255, 255) + else Color.rgb(255, 204, 92) + ) + } + + override fun onStartTrackingTouch(bar: SeekBar?) = Unit + + override fun onStopTrackingTouch(bar: SeekBar?) { + KtvEqualizer.setLevels(working.copyOf()) + KtvEqualizer.markCustomPreset() + renderPresets() + applyEqualizerSettings() + } + }) + } + bars.add(seek) + row.addView(seek, LinearLayout.LayoutParams(0, dp(38), 1f)) + val value = label("0.0", 15, Color.argb(170, 255, 255, 255)).apply { + gravity = Gravity.CENTER + } + valueViews.add(value) + row.addView(value, LinearLayout.LayoutParams(dp(76), dp(38))) + panel.addView(row, LinearLayout.LayoutParams(-1, dp(42))) + } + + // ── 预设 ── + val presetRow = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + } + KtvEqualizer.presetNames().forEach { name -> + val presetButton = TextView(this).apply { + text = name + textSize = 16f + setTextColor(Color.WHITE) + gravity = Gravity.CENTER + isClickable = true + isFocusable = true + background = getDrawable(R.drawable.bg_tuning_counter) + TvFocusStyler.installAction(this) + setOnClickListener { + KtvEqualizer.applyPreset(name) + syncBarsFromEqualizer() + renderPresets() + applyEqualizerSettings() + toast("均衡器预设:" + name) + } + } + presetButtons[name] = presetButton + presetRow.addView( + presetButton, + LinearLayout.LayoutParams(0, dp(56), 1f).apply { marginStart = dp(3); marginEnd = dp(3) }, + ) + } + panel.addView(presetRow, LinearLayout.LayoutParams(-1, dp(64))) + renderPresets() + resetEqualizer = { + KtvEqualizer.applyPreset("标准") + syncBarsFromEqualizer() + renderPresets() + applyEqualizerSettings() + toast("均衡器已重置为标准") + } + + // ── 底部 ── + val actions = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.END or Gravity.CENTER_VERTICAL + } + val done = pill("完成") { } + actions.addView(done, LinearLayout.LayoutParams(dp(110), dp(44))) + panel.addView(actions, LinearLayout.LayoutParams(-1, dp(52))) + + val scroll = android.widget.ScrollView(this).apply { addView(panel) } + val dialog = AlertDialog.Builder(this) + .setView(scroll) + .create() + close.setOnClickListener { dialog.dismiss() } + done.setOnClickListener { dialog.dismiss() } + dialog.restoreSourceFocusOnDismiss() + dialog.showForTv(afterShow = { + dialog.window?.apply { + setBackgroundDrawableResource(android.R.color.transparent) + attributes = attributes.apply { dimAmount = 0.72f } + addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + val available = (resources.displayMetrics.widthPixels * 0.92f).toInt() + setLayout(min(dp(700), available), ViewGroup.LayoutParams.WRAP_CONTENT) + } + // 初始化滑杆位置(不触发 fromUser 回调,避免被当成自定义)。 + bars.forEachIndexed { index, bar -> + bar.progress = toProgress(working.getOrElse(index) { 0f }) + valueViews[index].text = eqLevelText(working.getOrElse(index) { 0f }) + } + enabledPill.requestFocus() + }) + } + /** 4 个切换按钮的状态切换 */ private var muteBeforeVol = 80 private fun updateToggleState(btn: TextView?, on: Boolean) { @@ -3581,6 +3906,7 @@ class MainActivity : AppCompatActivity() { method == "POST" && path == "/api/v1/queue" -> remoteOrder(JSONObject(body)) method == "POST" && path == "/api/v1/player/actions" -> remotePlayerAction(JSONObject(body)) method == "PATCH" && path == "/api/v1/player/audio" -> remoteAudio(JSONObject(body)) + method == "PATCH" && path == "/api/v1/player/equalizer" -> remoteEqualizer(JSONObject(body)) path.startsWith("/api/v1/queue/") && method in arrayOf("PATCH", "DELETE") -> { remoteQueueMutation(method, URLDecoder.decode(path.substringAfterLast('/'), "UTF-8")) } @@ -3602,9 +3928,74 @@ class MainActivity : AppCompatActivity() { put("vocalMode", if (originalVocal) "original" else "accompany") put("musicVolume", musicVolume) put("micVolume", micVolume) + // 均衡器状态(远程点歌页面的均衡器入口据此渲染频段) + put("equalizer", equalizerJson()) } } + /** 均衡器状态 + 本机支持的频段(供远程页面渲染)。 */ + private fun equalizerJson(): JSONObject { + val caps = KtvEqualizer.capabilities(this) + val labels = KtvEqualizer.bandLabels(caps) + val levels = KtvEqualizer.levelsSnapshot() + return JSONObject().apply { + put("enabled", KtvEqualizer.enabled) + put("preset", KtvEqualizer.preset) + put("globalMix", KtvEqualizer.globalMix) + put("globalMixSupported", KtvEqualizer.globalMixSupported()) + put("minDb", caps.minDb.toDouble()) + put("maxDb", caps.maxDb.toDouble()) + put("bands", JSONArray().apply { + for (index in 0 until caps.bandCount) { + put( + JSONObject().apply { + put("label", labels.getOrElse(index) { (index + 1).toString() }) + put("freq", caps.frequencies.getOrElse(index) { 0f }.toDouble()) + put("level", levels.getOrElse(index) { 0f }.toDouble()) + }, + ) + } + }) + put("presets", JSONArray(KtvEqualizer.presetNames())) + } + } + + /** 远程点歌页面:调节均衡器(与 App 内均衡器弹窗等价)。 */ + private fun remoteEqualizer(json: JSONObject): LocalRemoteServer.ApiResponse { + if (json.has("globalMix")) { + if (!KtvEqualizer.globalMixSupported()) { + return remoteError(400, "GLOBAL_MIX_UNSUPPORTED", "本机不支持全局混音") + } + KtvEqualizer.setGlobalMix(json.optBoolean("globalMix")) + } + if (json.has("enabled")) { + KtvEqualizer.setEnabled(json.optBoolean("enabled")) + } + if (json.has("preset")) { + val name = json.optString("preset") + if (name !in KtvEqualizer.presetNames()) { + return remoteError(400, "BAD_PRESET", "预设不存在") + } + KtvEqualizer.applyPreset(name) + } + if (json.has("levels")) { + val array = json.optJSONArray("levels") + ?: return remoteError(400, "BAD_LEVELS", "频段格式错误") + val caps = KtvEqualizer.capabilities(this) + val levels = FloatArray(caps.bandCount) { index -> + array.optDouble(index, 0.0).toFloat().coerceIn(caps.minDb, caps.maxDb) + } + KtvEqualizer.setLevels(levels) + KtvEqualizer.markCustomPreset() + } + main.post { + syncEqualizerSessions() + saveState() + remoteStateVersion++ + } + return remoteOk(equalizerJson().put("message", "均衡器已更新")) + } + private fun remoteSearchJson(query: Map): JSONObject { val keyword = query["q"].orEmpty().trim() val type = query["type"].orEmpty().lowercase(Locale.ROOT).let { if (it == "song") "song" else "singer" } @@ -5254,6 +5645,14 @@ class MainActivity : AppCompatActivity() { // 队列为空(最后一首已播完/随机续播中未点歌) if (orderQueue!!.isEmpty()) { if (fromCompletion && autoNext) { + // 公放清单里的这首播完了:计数 +1,然后取清单里的下一首(同一轮不重复)。 + currentSong?.takeIf { RandomPlayQueue.isCurrentTrack(it) }?.let { finished -> + val count = RandomPlayQueue.markFinished(finished) + MiliLog.info( + "RANDOM-DONE title=${finished.title} count=$count " + + "cycle=${RandomPlayQueue.cycle} remaining=${RandomPlayQueue.remaining}", + ) + } playRandomLocalSong() return } @@ -5331,15 +5730,20 @@ class MainActivity : AppCompatActivity() { } } val allLocalSongs = localSongs.values.toList() - val alternatives = allLocalSongs.filter { stableId(it) != previousSongId } - val selected = (if (alternatives.isNotEmpty()) alternatives else allLocalSongs).randomOrNull() main.post { if (requestSerial != randomLocalRequestSerial || orderQueue!!.isNotEmpty() || currentSong != null) { return@post } + // 伪随机公放清单:按打乱后的顺序取下一首(一轮内不重复,自动吸纳新增歌曲)。 + val selected = RandomPlayQueue.next(allLocalSongs, avoidId = previousSongId) if (selected != null) { lastRandomLocalSongId = stableId(selected) + RandomPlayQueue.markStarted(selected) status?.text = "随机播放:${selected.title}" + MiliLog.info( + "RANDOM-PLAY title=${selected.title} cycle=${RandomPlayQueue.cycle} " + + "remaining=${RandomPlayQueue.remaining} played=${RandomPlayQueue.playedInCycle}", + ) play(selected) } else { playbackPreparing = false @@ -8859,7 +9263,28 @@ class MainActivity : AppCompatActivity() { addView(left, LinearLayout.LayoutParams(0, -2, 1f).apply { marginEnd = dp(28) }) addView(right, LinearLayout.LayoutParams(0, -2, 1f)) } - val scroll = android.widget.ScrollView(this).apply { addView(content) } + // 均衡器入口:与播放界面/主页「调音」里的入口等价,修改即时生效。 + val eqEntry = TextView(this).apply { + text = "均衡器入口 ›" + textSize = 17f + setTextColor(Color.WHITE) + gravity = Gravity.CENTER + background = getDrawable(R.drawable.bg_btn_glass) + isClickable = true + isFocusable = true + setPadding(dp(12), dp(10), dp(12), dp(10)) + setOnClickListener { showEqualizerDialog() } + TvFocusStyler.installAction(this) + } + val body = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView(content, LinearLayout.LayoutParams(-1, -2)) + addView(eqEntry, LinearLayout.LayoutParams(-1, dp(48)).apply { + marginStart = dp(28) + marginEnd = dp(28) + }) + } + val scroll = android.widget.ScrollView(this).apply { addView(body) } val dialog = AlertDialog.Builder(this).setTitle("声音设置") .setView(scroll) .setNegativeButton("关闭", null) @@ -9717,6 +10142,11 @@ class MainActivity : AppCompatActivity() { store.vocalChannelMode = vocalChannelMode store.musicVolume = musicVolume store.micVolume = micVolume + // 均衡器状态同样随设置一起持久化(下次启动自动恢复)。 + store.eqEnabled = KtvEqualizer.enabled + store.eqPreset = KtvEqualizer.preset + store.eqLevels = KtvEqualizer.levelsSnapshot().joinToString(",") + store.eqGlobalMix = KtvEqualizer.globalMix store.tone = tone store.atmosphere = atmosphere store.singMode = singMode @@ -9778,6 +10208,14 @@ class MainActivity : AppCompatActivity() { originalVocal = store.originalVocal vocalChannelMode = store.vocalChannelMode musicVolume = store.musicVolume + // 均衡器:先恢复保存值,再按本机真实频段数对齐(不同设备频段数/增益范围不同)。 + KtvEqualizer.restore( + enabled = store.eqEnabled, + presetName = store.eqPreset, + savedLevels = parseEqLevels(store.eqLevels), + useGlobalMix = store.eqGlobalMix && KtvEqualizer.globalMixSupported(), + ) + KtvEqualizer.alignToDevice(KtvEqualizer.capabilities(this)) micVolume = store.micVolume tone = store.tone.coerceIn(-5, 5) atmosphere = store.atmosphere diff --git a/app/src/main/java/com/local/ktv/RandomPlayQueue.kt b/app/src/main/java/com/local/ktv/RandomPlayQueue.kt new file mode 100644 index 0000000..b9192be --- /dev/null +++ b/app/src/main/java/com/local/ktv/RandomPlayQueue.kt @@ -0,0 +1,153 @@ +package com.local.ktv + +/** + * 公放随机清单(伪随机)。 + * + * 原来的「随机续播」是每首播完现随机挑一首,容易在短时间内重复播到同一首歌。 + * 这里改成一次性生成一份**打乱顺序的临时清单**,按顺序往下播: + * - 一轮之内**不重复**播放同一首歌([playedIds] 记录本轮已播); + * - 每首**播放完毕 +1**([playCounts] 计数,同时用于观测与诊断); + * - 每次取下一首都会重新比对曲库,**自动吸纳新增的本地歌曲**([refresh]), + * 被删除的歌自动从清单里剔除; + * - 一轮播完后自动开启新一轮(重新打乱),并尽量不把刚播完的那首放在开头。 + * + * 三条硬性约束(与需求一致): + * 1. **不显示**:清单只在内存中,界面上不展示; + * 2. **不关联点歌清单**:与 `MainActivity.orderQueue` 完全独立,随机歌曲不写入已点列表, + * 它是纯粹的「公放清单」; + * 3. **App 退出即清理**:纯内存态,随进程结束消失;退出路径也会主动调用 [clear]。 + */ +object RandomPlayQueue { + private val pending = ArrayDeque() + + /** 本轮已播(用于去重)。 */ + private val playedIds = LinkedHashSet() + + /** 每首播放完毕的次数(+1)。 */ + private val playCounts = HashMap() + + /** 曾经入过清单的曲目 id,用于识别「新增歌曲」。 */ + private val knownIds = LinkedHashSet() + + /** 最近一首由本清单开始播放的曲目 id(用于判断播完的是不是公放清单里的歌)。 */ + @Volatile private var lastStartedId: String = "" + + private val random = java.util.Random() + + /** 当前是第几轮(从 1 开始)。 */ + var cycle: Int = 0 + private set + + /** 清单里还剩几首。 */ + val remaining: Int get() = pending.size + + /** 本轮已播几首。 */ + val playedInCycle: Int get() = playedIds.size + + /** 清空清单(App 退出时调用)。 */ + fun clear() { + pending.clear() + playedIds.clear() + playCounts.clear() + knownIds.clear() + lastStartedId = "" + cycle = 0 + } + + /** 某首在本会话内播放完毕的次数。 */ + fun playCount(id: String): Int = playCounts[id] ?: 0 + + /** 这首是不是当前正在播的公放清单曲目。 */ + fun isCurrentTrack(song: Song): Boolean = KtvStore.stableId(song) == lastStartedId + + /** 开始播放某首随机曲目时登记(立即计入本轮已播,保证被打断也不会重复)。 */ + fun markStarted(song: Song) { + val id = KtvStore.stableId(song) + lastStartedId = id + playedIds.add(id) + } + + /** 某首随机曲目播放完毕:计数 +1。 */ + fun markFinished(song: Song): Int { + val id = KtvStore.stableId(song) + lastStartedId = "" + val next = (playCounts[id] ?: 0) + 1 + playCounts[id] = next + return next + } + + /** + * 取下一首。 + * + * @param candidates 当前可播的本地曲目(每次实时传入,从而自动吸纳新增歌曲) + * @param avoidId 尽量避开的曲目 id(通常是刚刚播完的那首) + * @return 下一首;曲库为空时返回 null + */ + fun next(candidates: List, avoidId: String? = null): Song? { + refresh(candidates) + val available = LinkedHashMap() + candidates.forEach { available[KtvStore.stableId(it)] = it } + + // 取清单里还没播过的第一首。 + while (pending.isNotEmpty()) { + val song = pending.removeFirst() + val id = KtvStore.stableId(song) + if (available.containsKey(id) && !playedIds.contains(id)) return song + } + + // 清单取空(本轮全部播完 / 都被删除)→ 开启新一轮。 + if (available.isEmpty()) return null + startNewCycle(available, avoidId) + while (pending.isNotEmpty()) { + val song = pending.removeFirst() + val id = KtvStore.stableId(song) + if (available.containsKey(id) && !playedIds.contains(id)) return song + } + // 极端兜底:曲库只有一首且被要求避开时仍要能播。 + return available.values.firstOrNull() + } + + /** + * 与当前曲库对齐:剔除已不存在的曲目,并把新出现的曲目随机插入清单。 + */ + private fun refresh(candidates: List) { + val currentIds = candidates.map { KtvStore.stableId(it) }.toSet() + + if (knownIds.isEmpty() && pending.isEmpty()) { + // 第一次使用:整库打乱作为第一轮清单。 + startNewCycle(candidates.associateBy { KtvStore.stableId(it) }, null) + return + } + + // 1) 剔除曲库里已不存在的曲目。 + pending.removeAll { !currentIds.contains(KtvStore.stableId(it)) } + playedIds.removeAll { !currentIds.contains(it) } + knownIds.retainAll(currentIds) + + // 2) 新增歌曲:随机插入清单,避免总是排在最后。 + val added = candidates.filter { knownIds.add(KtvStore.stableId(it)) } + if (added.isEmpty()) return + added.shuffled(random).forEach { song -> + val index = if (pending.isEmpty()) 0 else random.nextInt(pending.size + 1) + pending.add(index, song) + } + } + + /** 开启新一轮:重新打乱整库,尽量把 [avoidId] 放到最后。 */ + private fun startNewCycle(candidates: Map, avoidId: String?) { + cycle++ + playedIds.clear() + pending.clear() + val shuffled = candidates.values.shuffled(random).toMutableList() + if (avoidId != null && shuffled.size > 1) { + val head = shuffled.firstOrNull { KtvStore.stableId(it) == avoidId } + if (head != null) { + shuffled.remove(head) + shuffled.add(head) + } + } + pending.addAll(shuffled) + knownIds.clear() + knownIds.addAll(candidates.keys) + } +} diff --git a/app/src/main/java/com/local/ktv/player/KtvVideoView.kt b/app/src/main/java/com/local/ktv/player/KtvVideoView.kt index 4f19c6f..57f2d81 100644 --- a/app/src/main/java/com/local/ktv/player/KtvVideoView.kt +++ b/app/src/main/java/com/local/ktv/player/KtvVideoView.kt @@ -58,6 +58,9 @@ class KtvVideoView @JvmOverloads constructor( fun setTone(step: Int) = engine?.setTone(step) ?: Unit fun selectAudioTrack(original: Boolean): Boolean = engine?.selectAudioTrack(original) == true fun audioTrackCount(): Int = engine?.audioTrackCount() ?: 0 + + /** 当前播放的音频会话 id(供均衡器等音效挂载);未知返回 0。 */ + fun audioSessionId(): Int = engine?.audioSessionId() ?: 0 fun selectAudioChannel(channel: Int, volume: Float) = engine?.selectAudioChannel(channel, volume) ?: Unit val isPlaying: Boolean get() = engine?.isPlaying == true val currentPosition: Int get() = engine?.currentPosition ?: 0 @@ -290,6 +293,12 @@ class KtvPlaybackEngine(context: Context) { ijkPlayer?.trackInfo?.count { it.trackType == ITrackInfo.MEDIA_TRACK_TYPE_AUDIO } ?: 0 }.getOrDefault(0) + /** + * IJK 原生返回的真实 AudioTrack 会话 id(`IjkMediaPlayer_getAudioSessionId`)。 + * 音频均衡器需要它才能把音效挂到本播放器上;播放器尚未建立音轨时返回 0。 + */ + fun audioSessionId(): Int = runCatching { ijkPlayer?.audioSessionId ?: 0 }.getOrDefault(0) + fun selectAudioChannel(channel: Int, volume: Float) { val safeVolume = volume.coerceIn(0f, 1f) val ijk = ijkPlayer diff --git a/app/src/main/res/layout/dialog_tuning.xml b/app/src/main/res/layout/dialog_tuning.xml index 7d9dbf5..d20fca2 100644 --- a/app/src/main/res/layout/dialog_tuning.xml +++ b/app/src/main/res/layout/dialog_tuning.xml @@ -1,7 +1,7 @@ + + +