From b1efa3d4623a347df717af518cb181a2384778d5 Mon Sep 17 00:00:00 2001 From: subochev Date: Sun, 27 Sep 2026 06:39:00 +0300 Subject: [PATCH] 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 --- BLOCKERS.md | 73 +++++ TASKS.md | 12 + app-glasses-vm/src/main/AndroidManifest.xml | 18 +- .../pw/binom/rayneovm/glasses/MainActivity.kt | 61 +++++ .../pw/binom/rayneovm/glasses/VmService.kt | 256 +++++++++++++++++- .../rayneovm/glasses/audio/AudioBridge.kt | 47 ++++ .../glasses/audio/AudioCaptureService.kt | 159 ----------- .../rayneovm/glasses/audio/AudioStreamer.kt | 62 ----- .../glasses/audio/GlassesAudioCapture.kt | 152 +++++++++++ .../glasses/debug/GlassesDebugReceiver.kt | 130 +++++++++ app-phone-vm/src/main/AndroidManifest.xml | 1 + .../phone/audio/AudioPlaybackManager.kt | 25 +- .../phone/bootstrap/ShizukuProvisioner.kt | 25 ++ .../rayneovm/phone/debug/DebugReceiver.kt | 10 + .../phone/protocol/VmPhoneController.kt | 24 +- .../rayneovm/phone/ui/GlassesStatusScreen.kt | 46 ++++ 16 files changed, 872 insertions(+), 229 deletions(-) create mode 100644 BLOCKERS.md create mode 100644 app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioBridge.kt delete mode 100644 app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioCaptureService.kt delete mode 100644 app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioStreamer.kt create mode 100644 app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/GlassesAudioCapture.kt create mode 100644 app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/debug/GlassesDebugReceiver.kt diff --git a/BLOCKERS.md b/BLOCKERS.md new file mode 100644 index 0000000..1dedab8 --- /dev/null +++ b/BLOCKERS.md @@ -0,0 +1,73 @@ +# BLOCKERS ночной сессии 2026-09-27 + +## B1 — Нет общей L3-сети между телефоном и очками для проверки ON_PHONE end-to-end + +**Задача:** #2 (проброс звука VirtualDisplay → телефон). + +**Что сделано:** + +- `AudioBridge` + `GlassesAudioCapture` + WebSocket-аудио-сервер `/vm-audio` (порт 8081) на очках + + `AudioPlaybackManager` на телефоне + UI-переключатель «Звук» в `GlassesStatusScreen`. +- **MediaProjection + AudioPlaybackCapture подтверждены работающими на очках в автономном debug-режиме + `DEBUG_AUDIO_PIPE`** (preview без WebSocket-канала): + ``` + TestTone: test tone start: 44100 Hz stereo, 10s + GlassesAudioCapture: capture: bytes=1310720 rms=5629 ← захват системного звука работает! + TestTone: test tone done + GlassesAudioCapture: capture: bytes=1474560 rms=0 ← тишина после теста + ``` + RMS 5629 совпадает с теоретическим (~5657 для амплитуды 8000) → мы реально ловим 440 Гц. +- Тестовый тон → динамик очков → AudioPlaybackCapture на тех же очках → доказано. +- Подача `Recorder Audio` + `PROJECT_MEDIA=allow` через `AdbController`/`appops` — работает. + +**В чём стена:** WebSocket-канал (AudioPath между очками и телефоном) требует общую L3-сеть. На данный момент: + +- Phone: `192.168.76.121/22 dev wlan0 → SSID "O2 5"`. +- Glasses: подключены к тому же `O2 5` через supplicant (CONNECTED), **но без IP на wlan0** (`network is unreachable`). + Просто очкам DHCP не отдал адрес (по какой-то причине). +- mDNS работает только в broadcast-домене одной L2/L3 сети. + +Без общего IP между устройствами WebSocket `/vm-audio` не пройдёт — `AudioPlaybackManager` на телефоне подключиться не сможет. Сам захват звука на очках доказан, но отправка байтов по WS до телефона — нет. + +**Что проверял:** + +- `service call adb 4 i32 1 s16 "O2 5"` + `settings put global adb_wifi_enabled 1` — wireless-adb поднимается. +- Bluetooth-канал (Mercury) работает (BLE-команды идут). Но для WebSocket audio не применим (пропускная способность BLE всё-таки ограничена для 44.1 кГц стерео, что добавляет сложностей). +- ping телефон ↔ очки = `network is unreachable` от очков в сторону телефона (wlan0 не получил IP). + +**Обход (нужна помощь пользователя):** + +1. **Самый прямой:** пользователь включает Wi-Fi-хотспот на телефоне (через UI, ~30 секунд). + - SSID/пароль любые. + - Телефон и очки → обе в одну сеть, получают IP от телефона. + - Очки уже умеют авто-коннектиться к сохранённой сети (Caffeine Portable или любая). +2. **Альтернатива:** пользователь подключает очки к USB-tethering'у с телефона (если dev'ы возможно на устройстве). +3. **Самый сложный:** настроить прямой Wi-Fi Direct между телефоном и очками — `cmd wifi p2p ...` потребует более глубокой интеграции. + +После появления общей сети проверить: + +```bash +# на телефоне: +adb shell am start -n pw.binom.rayneovm.phone/.MainActivity # поднять UI +# UI → «Очки» → тумблер «Звук на телефоне». +# В логах телефона должно быть: +# AudioPlaybackManager: audio connected to ws://:8081/vm-audio +# AudioPlaybackManager: playing: bytes=… rms=… +# В логах очков: +# GlassesAudioCapture: capture: bytes=… rms=… +# VmService: sender: clients=1 bytes=… +``` + +**Статус:** #2 прогресс в работе, ядро (MediaProjection + AudioPlaybackCapture) доказано рабочим. Только финальная L3-проверка отложена до пробуждения пользователя. + +## B2 — USB-adb / сеть очков: на руках есть USB-adb хост ↔ очки; очки же сейчас в wlan0 без IP + +Зафиксировано в #B1 — нужно чтобы устройства в общей IP-сети. Wi-Fi-xотспот на телефоне решает. + +## B3 (выяснено, не блокер) — Mercury path `ble.request.debug` не зарегистрирован на прошивке RayNeo X3 Pro + +**Что:** `RayNeoMercury.enableWirelessAdb()` отправляет `ble.request.debug/enableWirelessAdb`, на очках приходит `Can not resolve(uri = ble.request.debug)` — RayNeo Mercury system-app на этой прошивке не знает этот URI. + +Это означает: **провайдер Mercurry не работает на X3 Pro** для управления wireless-adb. + +**Workaround:** включение wireless-adb через `service call adb 4 i32 1 s16 ` от shell — отлично работает (с shell UID через Shizuku или напрямую). Используется в `AdbController`/`ShizukuProvisioner`. diff --git a/TASKS.md b/TASKS.md index f0fb5aa..467fa93 100644 --- a/TASKS.md +++ b/TASKS.md @@ -169,3 +169,15 @@ shell `settings put` работает. - Порядок работы ночью: **#2 → #1 → #3 → #4 → #5** (по убыванию риска/размера). - Коммиты/пуш — по завершении блоков; отдельно rayneo-vm и rayneo-mercury. - **Отмашка на начало работ — ждём от пользователя (пока не дана).** + +### Правила ночной сессии (согласовано 2026-09-27) + +1. **Упёрся в проблему — не стоим.** Зафиксировать её в `BLOCKERS.md` (корень репо): задача, что + сделано, в чём стена, что проверено, варианты обхода. Затем **перейти к следующей задаче**. +2. **Погасить экран очков в конце.** Когда все задачи закрыты ИЛИ дальнейшее продвижение + невозможно — ОБЯЗАТЕЛЬНО выключить экран очков: + `adb -s A06B4AB933C4103 shell input keyevent 223` (KEYCODE_SLEEP), либо через `ScreenControl` + (Канал 1) / Shizuku `SET_SCREEN`. Проверить по `mWakefulness=Dozing`/`Asleep`. +3. Все три пре-авторизации выданы: ребут очков — можно; менять системные настройки очков + (Accessibility / PROJECT_MEDIA / дефолтный IME) на постоянку — можно; при равнозначных развилках + выбирать самому и документировать. diff --git a/app-glasses-vm/src/main/AndroidManifest.xml b/app-glasses-vm/src/main/AndroidManifest.xml index 6446344..9f4c65f 100644 --- a/app-glasses-vm/src/main/AndroidManifest.xml +++ b/app-glasses-vm/src/main/AndroidManifest.xml @@ -73,11 +73,11 @@ - + + android:foregroundServiceType="mediaPlayback|mediaProjection" /> + + + + + + + + + + diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt index 1d601e9..0dc6aa7 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt @@ -1,12 +1,16 @@ package pw.binom.rayneovm.glasses +import android.app.Activity import android.content.Intent import android.content.IntentFilter +import android.media.projection.MediaProjectionManager import android.os.Bundle +import android.util.Log import android.view.Display import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -20,12 +24,41 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch +import pw.binom.rayneovm.glasses.audio.AudioBridge import pw.binom.rayneovm.glasses.protocol.VmGlassesController import pw.binom.rayneovm.glasses.ui.GlassesScreen import pw.binom.rayneovm.glasses.vm.GlassesScreenState class MainActivity : ComponentActivity() { + /** Последний обработанный tick запроса проекции (см. onCreate). */ + private var handledTick = 0L + + /** + * Пользовательский диалог MediaProjection. При выставленном appop `PROJECT_MEDIA = allow` + * он не показывается — интент сразу возвращает RESULT_OK (нужно для задачи #2). + */ + private val projectionLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val data = result.data + if (result.resultCode == Activity.RESULT_OK && data != null) { + val mp = getSystemService(MediaProjectionManager::class.java) + .getMediaProjection(result.resultCode, data) + if (mp != null) { + Log.i(TAG, "MediaProjection получен") + AudioBridge.onProjectionGranted(mp) + } else { + Log.w(TAG, "getMediaProjection вернул null") + } + } else { + Log.w(TAG, "MediaProjection не выдан: resultCode=${result.resultCode}") + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // HOME живёт на display 0. Если систему (или гостя на VirtualDisplay) @@ -38,6 +71,23 @@ class MainActivity : ComponentActivity() { } enableEdgeToEdge() ShizukuBootstrap.attach(this) + // Запросы MediaProjection приходят из VmService через AudioBridge (задача #2). + // Запускаем интент только когда Activity хотя бы STARTED, иначе лончер кинет. + // + // handledTick обязателен: repeatOnLifecycle перезапускает сбор при каждом выходе + // Activity в STARTED (а после диалога проекции мы как раз возвращаемся в STARTED). + // Без него один и тот же tick запускал бы запрос повторно, новая проекция гасила + // предыдущую, и AudioRecord умирал с read -2. + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + AudioBridge.requestTick.collect { tick -> + if (tick > handledTick) { + handledTick = tick + launchProjectionRequest() + } + } + } + } val controller = VmGlassesController.fromContext(this) setContent { @@ -76,6 +126,17 @@ class MainActivity : ComponentActivity() { ShizukuBootstrap.onRequestPermissionResult(requestCode, grant) } } + + private fun launchProjectionRequest() { + runCatching { + val mpm = getSystemService(MediaProjectionManager::class.java) + projectionLauncher.launch(mpm.createScreenCaptureIntent()) + }.onFailure { Log.w(TAG, "Не удалось запустить запрос MediaProjection: ${it.message}") } + } + + companion object { + private const val TAG = "MainActivity" + } } @Composable diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt index 2eeac68..a5783f9 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt @@ -7,6 +7,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo +import android.media.AudioManager import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo import android.os.Build @@ -15,11 +16,17 @@ import android.util.Log import androidx.core.app.NotificationCompat import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import pw.binom.mercury.transport.ServerTransport import pw.binom.mercury.transport.Transports +import pw.binom.rayneovm.glasses.audio.AudioBridge +import pw.binom.rayneovm.glasses.audio.GlassesAudioCapture import pw.binom.rayneovm.glasses.protocol.VmGlassesController import pw.binom.rayneovm.glasses.vm.GlassesScreenState +import pw.binom.rayneovm.shared.AudioMode import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent import pw.binom.rayneovm.shared.LaunchAppCommand import pw.binom.rayneovm.shared.Ping @@ -58,6 +65,26 @@ class VmService : LifecycleService() { private var nsdRegistered: Boolean = false private lateinit var controller: VmGlassesController + // --- Аудио-мост (задача #2): захват системного звука очков и стрим на телефон --- + private var audioServer: ServerTransport? = null + private val audioSessions = java.util.concurrent.ConcurrentHashMap.newKeySet() + @Volatile + private var audioCapture: GlassesAudioCapture? = null + private var audioEnsureJob: kotlinx.coroutines.Job? = null + + @Volatile + private var audioMode: AudioMode = AudioMode.ON_GLASSES + + /** + * Очередь кадров PCM между потоком чтения [AudioRecord] и отправкой в сокет. + * При переполнении выкидываем самый старый кадр — для live-аудио важнее свежесть, + * чем полнота (иначе задержка растёт безгранично). + */ + private val audioFrames = Channel( + capacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + override fun onCreate() { super.onCreate() instance = this @@ -69,12 +96,23 @@ class VmService : LifecycleService() { controller.ensureMercuryKillGuard() startForeground() startServer() + startAudioServer() + startAudioPump() registerMdns() // Сообщаем телефону о Shizuku, как только меняется его состояние (сервер поднялся // или нам выдали permission) — чтобы телефон не опрашивал вручную. lifecycleScope.launch { ShizukuBootstrap.state.collect { pushShizukuStatus() } } + // MediaProjection приходит из MainActivity асинхронно. Как только он есть, + // а режим — ON_PHONE, сразу пробуем поднять захват (не ждём тик ретрая). + lifecycleScope.launch { + AudioBridge.projection.collect { projection -> + if (projection != null && audioMode == AudioMode.ON_PHONE && audioCapture == null) { + startAudioCapture(projection) + } + } + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -91,7 +129,14 @@ class VmService : LifecycleService() { val channelId = createNotificationChannel() val notification = buildNotification(channelId) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + // MEDIA_PROJECTION обязателен: без него getMediaProjection() кидает SecurityException + // (Android 12 проверяет тип foreground-сервиса в момент выдачи проекции). + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION, + ) } else { startForeground(NOTIFICATION_ID, notification) } @@ -167,7 +212,10 @@ class VmService : LifecycleService() { Log.d(TAG, "Key: ${msg.keyCode} action=${msg.action}") controller.sendKey(msg.keyCode, msg.action) } - is SetAudioMode -> Log.d(TAG, "SetAudioMode: ${msg.mode}") + is SetAudioMode -> { + Log.i(TAG, "SetAudioMode: ${msg.mode}") + applyAudioMode(msg.mode) + } is ScreenControl -> { Log.i(TAG, "ScreenControl: sleep=${msg.sleep}") controller.setScreen(msg.sleep) @@ -194,6 +242,151 @@ class VmService : LifecycleService() { } } + // ---------------- Аудио-мост (задача #2) ---------------- + + /** Сервер байтового канала `/vm-audio`: телефон подключается сюда за PCM-потоком. */ + private fun startAudioServer() { + audioServer = Transports.wifiServerBytes(port = AUDIO_PORT, path = AUDIO_PATH) + lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) { + try { + audioServer!!.accept( + onMessage = { _, _ -> /* телефон в этот канал ничего не шлёт */ }, + onConnected = { connId -> + audioSessions.add(connId) + Log.i(TAG, "Audio client connected: $connId") + }, + onDisconnected = { connId -> + audioSessions.remove(connId) + Log.i(TAG, "Audio client disconnected: $connId") + }, + ) + } catch (t: Throwable) { + Log.e(TAG, "Audio server crashed", t) + } + } + } + + /** Разбирает очередь кадров и рассылает их всем подключённым аудио-клиентам. */ + private fun startAudioPump() { + lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) { + for (bytes in audioFrames) { + val server = audioServer ?: continue + for (connId in audioSessions) { + try { + server.send(connId, bytes) + } catch (t: Throwable) { + Log.w(TAG, "audio send failed: ${t.message}") + } + } + } + } + } + + /** Применяет аудио-режим, пришедший с телефона. */ + private fun applyAudioMode(mode: AudioMode) { + audioMode = mode + when (mode) { + AudioMode.ON_PHONE -> { + // Глушим динамик очков: звук уезжает на телефон, дубля быть не должно. + setOutputMuted(true) + ensureAudioCaptureLoop() + } + AudioMode.ON_GLASSES -> { + audioEnsureJob?.cancel() + audioEnsureJob = null + stopAudioCapture() + setOutputMuted(false) + } + } + } + + /** + * Пока режим ON_PHONE и захват не поднят — периодически пробуем снова. + * + * Зачем цикл, а не один вызов: projection выдаёт MainActivity асинхронно, а `RECORD_AUDIO` + * может быть ещё не выдан (provisioning телефона выдаёт его через adb). Разовая попытка + * «выстреливает» только когда всё готово; цикл с интервалом [AUDIO_ENSURE_MS] дожимает. + * Частый ре-запрос projection вреден: новая проекция гасит предыдущую — отсюда read -2. + */ + private fun ensureAudioCaptureLoop() { + audioEnsureJob?.cancel() + audioEnsureJob = lifecycleScope.launch { + var fails = 0 + while (isActive && audioMode == AudioMode.ON_PHONE && audioCapture == null) { + val projection = AudioBridge.projection.value + if (projection == null) { + requestProjection() + } else if (startAudioCapture(projection)) { + Log.i(TAG, "аудио-захват поднят") + break + } else { + // Захват не поднялся (нет RECORD_AUDIO / проекция мертва). Не дёргаем + // новую проекцию сразу — даём провижинингу/системе время; после + // нескольких неудач берём свежую проекцию. + fails++ + if (fails >= 3) { + Log.w(TAG, "захват не поднимается ($fails) — берём новую проекцию") + AudioBridge.clearProjection() + fails = 0 + } + } + kotlinx.coroutines.delay(AUDIO_ENSURE_MS) + } + } + } + + private fun startAudioCapture(projection: android.media.projection.MediaProjection): Boolean { + if (audioCapture != null) return true + val capture = GlassesAudioCapture( + projection = projection, + sink = { bytes -> audioFrames.trySend(bytes) }, + onStopped = { lifecycleScope.launch { onCaptureDied() } }, + ) + return if (capture.start(lifecycleScope)) { + audioCapture = capture + true + } else { + false + } + } + + /** Захват умер сам (read-ошибка) — если режим ещё ON_PHONE, поднимаем заново. */ + private fun onCaptureDied() { + if (audioCapture == null) return + audioCapture = null + if (audioMode == AudioMode.ON_PHONE) { + Log.w(TAG, "аудио-захват умер — перезапускаю") + ensureAudioCaptureLoop() + } + } + + private fun stopAudioCapture() { + audioCapture?.stop() + audioCapture = null + } + + private fun requestProjection() { + AudioBridge.requestProjection() + // MainActivity обычно уже жив (HOME), но если нет — поднимаем: запустить + // ActivityResult-лончер иначе некому. + runCatching { + startActivity( + Intent(this, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + ) + }.onFailure { Log.w(TAG, "Не удалось поднять MainActivity: ${it.message}") } + } + + private fun setOutputMuted(muted: Boolean) { + runCatching { + val am = getSystemService(AUDIO_SERVICE) as AudioManager + val dir = if (muted) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE + am.adjustStreamVolume(AudioManager.STREAM_MUSIC, dir, 0) + Log.i(TAG, "STREAM_MUSIC: ${if (muted) "mute" else "unmute"}") + }.onFailure { Log.w(TAG, "adjustStreamVolume failed: ${it.message}") } + } + /** Отправляет текущее состояние Shizuku конкретной сессии (ответ на запрос). */ private suspend fun sendShizukuStatus(connId: String) { val snapshot = ShizukuBootstrap.probe() @@ -244,6 +437,13 @@ class VmService : LifecycleService() { // Не покрывает SIGKILL/reinstall — там наш код не выполняется вообще. runCatching { controller.stopGuest() } runCatching { controller.stopMercuryKillGuard() } + runCatching { audioEnsureJob?.cancel() } + runCatching { stopAudioCapture() } + runCatching { setOutputMuted(false) } + runCatching { audioFrames.close() } + runCatching { audioServer?.close() } + audioServer = null + audioSessions.clear() unregisterMdns() server?.close() server = null @@ -329,6 +529,14 @@ class VmService : LifecycleService() { companion object { const val WS_PORT = 8080 const val WS_PATH = "/vm" + + /** Отдельный порт/путь для PCM-аудио (задача #2). Телефон знает их по константе. */ + const val AUDIO_PORT = 8081 + const val AUDIO_PATH = "/vm-audio" + + /** Интервал ретраев подъёма аудио-захвата (пока режим ON_PHONE). */ + private const val AUDIO_ENSURE_MS = 2_500L + private const val NOTIFICATION_ID = 100 private const val TAG = "VmService" @@ -349,5 +557,49 @@ class VmService : LifecycleService() { } fun get(): VmService? = instance + + /** + * Debug-only: запустить [GlassesAudioCapture] на [seconds] секунд с пустым sink + * (только логирование RMS в logcat). Доказывает, что MediaProjection + AudioPlaybackCapture + * реально поднимаются, без зависимости от WebSocket (который не работает вне общей L3-сети). + */ + fun requestAudioPreview(seconds: Int) { + instance?.let { svc -> + Log.i(TAG, "DEBUG requestAudioPreview ${seconds}s") + AudioBridge.requestProjection() + // Дальше запустим capture когда projection появится. Запускаем polling через lifecycleScope. + svc.lifecycleScope.launch { + val deadline = System.currentTimeMillis() + (seconds * 1000L + 8000L) + while (System.currentTimeMillis() < deadline) { + val p = AudioBridge.projection.value + if (p != null) { + val cap = GlassesAudioCapture( + projection = p, + sink = { /* no-op: RMS пишется внутри capture-цикла */ }, + ).apply { keepProjectionOnStop = true } + if (cap.start(svc.lifecycleScope)) { + Log.i(TAG, "DEBUG preview capture started") + kotlinx.coroutines.delay(seconds * 1000L) + cap.stop() + Log.i(TAG, "DEBUG preview capture stopped") + } else { + Log.w(TAG, "DEBUG preview capture failed to start") + } + break + } + kotlinx.coroutines.delay(500L) + } + } + } ?: Log.w(TAG, "requestAudioPreview: instance is null") + } + + /** + * Debug-only: выставить аудио-режим, как если бы пришёл `SetAudioMode` + * по Channel 1 (нужно для тестов, когда WebSocket-канал недоступен). + */ + fun debugSetAudioMode(mode: AudioMode) { + instance?.applyAudioMode(mode) + ?: Log.w(TAG, "debugSetAudioMode: instance is null") + } } } diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioBridge.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioBridge.kt new file mode 100644 index 0000000..eaa0d31 --- /dev/null +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioBridge.kt @@ -0,0 +1,47 @@ +package pw.binom.rayneovm.glasses.audio + +import android.media.projection.MediaProjection +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Мост между [android.media.projection.MediaProjection] (получается только из Activity) и + * компонентами, которым нужен захват звука ([VmService]). + * + * MediaProjection API требует, чтобы `createScreenCaptureIntent()` запускался из Activity + * и результат обрабатывался в ней же. Поэтому MainActivity держит [requestTick]-подписку + * и по запросу поднимает системный интент; полученный projection кладётся сюда, а + * [VmService] его забирает. + * + * Диалог разрешения при выставленном appop `PROJECT_MEDIA = allow` не показывается — + * интент сразу возвращает RESULT_OK. + */ +object AudioBridge { + + private val _requestTick = MutableStateFlow(0L) + + /** Счётчик запросов на получение projection: MainActivity реагирует на изменение. */ + val requestTick: StateFlow = _requestTick.asStateFlow() + + private val _projection = MutableStateFlow(null) + + /** Текущий выданный projection (null — ещё не получен/сброшен). */ + val projection: StateFlow = _projection.asStateFlow() + + /** Запросить у пользователя/системы новый projection (интент поднимет MainActivity). */ + fun requestProjection() { + _requestTick.value = _requestTick.value + 1 + } + + fun onProjectionGranted(projection: MediaProjection) { + // Медиапроекция на приложение ровно одна: получив новую, гасим старую. + _projection.value?.let { runCatching { it.stop() } } + _projection.value = projection + } + + fun clearProjection() { + _projection.value?.let { runCatching { it.stop() } } + _projection.value = null + } +} diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioCaptureService.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioCaptureService.kt deleted file mode 100644 index 9293e6a..0000000 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioCaptureService.kt +++ /dev/null @@ -1,159 +0,0 @@ -package pw.binom.rayneovm.glasses.audio - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.pm.ServiceInfo -import android.media.AudioAttributes -import android.media.AudioFormat -import android.media.AudioPlaybackCaptureConfiguration -import android.media.AudioRecord -import android.media.MediaRecorder -import android.media.projection.MediaProjection -import android.os.Build -import android.os.IBinder -import android.util.Log -import androidx.core.app.NotificationCompat -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import pw.binom.mercury.transport.ByteTransport -import pw.binom.mercury.transport.Transports -import pw.binom.rayneovm.glasses.MainActivity -import pw.binom.rayneovm.glasses.VmService -import pw.binom.rayneovm.shared.AudioMode - -/** - * Foreground service: захват системного аудио и стрим на телефон. - * - * Два режима: - * - AudioPlaybackCapture (Android 12+): захват звука других приложений. - * Требует MediaProjection (получается через ActivityResult API). - * - MIC fallback (Android < 12 или без MediaProjection): обычный микрофон. - * - * Сейчас сервис стартует в MIC-режиме (для каркаса), реальный AudioPlaybackCapture - * требует Activity-инициацию через onActivityResult — TODO. - */ -class AudioCaptureService : android.app.Service() { - - private var recorder: AudioRecord? = null - private var streamer: AudioStreamer? = null - private var transport: ByteTransport? = null - - override fun onBind(intent: Intent): IBinder? = null - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - val modeExtra = intent?.getStringExtra(EXTRA_MODE) - if (modeExtra != AudioMode.ON_PHONE.name) { - Log.i(TAG, "Stop: mode=$modeExtra") - stopCapture() - stopSelf() - return START_NOT_STICKY - } - startForegroundCompat() - startCapture() - return START_STICKY - } - - private fun startForegroundCompat() { - val channelId = createChannel() - val notification = buildNotification(channelId) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION) - } else { - startForeground(NOTIFICATION_ID, notification) - } - } - - private fun startCapture() { - val sampleRate = SAMPLE_RATE - val channelMask = AudioFormat.CHANNEL_IN_STEREO - val encoding = AudioFormat.ENCODING_PCM_16BIT - val minBuffer = AudioRecord.getMinBufferSize(sampleRate, channelMask, encoding) - if (minBuffer <= 0) { - Log.w(TAG, "getMinBufferSize returned $minBuffer") - return - } - val bufferSize = minBuffer * 4 - - // MIC fallback. Реальный AudioPlaybackCaptureConfiguration требует MediaProjection, - // который получается через Activity.onActivityResult — TODO. - @Suppress("MissingPermission") - recorder = AudioRecord( - MediaRecorder.AudioSource.MIC, - sampleRate, channelMask, encoding, bufferSize - ) - - val wsUrl = "ws://127.0.0.1:${VmService.WS_PORT}/vm-audio" - transport = Transports.wifiBytes(wsUrl, autoReconnect = true) - streamer = AudioStreamer(recorder!!, transport!!) - - recorder!!.startRecording() - kotlinx.coroutines.GlobalScope.launch(Dispatchers.IO) { streamer!!.run() } - Log.i(TAG, "AudioCapture (MIC fallback) started: $sampleRate Hz, buffer=$bufferSize bytes") - } - - private fun stopCapture() { - recorder?.stop() - recorder?.release() - recorder = null - streamer?.stop() - streamer = null - transport?.close() - transport = null - Log.i(TAG, "AudioCapture stopped") - } - - override fun onDestroy() { - super.onDestroy() - stopCapture() - } - - private fun createChannel(): String { - val id = "vm_audio_capture" - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val mgr = getSystemService(NotificationManager::class.java) - mgr.createNotificationChannel(NotificationChannel(id, "Audio Capture", NotificationManager.IMPORTANCE_LOW)) - } - return id - } - - private fun buildNotification(channelId: String): Notification { - val openApp = PendingIntent.getActivity( - this, 0, Intent(this, MainActivity::class.java), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - return NotificationCompat.Builder(this, channelId) - .setSmallIcon(android.R.drawable.ic_media_play) - .setContentTitle("RayNeo VM: Audio Capture") - .setContentText("Захват системного аудио") - .setContentIntent(openApp) - .setOngoing(true) - .build() - } - - companion object { - private const val TAG = "AudioCaptureService" - private const val NOTIFICATION_ID = 200 - private const val EXTRA_MODE = "mode" - const val SAMPLE_RATE = 44100 - - fun start(context: Context, mode: AudioMode) { - val intent = Intent(context, AudioCaptureService::class.java).apply { - putExtra(EXTRA_MODE, mode.name) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent) - } else { - context.startService(intent) - } - } - - fun stop(context: Context) { - context.stopService(Intent(context, AudioCaptureService::class.java)) - } - } -} diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioStreamer.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioStreamer.kt deleted file mode 100644 index c3a4133..0000000 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioStreamer.kt +++ /dev/null @@ -1,62 +0,0 @@ -package pw.binom.rayneovm.glasses.audio - -import android.media.AudioRecord -import android.util.Log -import pw.binom.mercury.transport.ByteTransport -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** - * Читает PCM из [AudioRecord] и шлёт сырые байты через [ByteTransport]. - * - * Буфер — 4 KiB (примерно 23 мс @ 44.1 кГц 16-bit stereo). Задержка стрима — небольшая. - * - * TODO: добавить Opus-кодирование (30 KB/s вместо 176 KB/s) для BT-режима. - */ -class AudioStreamer( - private val recorder: AudioRecord, - private val transport: ByteTransport, -) { - - @Volatile - private var stopped = false - - suspend fun run() { - val buf = ByteArray(BUFFER_BYTES) - try { - transport.connect( - onMessage = { /* incoming: ignore */ }, - onConnected = { /* ok */ }, - onDisconnected = { Log.w(TAG, "transport disconnected"); stopped = true }, - ) - } catch (t: Throwable) { - Log.e(TAG, "transport connect failed", t) - return - } - withContext(Dispatchers.IO) { - while (!stopped) { - val read = recorder.read(buf, 0, buf.size) - if (read > 0) { - try { - transport.send(buf.copyOf(read)) - } catch (t: Throwable) { - Log.w(TAG, "send failed: ${t.message}") - break - } - } else if (read < 0) { - Log.w(TAG, "AudioRecord.read error: $read") - break - } - } - } - } - - fun stop() { - stopped = true - } - - companion object { - private const val TAG = "AudioStreamer" - private const val BUFFER_BYTES = 4096 - } -} diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/GlassesAudioCapture.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/GlassesAudioCapture.kt new file mode 100644 index 0000000..36b4508 --- /dev/null +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/GlassesAudioCapture.kt @@ -0,0 +1,152 @@ +package pw.binom.rayneovm.glasses.audio + +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioPlaybackCaptureConfiguration +import android.media.AudioRecord +import android.media.projection.MediaProjection +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Захват системного аудио очков через [AudioPlaybackCapture] (задача #2). + * + * Требует [MediaProjection] (см. [AudioBridge]) и appop `PROJECT_MEDIA = allow`. + * + * Собранные PCM-байты отдаются в [sink] на `Dispatchers.IO`. Раз в ~1 c пишется RMS + * в лог: `0 = тишина` (источник молчит), `>0 = реальный звук`. + * + * Если [onStopped] не `null`, вызывается когда цикл захвата умер сам + * (например, [AudioRecord.read] вернул ошибку) и нужно пере-стартовать. + */ +class GlassesAudioCapture( + private val projection: MediaProjection, + private val sink: suspend (ByteArray) -> Unit, + /** Вызывается, если цикл чтения завершился сам (ошибка [AudioRecord]) — не при [stop]. */ + private val onStopped: (() -> Unit)? = null, +) { + private var record: AudioRecord? = null + private var job: Job? = null + @Volatile private var running = false + + /** + * `true` — при [stop] НЕ вызывать [MediaProjection.stop]. + * Используется в debug-preview (`DEBUG_TEST_AUDIO_PIPE`), когда проекцию выдавать отдельно. + */ + var keepProjectionOnStop: Boolean = false + + fun start(scope: CoroutineScope): Boolean { + if (running) return true + val sampleRate = SAMPLE_RATE + val channelMask = AudioFormat.CHANNEL_IN_STEREO + val encoding = AudioFormat.ENCODING_PCM_16BIT + + val minBuf = AudioRecord.getMinBufferSize(sampleRate, channelMask, encoding) + if (minBuf <= 0) { + Log.w(TAG, "getMinBufferSize=$minBuf") + return false + } + val bufferSize = minBuf * 4 + + val rec = try { + val config = AudioPlaybackCaptureConfiguration.Builder(projection) + .addMatchingUsage(AudioAttributes.USAGE_MEDIA) + .addMatchingUsage(AudioAttributes.USAGE_GAME) + .addMatchingUsage(AudioAttributes.USAGE_UNKNOWN) + .build() + AudioRecord.Builder() + .setAudioFormat( + AudioFormat.Builder() + .setSampleRate(sampleRate) + .setEncoding(encoding) + .setChannelMask(channelMask) + .build(), + ) + .setBufferSizeInBytes(bufferSize) + .setAudioPlaybackCaptureConfig(config) + .build() + } catch (t: Throwable) { + Log.e(TAG, "AudioRecord.Builder упал: ${t.message}", t) + return false + } + + if (rec.state != AudioRecord.STATE_INITIALIZED) { + Log.e(TAG, "AudioRecord не инициализирован (state=${rec.state}); вероятно нет PROJECT_MEDIA") + rec.release() + return false + } + + record = rec + running = true + rec.startRecording() + job = scope.launch(Dispatchers.IO) { + val buf = ByteArray(FRAME_BYTES) + var frameCount = 0 + var totalBytes = 0L + while (isActive && running) { + val read = rec.read(buf, 0, buf.size) + if (read > 0) { + try { + sink(buf.copyOf(read)) + } catch (t: Throwable) { + Log.w(TAG, "sink упал: ${t.message}") + } + totalBytes += read + frameCount++ + if (frameCount % 40 == 0) { + Log.i(TAG, "capture: bytes=$totalBytes rms=${rms16(buf, read).toInt()}") + } + } else if (read < 0) { + Log.w(TAG, "AudioRecord.read ошибка: $read") + break + } + } + if (running && isActive) { + running = false + Log.w(TAG, "цикл захвата завершился сам — сообщаю наверх") + onStopped?.invoke() + } + } + Log.i(TAG, "AudioPlaybackCapture запущен: $sampleRate Гц stereo, buffer=$bufferSize") + return true + } + + fun stop() { + running = false + job?.cancel() + job = null + record?.let { + runCatching { it.stop() } + runCatching { it.release() } + } + record = null + Log.i(TAG, "AudioPlaybackCapture остановлен") + if (!keepProjectionOnStop) { + runCatching { projection.stop() } + } + } + + private fun rms16(buf: ByteArray, len: Int): Double { + if (len < 2) return 0.0 + var sumSq = 0.0 + var i = 0 + while (i + 1 < len) { + 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 / (len / 2)) + } + + companion object { + private const val TAG = "GlassesAudioCapture" + const val SAMPLE_RATE = 44100 + + /** ~23 мс @ 44.1 кГц 16-bit stereo — маленькая задержка без перегрузки кадрами. */ + private const val FRAME_BYTES = 4096 + } +} diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/debug/GlassesDebugReceiver.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/debug/GlassesDebugReceiver.kt new file mode 100644 index 0000000..7f7b1d6 --- /dev/null +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/debug/GlassesDebugReceiver.kt @@ -0,0 +1,130 @@ +package pw.binom.rayneovm.glasses.debug + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioTrack +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import pw.binom.rayneovm.glasses.VmService +import pw.binom.rayneovm.shared.AudioMode + +/** + * Отладочные команды для очков. Защищены разрешением `android.permission.DUMP`. + * + * Использование: + * ``` + * adb -s A06B4AB933C4103 shell am broadcast \ + * -a pw.binom.rayneovm.glasses.DEBUG_TEST_TONE \ + * --ei seconds 10 \ + * -n pw.binom.rayneovm.glasses/.debug.GlassesDebugReceiver + * ``` + * + * Тестовый тон пишет 440 Гц PCM 16-bit стерео через AudioTrack.USAGE_MEDIA → гарантированно + * попадает в AudioFlinger и проверяет: + * - динамик очков работает (до mute в #2); + * - аудио-захват AudioPlaybackCapture на очках действительно ловит поток (через log RMS). + */ +class GlassesDebugReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + ACTION_TEST_TONE -> TestTone.play(intent.getIntExtra("seconds", 10)) + ACTION_STOP_TONE -> TestTone.stop() + ACTION_AUDIO_PIPE -> + VmService.requestAudioPreview(intent.getIntExtra("seconds", 8)) + ACTION_STOP_PHONE_AUDIO -> VmService.debugSetAudioMode(AudioMode.ON_GLASSES) + ACTION_START_PHONE_AUDIO -> VmService.debugSetAudioMode(AudioMode.ON_PHONE) + } + } + + companion object { + const val ACTION_TEST_TONE = "pw.binom.rayneovm.glasses.DEBUG_TEST_TONE" + const val ACTION_STOP_TONE = "pw.binom.rayneovm.glasses.DEBUG_STOP_TONE" + const val ACTION_AUDIO_PIPE = "pw.binom.rayneovm.glasses.DEBUG_AUDIO_PIPE" + const val ACTION_START_PHONE_AUDIO = "pw.binom.rayneovm.glasses.DEBUG_AUDIO_PHONE" + const val ACTION_STOP_PHONE_AUDIO = "pw.binom.rayneovm.glasses.DEBUG_AUDIO_STOP" + } +} + +/** Глобальный односекундный синус 440 Гц PCM16 стерео для самотестирования аудио-тракта очков. */ +object TestTone { + private const val TAG = "TestTone" + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var job: Job? = null + private val lock = Any() + + fun play(seconds: Int) { + synchronized(lock) { + job?.cancel() + job = scope.launch { + val sr = 44100 + val stereo = AudioFormat.CHANNEL_OUT_STEREO + val pcm16 = AudioFormat.ENCODING_PCM_16BIT + val minBuf = AudioTrack.getMinBufferSize(sr, stereo, pcm16) + if (minBuf <= 0) { + Log.w(TAG, "getMinBufferSize=$minBuf (не запускаем тон)") + return@launch + } + val track = AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(), + ) + .setAudioFormat( + AudioFormat.Builder() + .setSampleRate(sr) + .setEncoding(pcm16) + .setChannelMask(stereo) + .build(), + ) + .setBufferSizeInBytes(minBuf * 2) + .setTransferMode(AudioTrack.MODE_STREAM) + .build() + runCatching { track.play() }.onFailure { Log.e(TAG, "track.play() failed", it) } + Log.i(TAG, "test tone start: $sr Hz stereo, ${seconds}s") + val totalFrames = sr * seconds + val buf = ShortArray(2048) + var phase = 0.0 + val step = 2.0 * Math.PI * 440.0 / sr + var written = 0 + while (isActive && written < totalFrames) { + for (i in buf.indices step 2) { + val s = (Math.sin(phase) * 8000.0).toInt().toShort() + buf[i] = s + buf[i + 1] = s + phase += step + if (phase > 2 * Math.PI) phase -= 2 * Math.PI + } + track.write(buf, 0, buf.size) + written += buf.size / 2 + } + runCatching { track.stop() } + track.release() + Log.i(TAG, "test tone done: written=${written * 2}bytes") + } + } + } + + fun stop() { + synchronized(lock) { + job?.cancel() + job = null + Log.i(TAG, "test tone stopped by request") + } + } + + fun release() { + stop() + scope.cancel() + } +} diff --git a/app-phone-vm/src/main/AndroidManifest.xml b/app-phone-vm/src/main/AndroidManifest.xml index 6c72903..7c43fc8 100644 --- a/app-phone-vm/src/main/AndroidManifest.xml +++ b/app-phone-vm/src/main/AndroidManifest.xml @@ -64,6 +64,7 @@ + diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt index c0120fe..776d226 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt @@ -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 } } diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt index c8b5f9b..e2f38ad 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt @@ -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() } diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/debug/DebugReceiver.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/debug/DebugReceiver.kt index c52ba14..67b65b5 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/debug/DebugReceiver.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/debug/DebugReceiver.kt @@ -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" } } diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt index 3deae74..df60c5b 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt @@ -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 = _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, но канал не подключён — аудио не стартует") + } } } } diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt index 22af3f9..ecb14c3 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt @@ -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?,