麦克风: 外接输入设备热插拔跟随重建 + 音量UI改硬件调节提示

- MicActivator: 支持 restart/setPreferredDevice(API26), 输入路由切换后重建占用
- MainActivity: AudioDeviceCallback(API23+)/HEADSET广播监听, USB/外接麦插入后
  自动重建 AudioRecord 跟随当前输入设备
- 麦克风音量标准Android无输入增益API: 远程界面滑杆禁用并提示'由功放/混音器
  硬件调节', 本地调音台+/−按钮同样提示, 避免假调节无效果
This commit is contained in:
zijunchiang
2026-09-06 01:33:59 +08:00
parent 197b01f806
commit 61ad000daf
6 changed files with 125 additions and 20 deletions
+2
View File
@@ -15,3 +15,5 @@
.rank-pager button:disabled{opacity:.35;box-shadow:none} .rank-pager button:disabled{opacity:.35;box-shadow:none}
.rank-pager b{min-width:64px;text-align:center;color:#b9a9c9;font-size:11px} .rank-pager b{min-width:64px;text-align:center;color:#b9a9c9;font-size:11px}
.empty.compact{padding:42px 15px} .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)}
+6 -5
View File
@@ -75,7 +75,9 @@ async function refresh() {
$("#musicVolume").value = data.musicVolume ?? 70; $("#musicVolume").value = data.musicVolume ?? 70;
$("#micVolume").value = data.micVolume ?? 70; $("#micVolume").value = data.micVolume ?? 70;
$("#musicValue").textContent = data.musicVolume ?? 70; $("#musicValue").textContent = data.musicVolume ?? 70;
$("#micValue").textContent = data.micVolume ?? 70; // 麦克风音量由功放/混音器硬件调节(滑杆已禁用), 仅保留显示值。
const micHw = $("#micValue");
if (micHw) micHw.textContent = data.micVolume ?? 70;
$("#original").classList.toggle("active", data.vocalMode === "original"); $("#original").classList.toggle("active", data.vocalMode === "original");
$("#accompany").classList.toggle("active", data.vocalMode !== "original"); $("#accompany").classList.toggle("active", data.vocalMode !== "original");
$("#nextSong").textContent = data.next?.title || "队列为空"; $("#nextSong").textContent = data.next?.title || "队列为空";
@@ -203,10 +205,9 @@ $("#searchForm").addEventListener("submit", (event) => { event.preventDefault();
$("#more").addEventListener("click", () => search(false)); $("#more").addEventListener("click", () => search(false));
$("#hotPrev").addEventListener("click", () => loadHot(uiState.hotPage - 1)); $("#hotPrev").addEventListener("click", () => loadHot(uiState.hotPage - 1));
$("#hotNext").addEventListener("click", () => loadHot(uiState.hotPage + 1)); $("#hotNext").addEventListener("click", () => loadHot(uiState.hotPage + 1));
["music", "mic"].forEach((kind) => { // 音乐音量可调; 麦克风音量由功放硬件控制, 不再向设备发送无效果请求。
$("#" + kind + "Volume").addEventListener("input", (event) => { $("#" + kind + "Value").textContent = event.target.value; }); $("#musicVolume").addEventListener("input", (event) => { $("#musicValue").textContent = event.target.value; });
$("#" + kind + "Volume").addEventListener("change", (event) => updateAudio({ [kind + "Volume"]: Number(event.target.value) })); $("#musicVolume").addEventListener("change", (event) => updateAudio({ musicVolume: Number(event.target.value) }));
});
document.addEventListener("visibilitychange", () => { uiState.visible = !document.hidden; if (uiState.visible) refresh(); }); document.addEventListener("visibilitychange", () => { uiState.visible = !document.hidden; if (uiState.visible) refresh(); });
window.setInterval(() => { if (uiState.visible) refresh(); }, 1000); window.setInterval(() => { if (uiState.visible) refresh(); }, 1000);
refresh(); refresh();
+1 -1
View File
@@ -37,7 +37,7 @@
<div class="panel-title"><div><span class="tiny-icon"></span><b>声音控制</b></div><small>即时生效</small></div> <div class="panel-title"><div><span class="tiny-icon"></span><b>声音控制</b></div><small>即时生效</small></div>
<div class="seg"><button id="accompany" data-vocal="accompany"><span></span>伴唱</button><button id="original" data-vocal="original"><span></span>原唱</button></div> <div class="seg"><button id="accompany" data-vocal="accompany"><span></span>伴唱</button><button id="original" data-vocal="original"><span></span>原唱</button></div>
<label><span><i class="volume-icon"></i>音乐音量</span><b><em id="musicValue">70</em>%</b><input id="musicVolume" type="range" min="0" max="100" value="70"></label> <label><span><i class="volume-icon"></i>音乐音量</span><b><em id="musicValue">70</em>%</b><input id="musicVolume" type="range" min="0" max="100" value="70"></label>
<label><span><i class="volume-icon mic"></i>麦克风音量</span><b><em id="micValue">70</em>%</b><input id="micVolume" type="range" min="0" max="100" value="70"></label> <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>
<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> <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>
@@ -5,6 +5,9 @@ import android.app.AlertDialog
import android.content.DialogInterface import android.content.DialogInterface
import android.content.DialogInterface.OnShowListener import android.content.DialogInterface.OnShowListener
import android.content.Intent import android.content.Intent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.IntentFilter
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.content.pm.PackageManager import android.content.pm.PackageManager
@@ -512,9 +515,72 @@ class MainActivity : AppCompatActivity() {
} }
} }
micActivator.start() micActivator.start()
registerMicInputWatcher()
Log.i(TAG, "麦克风输入已激活(演唱输出通道占用)") Log.i(TAG, "麦克风输入已激活(演唱输出通道占用)")
} }
/**
* 监听输入设备变化: 外接麦克风(USB 声卡/3.5mm )插入时系统会把默认输入路由切到外接,
* AudioRecord 随之失效检测到变化后重建占用, 让演唱输出通道跟随当前麦克风
*/
private fun registerMicInputWatcher() {
val manager = audioManager ?: return
if (Build.VERSION.SDK_INT >= 23) {
manager.registerAudioDeviceCallback(micDeviceCallback, null)
} else {
runCatching {
val filter = IntentFilter(Intent.ACTION_HEADSET_PLUG)
registerReceiver(micHotplugReceiver, filter)
}
}
}
private fun unregisterMicInputWatcher() {
val manager = audioManager ?: return
if (Build.VERSION.SDK_INT >= 23) {
manager.unregisterAudioDeviceCallback(micDeviceCallback)
} else {
runCatching { unregisterReceiver(micHotplugReceiver) }
}
}
private val micDeviceCallback = object : android.media.AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out android.media.AudioDeviceInfo>) {
handleMicInputDevicesChanged(addedDevices.any { isMicInputDevice(it) })
}
override fun onAudioDevicesRemoved(removedDevices: Array<out android.media.AudioDeviceInfo>) {
handleMicInputDevicesChanged(removedDevices.any { isMicInputDevice(it) })
}
}
private val micHotplugReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
handleMicInputDevicesChanged(true)
}
}
/** 是否为可用的麦克风输入设备(内置/耳机/USB 等)。 */
private fun isMicInputDevice(device: android.media.AudioDeviceInfo): Boolean {
if (!device.isSource) return false
return when (device.type) {
android.media.AudioDeviceInfo.TYPE_BUILTIN_MIC,
android.media.AudioDeviceInfo.TYPE_USB_HEADSET,
android.media.AudioDeviceInfo.TYPE_USB_DEVICE,
android.media.AudioDeviceInfo.TYPE_HDMI,
android.media.AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
android.media.AudioDeviceInfo.TYPE_LINE_ANALOG,
-> true
else -> false
}
}
/** 输入设备增删后: 重新打开麦克风占用, 跟随系统当前路由(优先外接)。 */
private fun handleMicInputDevicesChanged(_changed: Boolean) {
// 延迟少许等系统完成路由切换, 再重建 AudioRecord。
main.postDelayed({ micActivator.restart() }, 300L)
}
private fun initializeDatabase() { private fun initializeDatabase() {
if (databaseBootstrapRunning) return if (databaseBootstrapRunning) return
databaseBootstrapRunning = true databaseBootstrapRunning = true
@@ -603,6 +669,7 @@ class MainActivity : AppCompatActivity() {
main.removeCallbacks(remoteNetworkMonitor) main.removeCallbacks(remoteNetworkMonitor)
main.removeCallbacks(qrAutoHideMonitor) main.removeCallbacks(qrAutoHideMonitor)
stopRecording(false) stopRecording(false)
unregisterMicInputWatcher()
micActivator.stop() micActivator.stop()
releaseVocalPlayer() releaseVocalPlayer()
if (::playbackEngine.isInitialized) playbackEngine.release() if (::playbackEngine.isInitialized) playbackEngine.release()
@@ -2644,15 +2711,11 @@ class MainActivity : AppCompatActivity() {
}) })
root.findViewById<View?>(R.id.btn_mic_down) root.findViewById<View?>(R.id.btn_mic_down)
.setOnClickListener(View.OnClickListener { v: View? -> .setOnClickListener(View.OnClickListener { v: View? ->
micVolume = max(0, micVolume - 5) toast("麦克风音量由功放/混音器硬件调节")
micValue.setText(micVolume.toString())
saveState()
}) })
root.findViewById<View?>(R.id.btn_mic_up) root.findViewById<View?>(R.id.btn_mic_up)
.setOnClickListener(View.OnClickListener { v: View? -> .setOnClickListener(View.OnClickListener { v: View? ->
micVolume = min(100, micVolume + 5) toast("麦克风音量由功放/混音器硬件调节")
micValue.setText(micVolume.toString())
saveState()
}) })
bindAtmospherePreset(root, R.id.btn_preset_pop, "流行") bindAtmospherePreset(root, R.id.btn_preset_pop, "流行")
bindAtmospherePreset(root, R.id.btn_preset_lyric, "抒情") bindAtmospherePreset(root, R.id.btn_preset_lyric, "抒情")
@@ -1,5 +1,6 @@
package com.local.ktv package com.local.ktv
import android.media.AudioDeviceInfo
import android.media.AudioFormat import android.media.AudioFormat
import android.media.AudioRecord import android.media.AudioRecord
import android.media.MediaRecorder import android.media.MediaRecorder
@@ -15,6 +16,11 @@ import android.util.Log
* 做法: 用一个低开销的 [AudioRecord] 常驻打开麦克风并循环读取丢弃数据, * 做法: 用一个低开销的 [AudioRecord] 常驻打开麦克风并循环读取丢弃数据,
* 保持输入通道占用(不写文件、不对外暴露声音)。与 MediaRecorder 录音互斥: * 保持输入通道占用(不写文件、不对外暴露声音)。与 MediaRecorder 录音互斥:
* 开始录音前先 pause, 结束录音后自动 resume。 * 开始录音前先 pause, 结束录音后自动 resume。
*
* 外接麦克风(USB/耳机麦)热插拔时, 系统会把默认输入路由切到外接设备并使
* 原 AudioRecord 失效 —— 此时需重建 AudioRecord 并优先占用当前活动输入设备。
* 因此对外暴露 [restart] 供设备变化监听调用; [setPreferredInput] 支持 Android 8.0+
* 的 AudioRecord.setPreferredDevice 直接把输入锁定到指定麦克风(优先外接)。
*/ */
class MicActivator { class MicActivator {
private val tag = "MicActivator" private val tag = "MicActivator"
@@ -23,6 +29,9 @@ class MicActivator {
@Volatile private var running = false @Volatile private var running = false
@Volatile private var paused = false @Volatile private var paused = false
/** 期望优先使用的输入设备; null = 跟随系统默认(自动切到外接)。 */
@Volatile private var preferredInput: AudioDeviceInfo? = null
val isActive: Boolean get() = running && !paused val isActive: Boolean get() = running && !paused
/** 打开麦克风并保持占用。失败静默(设备无麦克风时不影响 App 使用)。 */ /** 打开麦克风并保持占用。失败静默(设备无麦克风时不影响 App 使用)。 */
@@ -31,6 +40,28 @@ class MicActivator {
if (running) return if (running) return
running = true running = true
paused = false paused = false
spawnThread()
}
/** 输入设备发生变化(外接麦克风插入/拔出等)时重建 AudioRecord, 跟随新路由。 */
@Synchronized
fun restart() {
if (!running || paused) return
releaseInternal()
thread?.interrupt()
thread = null
spawnThread()
}
/** 指定首选输入设备(传入 null 恢复跟随系统默认)。 */
@Synchronized
fun setPreferredInput(device: AudioDeviceInfo?) {
preferredInput = device
restart()
}
@Synchronized
private fun spawnThread() {
thread = Thread({ readLoop() }, "mic-activator").also { it.isDaemon = true; it.start() } thread = Thread({ readLoop() }, "mic-activator").also { it.isDaemon = true; it.start() }
} }
@@ -50,7 +81,7 @@ class MicActivator {
if (!running) return if (!running) return
if (!paused) return if (!paused) return
paused = false paused = false
thread = Thread({ readLoop() }, "mic-activator").also { it.isDaemon = true; it.start() } spawnThread()
} }
/** 彻底停止(不再自动恢复)。 */ /** 彻底停止(不再自动恢复)。 */
@@ -89,6 +120,11 @@ class MicActivator {
sleepQuietly(5000) sleepQuietly(5000)
continue continue
} }
// Android 8.0+: 指定首选输入设备(如刚插入的外接麦克风), 避免仍占用内建。
val target = preferredInput
if (target != null && android.os.Build.VERSION.SDK_INT >= 26) {
runCatching { recorder.setPreferredDevice(target) }
}
record = recorder record = recorder
recorder.startRecording() recorder.startRecording()
val buffer = ByteArray(minBuffer.coerceAtLeast(4096)) val buffer = ByteArray(minBuffer.coerceAtLeast(4096))
@@ -125,3 +161,4 @@ class MicActivator {
try { Thread.sleep(ms) } catch (ignored: InterruptedException) { Thread.currentThread().interrupt() } try { Thread.sleep(ms) } catch (ignored: InterruptedException) { Thread.currentThread().interrupt() }
} }
} }
@@ -97,12 +97,12 @@
android:textSize="14sp" /> android:textSize="14sp" />
</LinearLayout> </LinearLayout>
<!-- 麦克风 --> <!-- 麦克风(增益/效果由功放混音器硬件调节, 滑杆仅作指示) -->
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="4dp" android:layout_marginBottom="2dp"
android:text="麦克风音量" android:text="麦克风音量(功放/混音器硬件调节)"
android:textColor="#FFE0E0FF" android:textColor="#FFE0E0FF"
android:textSize="13sp" /> android:textSize="13sp" />
@@ -121,7 +121,8 @@
android:max="100" android:max="100"
android:progress="60" android:progress="60"
android:progressTint="#FF27CCA4" android:progressTint="#FF27CCA4"
android:thumbTint="#FF27CCA4" /> android:thumbTint="#FF27CCA4"
android:enabled="false" />
<TextView <TextView
android:id="@+id/text_mic_volume_value" android:id="@+id/text_mic_volume_value"
@@ -136,8 +137,8 @@
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="4dp" android:layout_marginBottom="2dp"
android:text="麦克风效果" android:text="麦克风效果(功放/混音器硬件调节)"
android:textColor="#FFE0E0FF" android:textColor="#FFE0E0FF"
android:textSize="13sp" /> android:textSize="13sp" />
@@ -156,7 +157,8 @@
android:max="100" android:max="100"
android:progress="50" android:progress="50"
android:progressTint="#FF27CCA4" android:progressTint="#FF27CCA4"
android:thumbTint="#FF27CCA4" /> android:thumbTint="#FF27CCA4"
android:enabled="false" />
<TextView <TextView
android:id="@+id/text_mic_effect_value" android:id="@+id/text_mic_effect_value"