Verify shared Android audio ownership on a physical device
This commit is contained in:
1 parent
44a3b33c3a
commit
2b63c680b2
3 files changed
+139
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<instrumentation android:name="com.utun.chat.data.CallVideoProbe"
|
||||
android:targetPackage="com.utun.chat" android:label="Call video codec probe" />
|
||||
<instrumentation android:name="com.utun.chat.data.MediaAudioProbe"
|
||||
android:targetPackage="com.utun.chat" android:label="Shared media audio probe" />
|
||||
<application>
|
||||
<activity android:name="com.utun.chat.data.MediaAudioProbeActivity" android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user