麦克风: 启动请求RECORD_AUDIO权限并常驻占用麦克风输入(TV/投影需激活后才输出演唱声)
- 新增 MicActivator: AudioRecord 低开销常驻打开 MIC 并循环读丢弃, 保持输入通道占用 - MainActivity: onCreate 延迟请求麦克风权限(REQUEST_MIC_PERMISSION=12), 授权后激活 - 与 MediaRecorder 录音互斥: startRecording 前 pause, stopRecording 后 resume - onDestroy 释放
This commit is contained in:
@@ -251,6 +251,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private var queueAdapter: ArrayAdapter<String?>? = null
|
private var queueAdapter: ArrayAdapter<String?>? = null
|
||||||
private var audioManager: AudioManager? = null
|
private var audioManager: AudioManager? = null
|
||||||
private var recorder: MediaRecorder? = null
|
private var recorder: MediaRecorder? = null
|
||||||
|
/** 电视/投影等设备需 App 占用麦克风输入后才会启用麦克风声音输出(供演唱)。 */
|
||||||
|
private val micActivator = MicActivator()
|
||||||
private var currentRecording: File? = null
|
private var currentRecording: File? = null
|
||||||
private var currentPage = "首页"
|
private var currentPage = "首页"
|
||||||
|
|
||||||
@@ -497,6 +499,20 @@ class MainActivity : AppCompatActivity() {
|
|||||||
main.postDelayed(ktvHeartbeatRunnable, HEARTBEAT_INTERVAL_MS)
|
main.postDelayed(ktvHeartbeatRunnable, HEARTBEAT_INTERVAL_MS)
|
||||||
main.post(lyricTicker)
|
main.post(lyricTicker)
|
||||||
main.postDelayed({ checkAppUpdate(false) }, 2500L)
|
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() {
|
private fun initializeDatabase() {
|
||||||
@@ -587,6 +603,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
main.removeCallbacks(remoteNetworkMonitor)
|
main.removeCallbacks(remoteNetworkMonitor)
|
||||||
main.removeCallbacks(qrAutoHideMonitor)
|
main.removeCallbacks(qrAutoHideMonitor)
|
||||||
stopRecording(false)
|
stopRecording(false)
|
||||||
|
micActivator.stop()
|
||||||
releaseVocalPlayer()
|
releaseVocalPlayer()
|
||||||
if (::playbackEngine.isInitialized) playbackEngine.release()
|
if (::playbackEngine.isInitialized) playbackEngine.release()
|
||||||
remoteServer.stop()
|
remoteServer.stop()
|
||||||
@@ -3959,6 +3976,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
requestPermissions(arrayOf<String>(Manifest.permission.RECORD_AUDIO), 11)
|
requestPermissions(arrayOf<String>(Manifest.permission.RECORD_AUDIO), 11)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// MediaRecorder 需要独占麦克风: 先暂停 MicActivator 占用。
|
||||||
|
micActivator.pause()
|
||||||
try {
|
try {
|
||||||
val dir = recordDir()
|
val dir = recordDir()
|
||||||
if (!dir.exists()) dir.mkdirs()
|
if (!dir.exists()) dir.mkdirs()
|
||||||
@@ -3978,7 +3997,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
status!!.setText("正在录音:" + currentRecording!!.getName())
|
status!!.setText("正在录音:" + currentRecording!!.getName())
|
||||||
if ("首页" == currentPage) showHome()
|
if ("首页" == currentPage) showHome()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
stopRecording(false)
|
// 录音启动失败: 释放 recorder 并恢复麦克风输入占用。
|
||||||
|
if (recorder != null) stopRecording(false) else micActivator.resume()
|
||||||
toast("录音启动失败:" + e.message)
|
toast("录音启动失败:" + e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3994,6 +4014,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
} catch (ignored: Exception) {
|
} catch (ignored: Exception) {
|
||||||
}
|
}
|
||||||
recorder = null
|
recorder = null
|
||||||
|
// 录音结束 → 恢复麦克风输入占用(演唱声音输出通道)。
|
||||||
|
micActivator.resume()
|
||||||
if (notify && currentRecording != null) toast("录音已保存:" + currentRecording!!.getName())
|
if (notify && currentRecording != null) toast("录音已保存:" + currentRecording!!.getName())
|
||||||
currentRecording = null
|
currentRecording = null
|
||||||
if ("录音" == currentPage) showRecordings()
|
if ("录音" == currentPage) showRecordings()
|
||||||
@@ -9648,6 +9670,14 @@ class MainActivity : AppCompatActivity() {
|
|||||||
recreate()
|
recreate()
|
||||||
}
|
}
|
||||||
if (requestCode == 11 && grantResults.size > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) startRecording()
|
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 {
|
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||||
@@ -10103,6 +10133,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private const val PREFS = "ktv"
|
private const val PREFS = "ktv"
|
||||||
private const val KEY_CATALOG_URL = "catalog_url"
|
private const val KEY_CATALOG_URL = "catalog_url"
|
||||||
private const val KEY_SHOW_MOBILE_QR = "show_mobile_qr"
|
private const val KEY_SHOW_MOBILE_QR = "show_mobile_qr"
|
||||||
|
private const val REQUEST_MIC_PERMISSION = 12
|
||||||
|
|
||||||
private const val TAG = "MainActivity"
|
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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user