麦克风: 启动请求RECORD_AUDIO权限并常驻占用麦克风输入(TV/投影需激活后才输出演唱声)

- 新增 MicActivator: AudioRecord 低开销常驻打开 MIC 并循环读丢弃, 保持输入通道占用
- MainActivity: onCreate 延迟请求麦克风权限(REQUEST_MIC_PERMISSION=12), 授权后激活
- 与 MediaRecorder 录音互斥: startRecording 前 pause, stopRecording 后 resume
- onDestroy 释放
This commit is contained in:
zijunchiang
2026-09-06 00:39:53 +08:00
parent 9dc0d4b994
commit 197b01f806
2 changed files with 159 additions and 1 deletions
@@ -251,6 +251,8 @@ class MainActivity : AppCompatActivity() {
private var queueAdapter: ArrayAdapter<String?>? = null
private var audioManager: AudioManager? = null
private var recorder: MediaRecorder? = null
/** 电视/投影等设备需 App 占用麦克风输入后才会启用麦克风声音输出(供演唱)。 */
private val micActivator = MicActivator()
private var currentRecording: File? = null
private var currentPage = "首页"
@@ -497,6 +499,20 @@ class MainActivity : AppCompatActivity() {
main.postDelayed(ktvHeartbeatRunnable, HEARTBEAT_INTERVAL_MS)
main.post(lyricTicker)
main.postDelayed({ checkAppUpdate(false) }, 2500L)
// 电视/投影等设备: 请求麦克风权限并保持输入占用, 否则麦克风声音不会输出到扬声器。
main.postDelayed({ requestMicActivation() }, 4000L)
}
/** 申请并激活麦克风输入(演唱用)。授权后由 [MicActivator] 常驻占用输入通道。 */
private fun requestMicActivation() {
if (Build.VERSION.SDK_INT >= 23) {
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_MIC_PERMISSION)
return
}
}
micActivator.start()
Log.i(TAG, "麦克风输入已激活(演唱输出通道占用)")
}
private fun initializeDatabase() {
@@ -587,6 +603,7 @@ class MainActivity : AppCompatActivity() {
main.removeCallbacks(remoteNetworkMonitor)
main.removeCallbacks(qrAutoHideMonitor)
stopRecording(false)
micActivator.stop()
releaseVocalPlayer()
if (::playbackEngine.isInitialized) playbackEngine.release()
remoteServer.stop()
@@ -3959,6 +3976,8 @@ class MainActivity : AppCompatActivity() {
requestPermissions(arrayOf<String>(Manifest.permission.RECORD_AUDIO), 11)
return
}
// MediaRecorder 需要独占麦克风: 先暂停 MicActivator 占用。
micActivator.pause()
try {
val dir = recordDir()
if (!dir.exists()) dir.mkdirs()
@@ -3978,7 +3997,8 @@ class MainActivity : AppCompatActivity() {
status!!.setText("正在录音:" + currentRecording!!.getName())
if ("首页" == currentPage) showHome()
} catch (e: Exception) {
stopRecording(false)
// 录音启动失败: 释放 recorder 并恢复麦克风输入占用。
if (recorder != null) stopRecording(false) else micActivator.resume()
toast("录音启动失败:" + e.message)
}
}
@@ -3994,6 +4014,8 @@ class MainActivity : AppCompatActivity() {
} catch (ignored: Exception) {
}
recorder = null
// 录音结束 → 恢复麦克风输入占用(演唱声音输出通道)。
micActivator.resume()
if (notify && currentRecording != null) toast("录音已保存:" + currentRecording!!.getName())
currentRecording = null
if ("录音" == currentPage) showRecordings()
@@ -9648,6 +9670,14 @@ class MainActivity : AppCompatActivity() {
recreate()
}
if (requestCode == 11 && grantResults.size > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) startRecording()
if (requestCode == REQUEST_MIC_PERMISSION && grantResults.size > 0 &&
grantResults[0] == PackageManager.PERMISSION_GRANTED
) {
micActivator.start()
Log.i(TAG, "麦克风权限已授予, 麦克风输入激活")
} else if (requestCode == REQUEST_MIC_PERMISSION) {
Log.w(TAG, "麦克风权限被拒绝: 部分设备将无麦克风演唱声音输出")
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
@@ -10103,6 +10133,7 @@ class MainActivity : AppCompatActivity() {
private const val PREFS = "ktv"
private const val KEY_CATALOG_URL = "catalog_url"
private const val KEY_SHOW_MOBILE_QR = "show_mobile_qr"
private const val REQUEST_MIC_PERMISSION = 12
private const val TAG = "MainActivity"
}
@@ -0,0 +1,127 @@
package com.local.ktv
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Log
/**
* 麦克风输入激活器。
*
* 背景: 电视/投影盒子等设备, 需要某个应用真正打开(占用)麦克风输入通道后,
* 系统/音频 HAL 才会把外接麦克风的声音路由到扬声器(供 KTV 演唱)。仅持有
* RECORD_AUDIO 权限而不实际录音是不够的。
*
* 做法: 用一个低开销的 [AudioRecord] 常驻打开麦克风并循环读取丢弃数据,
* 保持输入通道占用(不写文件、不对外暴露声音)。与 MediaRecorder 录音互斥:
* 开始录音前先 pause, 结束录音后自动 resume。
*/
class MicActivator {
private val tag = "MicActivator"
private var record: AudioRecord? = null
private var thread: Thread? = null
@Volatile private var running = false
@Volatile private var paused = false
val isActive: Boolean get() = running && !paused
/** 打开麦克风并保持占用。失败静默(设备无麦克风时不影响 App 使用)。 */
@Synchronized
fun start() {
if (running) return
running = true
paused = false
thread = Thread({ readLoop() }, "mic-activator").also { it.isDaemon = true; it.start() }
}
/** 暂停占用(例如要使用 MediaRecorder 录音时), 保留重开能力。 */
@Synchronized
fun pause() {
if (!running) return
paused = true
releaseInternal()
thread?.interrupt()
thread = null
}
/** 恢复占用。 */
@Synchronized
fun resume() {
if (!running) return
if (!paused) return
paused = false
thread = Thread({ readLoop() }, "mic-activator").also { it.isDaemon = true; it.start() }
}
/** 彻底停止(不再自动恢复)。 */
@Synchronized
fun stop() {
running = false
paused = false
releaseInternal()
thread?.interrupt()
thread = null
}
private fun readLoop() {
var consecutiveFails = 0
while (running && !paused) {
var recorder: AudioRecord? = null
try {
val minBuffer = AudioRecord.getMinBufferSize(
16000,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
if (minBuffer <= 0) {
// 无可用输入配置(可能无麦克风) → 稍后重试。
sleepQuietly(5000)
continue
}
recorder = AudioRecord(
MediaRecorder.AudioSource.MIC,
16000,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
minBuffer.coerceAtLeast(4096) * 2,
)
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
sleepQuietly(5000)
continue
}
record = recorder
recorder.startRecording()
val buffer = ByteArray(minBuffer.coerceAtLeast(4096))
consecutiveFails = 0
while (running && !paused) {
// 丢弃读到的数据, 目的仅是让麦克风输入通道保持打开。
val read = recorder.read(buffer, 0, buffer.size)
if (read < 0) {
if (++consecutiveFails > 5) break
sleepQuietly(200)
} else {
consecutiveFails = 0
}
}
} catch (e: Throwable) {
Log.w(tag, "mic keep-alive error: ${e.message}")
sleepQuietly(5000)
} finally {
runCatching { recorder?.stop() }
runCatching { recorder?.release() }
if (record === recorder) record = null
}
}
}
private fun releaseInternal() {
val r = record
record = null
runCatching { r?.stop() }
runCatching { r?.release() }
}
private fun sleepQuietly(ms: Long) {
try { Thread.sleep(ms) } catch (ignored: InterruptedException) { Thread.currentThread().interrupt() }
}
}