feat(audio,#2): MediaProjection + AudioPlaybackCapture preview on glasses; AudioPath /vm-audio to phone; debug test tone; debug audio pipe; FGS mediaProjection type; AudioOutputSection UI toggle

This commit is contained in:
2026-09-27 06:39:00 +03:00
parent 2125788e25
commit b1efa3d462
16 changed files with 872 additions and 229 deletions
@@ -64,6 +64,7 @@
<action android:name="pw.binom.rayneovm.phone.DEBUG_SET_DEEP_SUSPEND" />
<action android:name="pw.binom.rayneovm.phone.DEBUG_ADB_SHELL" />
<action android:name="pw.binom.rayneovm.phone.DEBUG_REFRESH_STATUS" />
<action android:name="pw.binom.rayneovm.phone.DEBUG_SET_AUDIO" />
</intent-filter>
</receiver>
</application>
@@ -76,15 +76,24 @@ class AudioPlaybackManager(private val context: Context) {
transport = Transports.wifiBytes(wsUrl, autoReconnect = true)
streamJob = scope.launch {
var totalBytes = 0L
var frames = 0
try {
transport!!.connect(
onMessage = { pcm ->
val t = track ?: return@connect
t.write(pcm, 0, pcm.size)
totalBytes += pcm.size
frames++
if (frames % 40 == 0) {
Log.i(TAG, "playing: bytes=$totalBytes rms=${rms16(pcm).toInt()}")
}
},
onConnected = { Log.i(TAG, "audio connected to $wsUrl") },
onDisconnected = { Log.w(TAG, "audio disconnected") },
)
} catch (t: kotlinx.coroutines.CancellationException) {
// Штатная остановка (stop()/смена режима) — не ошибка.
} catch (t: Throwable) {
Log.e(TAG, "audio transport failed", t)
}
@@ -100,9 +109,23 @@ class AudioPlaybackManager(private val context: Context) {
Log.i(TAG, "AudioPlaybackManager stopped")
}
private fun rms16(buf: ByteArray): Double {
if (buf.size < 2) return 0.0
var sumSq = 0.0
var i = 0
while (i + 1 < buf.size) {
val s = ((buf[i].toInt() and 0xFF) or (buf[i + 1].toInt() shl 8)).toShort().toInt()
sumSq += s.toDouble() * s
i += 2
}
return Math.sqrt(sumSq / (buf.size / 2))
}
companion object {
private const val TAG = "AudioPlaybackMgr"
const val SAMPLE_RATE = 44100
const val GLASSES_PORT = 8080
/** Совпадает с [pw.binom.rayneovm.glasses.VmService.AUDIO_PORT]. */
const val GLASSES_PORT = 8081
}
}
@@ -55,6 +55,13 @@ class ShizukuProvisioner(
@Volatile
private var provisionedInSession = false
/**
* Разовые системные разрешения на очках (RECORD_AUDIO, appop PROJECT_MEDIA) —
* выдаём один раз за сессию. Без них AudioPlaybackCapture (задача #2) не поднимется.
*/
@Volatile
private var grantsAppliedInSession = false
/**
* Проверить Shizuku и, если не запущен, поднять его. Безопасно вызывать часто:
* дешёвая проверка через WiFi-статус, полный adb-цикл — максимум один раз за BLE-сессию.
@@ -65,6 +72,7 @@ class ShizukuProvisioner(
"ensure(): вызов, состояние=${_state.value}, shizuku=${controller.glassesShizuku.value}, " +
"provisioned=$provisionedInSession",
)
maybeApplyGrants()
scope.launch {
val shizuku = controller.glassesShizuku.value
// Shizuku точно работает — ничего не делаем.
@@ -92,9 +100,26 @@ class ShizukuProvisioner(
}
}
/**
* Разовые разрешения на очках. Идемпотентно, один раз за сессию. Не блокирует ensure():
* `adb.ensureConnected()` переиспользует соединение, поднятое бутстрапом Shizuku.
*/
private fun maybeApplyGrants() {
if (grantsAppliedInSession) return
scope.launch {
val serial = adb.ensureConnected() ?: return@launch
Log.i(TAG, "применяю системные гранты на очках ($serial)")
val grant = adb.shell("pm grant $GLASSES_PACKAGE android.permission.RECORD_AUDIO", timeoutMs = 15_000)
val appop = adb.shell("cmd appops set $GLASSES_PACKAGE PROJECT_MEDIA allow", timeoutMs = 15_000)
Log.i(TAG, "гранты: RECORD_AUDIO=${grant.isSuccess}, PROJECT_MEDIA=${appop.isSuccess}")
if (grant.isSuccess && appop.isSuccess) grantsAppliedInSession = true
}
}
/** BLE-сессия разорвана: при следующем подключении проверяем Shizuku заново. */
fun onBleDisconnected() {
provisionedInSession = false
grantsAppliedInSession = false
// wireless-adb очки гасят сами при потере сети — соединение считать мёртвым.
adb.invalidate()
}
@@ -11,6 +11,8 @@ import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.AdbControllerProvider
import pw.binom.rayneovm.phone.ShizukuProvisionerProvider
import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider
import pw.binom.rayneovm.phone.protocol.ControllerProvider
import pw.binom.rayneovm.shared.AudioMode
/**
* Диагностический приёмник (shell/system, `android.permission.DUMP`):
@@ -63,6 +65,13 @@ class DebugReceiver : BroadcastReceiver() {
}
}
ACTION_SET_AUDIO -> {
val controller = (app as? ControllerProvider)?.provideController() ?: return
val mode = if (intent.getIntExtra("mode", 0) != 0) AudioMode.ON_PHONE else AudioMode.ON_GLASSES
Log.i(TAG, "DEBUG_SET_AUDIO mode=$mode")
scope.launch { controller.setAudioMode(mode) }
}
else -> {
val provider = app as? ShizukuProvisionerProvider ?: return
Log.i(TAG, "получен ${intent.action} — запускаю бутстрап Shizuku")
@@ -82,5 +91,6 @@ class DebugReceiver : BroadcastReceiver() {
const val ACTION_SET_DEEP_SUSPEND = "pw.binom.rayneovm.phone.DEBUG_SET_DEEP_SUSPEND"
const val ACTION_REFRESH_STATUS = "pw.binom.rayneovm.phone.DEBUG_REFRESH_STATUS"
const val ACTION_ADB_SHELL = "pw.binom.rayneovm.phone.DEBUG_ADB_SHELL"
const val ACTION_SET_AUDIO = "pw.binom.rayneovm.phone.DEBUG_SET_AUDIO"
}
}
@@ -57,6 +57,10 @@ class VmPhoneController(
private var currentTransport: StringTransport? = null
private val audioManager by lazy { AudioPlaybackManager(context) }
/** Текущий аудио-режим (зеркалит то, что отправлено очкам) — для UI-переключателя. */
private val _audioMode = MutableStateFlow(pw.binom.rayneovm.shared.AudioMode.ON_GLASSES)
val audioMode: StateFlow<pw.binom.rayneovm.shared.AudioMode> = _audioMode.asStateFlow()
fun start() {
if (scope.coroutineContext[Job]?.children?.any { it.isActive } == true) return
scope.launch { reconnectLoop() }
@@ -106,10 +110,18 @@ class VmPhoneController(
// Очки сами присылают ShizukuStatus при подключении; на всякий
// случай дублируем запросом — первый ответ легко теряется.
scope.launch { send(RequestShizukuStatus) }
// Канал поднялся: если выбран вывод звука на телефон — восстанавливаем
// его (после реконнекта очков захват на той стороне уже остановлен).
if (_audioMode.value == pw.binom.rayneovm.shared.AudioMode.ON_PHONE) {
scope.launch { send(SetAudioMode(pw.binom.rayneovm.shared.AudioMode.ON_PHONE)) }
audioManager.start(service.host)
}
},
onDisconnected = {
currentTransport = null
_glassesShizuku.value = null
// Очки отвалились — аудио-поток всё равно мёртв, глушим трек.
audioManager.stop()
_state.value = ConnectionState.Disconnected
},
)
@@ -218,13 +230,19 @@ class VmPhoneController(
* Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager.
*/
suspend fun setAudioMode(mode: pw.binom.rayneovm.shared.AudioMode) {
_audioMode.value = mode
send(SetAudioMode(mode))
when (mode) {
pw.binom.rayneovm.shared.AudioMode.ON_GLASSES -> audioManager.stop()
pw.binom.rayneovm.shared.AudioMode.ON_PHONE -> {
val host = (currentTransport as? pw.binom.mercury.transport.WifiTransport)?.let { null }
?: (_state.value as? ConnectionState.Connected)?.host
if (host != null) audioManager.start(host)
val host = (_state.value as? ConnectionState.Connected)?.host
if (host != null) {
audioManager.start(host)
} else {
// Не подключены — команда уйдёт при следующем подключении не сразу,
// поэтому честно сообщаем в лог: аудио не поднимется.
android.util.Log.w("VmPhoneController", "ON_PHONE, но канал не подключён — аудио не стартует")
}
}
}
}
@@ -50,6 +50,7 @@ import pw.binom.rayneovm.phone.protocol.ConnectionState
import pw.binom.rayneovm.phone.protocol.VmPhoneController
import pw.binom.rayneovm.phone.bootstrap.ProvisionState
import pw.binom.rayneovm.phone.bootstrap.ShizukuProvisioner
import pw.binom.rayneovm.shared.AudioMode
import pw.binom.rayneovm.shared.ShizukuStatus
/**
@@ -81,6 +82,7 @@ fun GlassesStatusScreen(
val wifiScanning by mercuryController.wifiScanning.collectAsState()
val adbAddress by mercuryController.wirelessAdbAddress.collectAsState()
val adbEnabling by mercuryController.wirelessAdbEnabling.collectAsState()
val audioMode by controller.audioMode.collectAsState()
val scope = rememberCoroutineScope()
val mercuryConnected = mercury is MercuryState.Connected
@@ -144,6 +146,12 @@ fun GlassesStatusScreen(
onSetScreen = { sleep -> controller.setScreen(sleep) },
)
AudioOutputSection(
mode = audioMode,
enabled = connection is ConnectionState.Connected,
onChange = { mode -> controller.setAudioMode(mode) },
)
GlassesWifiSection(
status = generalStatus,
networks = wifiNetworks,
@@ -374,6 +382,44 @@ private fun ScreenPowerSection(
}
}
@Composable
private fun AudioOutputSection(
mode: AudioMode,
enabled: Boolean,
onChange: suspend (AudioMode) -> Unit,
) {
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
val onPhone = mode == AudioMode.ON_PHONE
SectionCard(title = "Звук") {
Hint("Куда выводить звук приложений с виртуального экрана очков.")
ToggleRow(
label = if (onPhone) "Звук на телефоне" else "Звук на очках",
checked = onPhone,
onCheckedChange = { phone ->
if (!busy && enabled) {
busy = true
scope.launch {
try {
onChange(if (phone) AudioMode.ON_PHONE else AudioMode.ON_GLASSES)
} finally {
busy = false
}
}
}
},
)
when {
!enabled -> Hint("Нужен Канал 1 (WebSocket) — приложение на очках не подключено.")
onPhone -> Hint(
"Динамик очков глушится, звук идёт на телефон. При первом включении " +
"система может на секунду запросить разрешение на захват."
)
}
}
}
@Composable
private fun GlassesWifiSection(
status: GeneralStatus?,