diff --git a/doc/service_lifecycle.md b/doc/service_lifecycle.md index 44e0947d..1c3f1b23 100644 --- a/doc/service_lifecycle.md +++ b/doc/service_lifecycle.md @@ -225,10 +225,16 @@ Microphone lease публикации исключает call/recorder; раци Если первой работала рация, публикация заменяет профиль захвата своим; после остановки публикации оставшаяся рация продолжает тот же захват. Recovery останавливает callbacks, workers TX и один раз открывает общий вход; RX рации сохраняется. Каждый потребитель обрабатывает собственную копию PCM. -На Android `MicrophoneCapture` владеет AudioRecord и reader-потоком, подписчики — ограниченными очередями. -Stop публикации удаляет её подписку до join Opus worker и FIN; AudioRecord остаётся живым для рации. -PTT и PCM рации проходят через одну очередь. Последний подписчик делает stop/read join/release; -приоритет глобального AudioManager сохраняется до освобождения трансляционного захвата. +На Android `MediaAudioController` владеет общим выбором устройства, AudioManager и одним аппаратным reader. +Маршрутизация использует `CallAudioRouter`; AudioTrack рации и микшера просмотра регистрируются у одного владельца. +`MicrophoneCapture` раздаёт независимые ограниченные очереди постоянного PCM 48k stereo; моно апмиксуется. +После подключения публикации встроенный микрофон использует CAMCORDER, сохраняемый до последней подписки. +Смена профиля заменяет только аппаратный AudioRecord: камера, видеокодер и source_epoch продолжают работать. +Общая сетка по capture timestamps имеет запас 200ms; пропуски заполняются тишиной, старый PCM не переносится вперёд. +Stop публикации удаляет её подписку до join Opus worker и FIN, сохраняя захват для оставшейся рации. +PTT и PCM рации проходят через одну очередь. Последняя подписка завершает общий reader; +контроллер закрывает аппаратный вход, сохраняя выходы. Перед Telecom все владельцы снимаются, +а `MediaAudioController.awaitIdle()` дожидается освобождения входа и глобального маршрута. Ограничения фонового захвата рации, её разрешения и foreground-сервис продолжают действовать. Fatal ошибка audio worker передаётся в GUI с session ID и завершает всю публикацию; откат видеоконфигурации применяется только к video/capture. Поздний отказ старого publisher diff --git a/tools/chatgui-android/app/src/debug/AndroidManifest.xml b/tools/chatgui-android/app/src/debug/AndroidManifest.xml index 32a234df..153bf663 100644 --- a/tools/chatgui-android/app/src/debug/AndroidManifest.xml +++ b/tools/chatgui-android/app/src/debug/AndroidManifest.xml @@ -1,4 +1,9 @@ + + + + diff --git a/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/MediaAudioProbe.kt b/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/MediaAudioProbe.kt new file mode 100644 index 00000000..c7c0264d --- /dev/null +++ b/tools/chatgui-android/app/src/debug/java/com/utun/chat/data/MediaAudioProbe.kt @@ -0,0 +1,124 @@ +package com.utun.chat.data + +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack +import android.os.Bundle +import android.widget.TextView + +/** Явная аппаратная проверка через adb am instrument. PCM остаётся в памяти; сеть и камера не используются. */ +class MediaAudioProbeActivity : Activity() { + override fun onCreate(state: Bundle?) { + super.onCreate(state) + if (android.os.Build.VERSION.SDK_INT >= 27) { setShowWhenLocked(true); setTurnScreenOn(true) } + else window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON) + setContentView(TextView(this).apply { text = "Shared media audio test" }) + } +} + +class MediaAudioProbe : Instrumentation() { + override fun onCreate(arguments: Bundle?) { super.onCreate(arguments); start() } + override fun onStart() { + var activity: Activity? = null + var radio: MicrophoneCapture.Subscription? = null + var stream: MicrophoneCapture.Subscription? = null + var track: AudioTrack? = null + var output: AutoCloseable? = null + var writer: Thread? = null + val writing = java.util.concurrent.atomic.AtomicBoolean(true) + var saved: MediaAudioProfile? = null + var problem: Exception? = null + try { + activity = startActivitySync(Intent(targetContext, MediaAudioProbeActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + check(MicrophonePermission.granted()) { "RECORD_AUDIO is required for the explicit microphone probe" } + MediaAudioController.awaitIdle() + saved = MediaAudioController.state.value.preferred + MediaAudioController.select(MediaAudioProfile.PHONE) + MediaAudioController.awaitIdle() + val sink = AudioTrack.Builder().setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).build()) + .setAudioFormat(AudioFormat.Builder().setSampleRate(48000).setChannelMask(AudioFormat.CHANNEL_OUT_STEREO) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT).build()) + .setBufferSizeInBytes(15360).setTransferMode(AudioTrack.MODE_STREAM).build() + track = sink + output = MediaAudioController.attachOutput(sink) + sink.play() + writer = Thread({ + val silence = ShortArray(960) + while (writing.get()) { + if (sink.write(silence, 0, silence.size) < 0) break + Thread.sleep(10) + } + }, "media-probe-output").apply { start() } + val first = MediaAudioController.subscribe("radio"); radio = first + repeat(15) { frame(first) } + val second = MediaAudioController.subscribe("stream"); stream = second + var previous = -1L + for (profile in listOf(MediaAudioProfile.PHONE, MediaAudioProfile.AUTO, MediaAudioProfile.PHONE)) { + MediaAudioController.select(profile) + val until = System.nanoTime() + 10_000_000_000L + var count = 0 + do { + val a = frame(second) + if (previous >= 0) check(a.timeNs == previous + 20_000_000L) { "Non-contiguous stream PTS $previous -> ${a.timeNs}" } + previous = a.timeNs + var b = frame(first) + while (b.sequence < a.sequence) b = frame(first) + check(a.sequence == b.sequence && a.timeNs == b.timeNs && a.pcm.contentEquals(b.pcm)) { "Consumers received different capture" } + val unchanged = b.pcm.copyOf() + a.pcm.fill(123) + check(b.pcm.contentEquals(unchanged)) { "PCM copies are shared" } + check(System.nanoTime() < until) { "Profile did not settle: ${MediaAudioController.state.value}" } + count++ + } while (count < 30 || MediaAudioController.state.value.switching) + check(MediaAudioController.state.value.notice == null && MediaAudioController.state.value.inputName != null) { + "Microphone was not confirmed: ${MediaAudioController.state.value}" + } + android.util.Log.i("MediaAudioProbe", "profile verified=$profile frames=$count state=${MediaAudioController.state.value}") + } + first.close(); radio = null + val next = frame(second) + check(next.timeNs == previous + 20_000_000L) { "Closing radio interrupted stream capture" } + val remaining = MediaAudioController.subscribe("radio"); radio = remaining + val beforeDetach = frame(remaining) + second.close(); stream = null + val afterDetach = frame(remaining) + check(afterDetach.timeNs == beforeDetach.timeNs + 20_000_000L && afterDetach.generation == beforeDetach.generation) { + "Closing stream restarted the remaining radio microphone" + } + remaining.close(); radio = null + // Оставшийся output продолжает принадлежать общей подсистеме после закрытия последнего input. + Thread.sleep(250) + check(sink.playState == AudioTrack.PLAYSTATE_PLAYING) { "Closing input stopped shared output" } + } catch (e: Exception) { + problem = e + android.util.Log.e("MediaAudioProbe", "probe failed", e) + } finally { + try { + radio?.close(); stream?.close() + writing.set(false) + track?.stop(); writer?.join() + output?.close(); track?.release() + MediaAudioController.awaitIdle() + saved?.let { MediaAudioController.select(it); MediaAudioController.awaitIdle() } + runOnMainSync { activity?.finish() } + } catch (e: Exception) { + android.util.Log.e("MediaAudioProbe", "cleanup failed", e) + if (problem == null) problem = e + } + } + finish(if (problem == null) 0 else 1, Bundle().apply { + putString("result", problem?.toString() ?: "Shared PCM, CAMCORDER, output route and continuous PTS verified") + }) + } + + private fun frame(subscription: MicrophoneCapture.Subscription): MicrophoneCapture.Frame { + val deadline = System.nanoTime() + 3_000_000_000L + while (System.nanoTime() < deadline) (subscription.poll() as? MicrophoneCapture.Frame)?.let { return it } + error("Shared microphone frame timeout") + } +}