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:
+73
@@ -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://<glasses_ip>: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 <ssid>` от shell — отлично работает (с shell UID через Shizuku или напрямую). Используется в `AdbController`/`ShizukuProvisioner`.
|
||||
@@ -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) на постоянку — можно; при равнозначных развилках
|
||||
выбирать самому и документировать.
|
||||
|
||||
@@ -73,11 +73,11 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Foreground service: WebSocket server + mDNS -->
|
||||
<!-- Foreground service: WebSocket server + mDNS + захват аудио (задача #2) -->
|
||||
<service
|
||||
android:name=".VmService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
android:foregroundServiceType="mediaPlayback|mediaProjection" />
|
||||
|
||||
<!-- Пассивный Accessibility-сервис (задача #5): живучесть процесса +
|
||||
легальный запуск Activity из фона. Ничего не читает и не инжектит.
|
||||
@@ -111,6 +111,20 @@
|
||||
android:exported="true"
|
||||
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||||
|
||||
<!-- Отладочный приёмник (защищён DUMP): тестовый тон для проверки аудио-тракта. -->
|
||||
<receiver
|
||||
android:name=".debug.GlassesDebugReceiver"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.DUMP">
|
||||
<intent-filter>
|
||||
<action android:name="pw.binom.rayneovm.glasses.DEBUG_TEST_TONE" />
|
||||
<action android:name="pw.binom.rayneovm.glasses.DEBUG_AUDIO_PIPE" />
|
||||
<action android:name="pw.binom.rayneovm.glasses.DEBUG_AUDIO_PHONE" />
|
||||
<action android:name="pw.binom.rayneovm.glasses.DEBUG_AUDIO_STOP" />
|
||||
<action android:name="pw.binom.rayneovm.glasses.DEBUG_STOP_TONE" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<meta-data android:name="com.rayneo.mercury.app" android:value="true" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ByteArray>? = null
|
||||
private val audioSessions = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
|
||||
@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<ByteArray>(
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Long> = _requestTick.asStateFlow()
|
||||
|
||||
private val _projection = MutableStateFlow<MediaProjection?>(null)
|
||||
|
||||
/** Текущий выданный projection (null — ещё не получен/сброшен). */
|
||||
val projection: StateFlow<MediaProjection?> = _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
|
||||
}
|
||||
}
|
||||
-159
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+152
@@ -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
|
||||
}
|
||||
}
|
||||
+130
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
|
||||
Reference in New Issue
Block a user