аудио UDP+Opus (очки→телефон), жесты дужки → гость, «убить всё лишнее» на очках

- shared/audio: вендорнут Concentus (libs/concentus.jar), OpusCodec/AudioDatagram/AudioConfig;
  захват на очках 48кГц стерео → Opus → UDP-датаграммы вместо сырого PCM по WebSocket
- glass.audio: AudioRecord 48кГц с минимальным буфером; UDP-сервер запоминает адрес пира из датаграммы
- glasses: жесты тач-сенсора дужки (cyttsp5_mt) через dispatchTouchEvent → DPAD/BACK гостю (TempleGesture*)
- glasses: forceStopAll — force-stop сторонних + xr.runtime, kill scrcpy-сирот через Shizuku UserService
- phone: AudioPlaybackManager на UDP+Opus+low-latency AudioTrack; кнопка «Убить всё лишнее» на вкладке очков
- deps: mercury transport 0.1.2-SNAPSHOT, shizuku-vd 0.1.1-SNAPSHOT
This commit is contained in:
2026-10-01 05:54:54 +03:00
parent 8cfd9192b3
commit d4e1b66924
16 changed files with 744 additions and 90 deletions
@@ -9,17 +9,25 @@ import android.os.PowerManager
import android.util.Log
import pw.binom.mercury.transport.ByteTransport
import pw.binom.mercury.transport.Transports
import pw.binom.rayneovm.shared.audio.AudioConfig
import pw.binom.rayneovm.shared.audio.AudioDatagram
import pw.binom.rayneovm.shared.audio.OpusAudioDecoder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Менеджер воспроизведения аудио с очков на телефон (Audio mode 2).
* Воспроизведение аудио с очков на телефоне (Audio mode 2).
*
* Держит AudioTrack + WiFi/Wake lock + WebSocket-клиент, качающий PCM с очков.
* Канал — **UDP + Opus**: телефон шлёт очкам keepalive-датаграммы (так очки запоминают
* наш адрес как пира) и принимает Opus-пакеты, декодирует и пишет в low-latency [AudioTrack].
*
* UDP выбран вместо WebSocket: устаревшие пакеты дропаются, а не копятся (нет роста задержки).
*
* Вызывается из [pw.binom.rayneovm.phone.protocol.VmPhoneController] при получении
* [pw.binom.rayneovm.shared.SetAudioMode] с режимом ON_PHONE.
@@ -30,9 +38,16 @@ class AudioPlaybackManager(private val context: Context) {
private var track: AudioTrack? = null
private var transport: ByteTransport? = null
private var streamJob: Job? = null
private var keepAliveJob: Job? = null
private var wifiLock: WifiManager.WifiLock? = null
private var wakeLock: PowerManager.WakeLock? = null
@Volatile
private var running = false
private val decoder = OpusAudioDecoder()
private val pcmOut = ShortArray(AudioConfig.FRAME_SHORTS)
fun start(glassesHost: String) {
if (track != null) {
Log.w(TAG, "start: уже running — пропускаю (host=$glassesHost)")
@@ -40,12 +55,12 @@ class AudioPlaybackManager(private val context: Context) {
}
Log.i(TAG, "start: host=$glassesHost")
val sampleRate = SAMPLE_RATE
val sampleRate = AudioConfig.SAMPLE_RATE
val channelMask = AudioFormat.CHANNEL_OUT_STEREO
val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBuffer = AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding)
val bufferSize = minBuffer * 4
val bufferSize = maxOf(minBuffer, AudioConfig.FRAME_BYTES)
track = AudioTrack.Builder()
.setAudioAttributes(
@@ -63,9 +78,10 @@ class AudioPlaybackManager(private val context: Context) {
)
.setBufferSizeInBytes(bufferSize)
.setTransferMode(AudioTrack.MODE_STREAM)
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
.build()
track!!.play()
Log.i(TAG, "AudioTrack started: $sampleRate Hz, buffer=$bufferSize")
Log.i(TAG, "AudioTrack started: $sampleRate Hz stereo, buffer=$bufferSize")
// WiFi + Wake lock
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
@@ -75,43 +91,72 @@ class AudioPlaybackManager(private val context: Context) {
acquire(60 * 60 * 1000L)
}
// Подключаемся к очкам по тому же ws://host:port/vm-audio
val wsUrl = "ws://$glassesHost:${GLASSES_PORT}/vm-audio"
Log.i(TAG, "start: подключаюсь к $wsUrl")
transport = Transports.wifiBytes(wsUrl, autoReconnect = true)
running = true
transport = Transports.udp(glassesHost, AudioConfig.UDP_PORT)
val subscribe = ByteArray(0)
streamJob = scope.launch {
var totalBytes = 0L
var frames = 0
var lastSeq = -1
var lost = 0
try {
transport!!.connect(
onMessage = { pcm ->
onMessage = { datagram ->
if (!AudioDatagram.hasPayload(datagram)) return@connect
val seq = AudioDatagram.seq(datagram)
if (lastSeq >= 0) {
val gap = seq - lastSeq - 1
if (gap in 1 until 1000) lost += gap
}
lastSeq = seq
val t = track ?: return@connect
if (frames == 0 || frames % 40 == 0) {
Log.i(TAG, "playing: frame#=$frames size=${pcm.size} bytes=$totalBytes rms=${rms16(pcm).toInt()}")
val decoded = try {
decoder.decode(
datagram,
AudioDatagram.HEADER_SIZE,
datagram.size - AudioDatagram.HEADER_SIZE,
pcmOut,
)
} catch (e: Throwable) {
Log.w(TAG, "opus decode failed: ${e.message}")
-1
}
val written = t.write(pcm, 0, pcm.size)
if (written < 0) {
Log.w(TAG, "playing: AudioTrack.write вернул $written (size=${pcm.size})")
if (decoded > 0) {
t.write(pcmOut, 0, decoded * AudioConfig.CHANNELS)
}
totalBytes += pcm.size
frames++
if (frames % 200 == 0) {
Log.i(TAG, "playing: frames=$frames lost=$lost lastSeq=$seq")
}
},
onConnected = { Log.i(TAG, "audio connected to $wsUrl") },
onDisconnected = { Log.w(TAG, "audio disconnected from $wsUrl") },
onConnected = {
Log.i(TAG, "audio udp connected to $glassesHost:${AudioConfig.UDP_PORT}")
// Подписка: заставляем очки запомнить наш адрес как аудио-пира.
runCatching { transport!!.send(subscribe) }
},
onDisconnected = { Log.w(TAG, "audio udp disconnected from $glassesHost") },
)
} catch (t: kotlinx.coroutines.CancellationException) {
Log.i(TAG, "start: stream job cancelled (stop)")
// Штатная остановка (stop()/смена режима) — не ошибка.
} catch (t: CancellationException) {
Log.i(TAG, "stream job cancelled (stop)")
} catch (t: Throwable) {
Log.e(TAG, "audio transport failed", t)
}
Log.i(TAG, "start: stream job завершился frames=$frames bytes=$totalBytes")
Log.i(TAG, "stream job завершился frames=$frames lost=$lost")
}
// Очки считают пира живым, пока от него идут датаграммы (peerTimeoutMs). Держим связь.
keepAliveJob = scope.launch {
while (isActive && running) {
delay(AudioConfig.KEEPALIVE_MS)
runCatching { transport?.send(subscribe) }
}
}
}
fun stop() {
Log.i(TAG, "stop: track=${track != null} transport=${transport != null}")
running = false
keepAliveJob?.cancel(); keepAliveJob = null
streamJob?.cancel(); streamJob = null
transport?.close(); transport = null
track?.stop(); track?.release(); track = null
@@ -120,23 +165,7 @@ 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
/** Совпадает с [pw.binom.rayneovm.glasses.VmService.AUDIO_PORT]. */
const val GLASSES_PORT = 8081
private companion object {
const val TAG = "AudioPlaybackMgr"
}
}
@@ -21,6 +21,7 @@ import pw.binom.rayneovm.shared.AppLaunchedAck
import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove
import pw.binom.rayneovm.shared.CursorScroll
import pw.binom.rayneovm.shared.ForceStopAll
import pw.binom.rayneovm.shared.GetAppList
import pw.binom.rayneovm.shared.GlassesStatus
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
@@ -190,6 +191,14 @@ class VmPhoneController(
send(RequestShizukuStatus)
}
/**
* «Убить всё лишнее» на очках: force-stop сторонних приложений + RayNeo XR-runtime
* (греющая камера) + процессы-сироты вроде scrcpy. Наши приложения и системное не трогает.
*/
suspend fun forceStopAll() {
send(ForceStopAll)
}
/** Отправляет сообщение очкам. */
suspend fun send(msg: VmMessage) {
val t = currentTransport ?: return
@@ -173,6 +173,11 @@ fun GlassesStatusScreen(
onConnect = { ssid, password -> mercuryController.connectWifi(ssid, password) },
)
ForceStopSection(
enabled = connection is ConnectionState.Connected,
onForceStop = { controller.forceStopAll() },
)
Spacer(modifier = Modifier.height(8.dp))
LaunchGlassesAppButton(
@@ -431,6 +436,54 @@ private fun AudioOutputSection(
}
}
/**
* «Убить всё лишнее» на очках: снимает сторонние приложения, RayNeo XR-runtime
* (он держит камеру и греет) и процессы-сироты вроде scrcpy. Наши приложения, Shizuku и
* активный гость на виртуальном дисплее остаются жить.
*/
@Composable
private fun ForceStopSection(
enabled: Boolean,
onForceStop: suspend () -> Unit,
) {
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
var message by remember { mutableStateOf<String?>(null) }
SectionCard(title = "Обслуживание") {
Hint(
"Остановит все лишние приложения на очках (включая RayNeo XR-runtime, который " +
"греет) и зеркала экрана. Наше приложение и текущий гость не тронутся.",
)
Button(
enabled = !busy && enabled,
onClick = {
busy = true
message = null
scope.launch {
try {
onForceStop()
message = "Команда отправлена на очки"
} catch (t: Throwable) {
message = "Ошибка: ${t.message}"
} finally {
busy = false
}
}
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF8E2A2A),
disabledContainerColor = Color(0xFF2A2A2A),
),
) {
Text(if (busy) "Отправляем…" else "Убить всё лишнее на очках")
}
if (!enabled) Hint("Нужен Канал 1 (WebSocket) — приложение на очках не подключено.")
message?.let { Hint(it) }
}
}
@Composable
private fun GlassesWifiSection(
status: GeneralStatus?,