diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt index d3569c6..5d4316f 100644 --- a/app/src/main/java/com/local/ktv/MainActivity.kt +++ b/app/src/main/java/com/local/ktv/MainActivity.kt @@ -251,6 +251,8 @@ class MainActivity : AppCompatActivity() { private var queueAdapter: ArrayAdapter? = 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(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" } diff --git a/app/src/main/java/com/local/ktv/MicActivator.kt b/app/src/main/java/com/local/ktv/MicActivator.kt new file mode 100644 index 0000000..aa12349 --- /dev/null +++ b/app/src/main/java/com/local/ktv/MicActivator.kt @@ -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() } + } +}