V1.3.4: 新增音频均衡器(参考 OpenEQ) + 随机续播改为伪随机公放清单
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, 仅本地打包测试。
This commit is contained in:
@@ -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)
|
||||
|
||||
+2
-2
@@ -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,否则
|
||||
|
||||
@@ -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)}
|
||||
.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}
|
||||
|
||||
@@ -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) => `<label class="eq-band"><span>${escapeHtml(band.label)}Hz</span><b><em id="eqValue${index}">${Number(band.level).toFixed(1)}</em>dB</b><input id="eqBand${index}" type="range" min="${eq.minDb}" max="${eq.maxDb}" step="1" value="${band.level}"></label>`).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) => `<button type="button" class="eq-preset" data-preset="${escapeHtml(name)}">${escapeHtml(name)}</button>`).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();
|
||||
|
||||
@@ -40,6 +40,17 @@
|
||||
<label class="mic-hw"><span><i class="volume-icon mic">●</i>麦克风音量</span><small>由功放/混音器硬件调节</small><input id="micVolume" type="range" min="0" max="100" value="70" disabled></label>
|
||||
</article>
|
||||
|
||||
<article class="glass-card eq-panel">
|
||||
<div class="panel-title"><div><span class="tiny-icon">≡</span><b>均衡器</b></div><small id="eqState">已关闭</small></div>
|
||||
<div class="seg eq-switch">
|
||||
<button type="button" id="eqToggle">开启均衡器</button>
|
||||
<button type="button" id="eqReset">恢复标准</button>
|
||||
</div>
|
||||
<div id="eqBands" class="eq-bands"></div>
|
||||
<div id="eqPresets" class="eq-presets"></div>
|
||||
<p class="eq-hint" id="eqHint">均衡器作用于本机播放输出,不改动系统音量</p>
|
||||
</article>
|
||||
|
||||
<article class="next-card"><div class="next-number">02</div><div><small>下一首</small><b id="nextSong">队列为空</b></div><button data-page="queue">查看队列 <span>›</span></button></article>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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<Float>, val minDb: Float, val maxDb: Float) {
|
||||
val bandCount: Int get() = frequencies.size
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设曲线:以「频段在可用范围内的相对位置 0..1」为横轴、以最大增益的比例 -1..1 为纵轴。
|
||||
* 这样同一套预设能自适应任意频段数的设备。
|
||||
*/
|
||||
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.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<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
|
||||
}
|
||||
@@ -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<Song>()
|
||||
@JvmField val sangHistory = mutableListOf<Song>()
|
||||
@JvmField val scoreHistory = mutableListOf<String>()
|
||||
@@ -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)))
|
||||
|
||||
@@ -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<View?>(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<SeekBar>(caps.bandCount)
|
||||
val valueViews = ArrayList<TextView>(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<String, TextView>()
|
||||
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<String, String>): 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
|
||||
|
||||
@@ -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<Song>()
|
||||
|
||||
/** 本轮已播(用于去重)。 */
|
||||
private val playedIds = LinkedHashSet<String>()
|
||||
|
||||
/** 每首播放完毕的次数(+1)。 */
|
||||
private val playCounts = HashMap<String, Int>()
|
||||
|
||||
/** 曾经入过清单的曲目 id,用于识别「新增歌曲」。 */
|
||||
private val knownIds = LinkedHashSet<String>()
|
||||
|
||||
/** 最近一首由本清单开始播放的曲目 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<Song>, avoidId: String? = null): Song? {
|
||||
refresh(candidates)
|
||||
val available = LinkedHashMap<String, Song>()
|
||||
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<Song>) {
|
||||
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<String, Song>, 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="470dp"
|
||||
android:layout_height="340dp"
|
||||
android:layout_height="404dp"
|
||||
android:background="@drawable/bg_tuning_dialog"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="28dp"
|
||||
@@ -101,4 +101,16 @@
|
||||
<TextView android:id="@+id/btn_preset_singer" android:layout_width="0dp" android:layout_height="92dp"
|
||||
android:layout_weight="1" android:gravity="center" android:text="♪\n唱将" android:textColor="#FFFFFFFF" android:textSize="16sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 均衡器入口(内核参考开源项目 OpenEQ, 按设备真实频段自适应) -->
|
||||
<TextView
|
||||
android:id="@+id/btn_tuning_equalizer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="46dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:background="@drawable/bg_btn_glass"
|
||||
android:gravity="center"
|
||||
android:text="均衡器"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="17sp" />
|
||||
</LinearLayout>
|
||||
|
||||
Reference in New Issue
Block a user