аудио 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
@@ -7,6 +7,8 @@ import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.util.Log
import android.view.Display
import android.view.InputDevice
import android.view.MotionEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -29,6 +31,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import pw.binom.rayneovm.glasses.audio.AudioBridge
import pw.binom.rayneovm.glasses.input.TempleGestureDetector
import pw.binom.rayneovm.glasses.protocol.VmGlassesController
import pw.binom.rayneovm.glasses.ui.GlassesScreen
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
@@ -38,6 +41,26 @@ class MainActivity : ComponentActivity() {
/** Последний обработанный tick запроса проекции (см. onCreate). */
private var handledTick = 0L
/** Контроллер очков — нужен и Compose, и распознавателю жестов дужки. */
private lateinit var controller: VmGlassesController
/**
* Распознаватель жестов тач-сенсора дужки. Дужка на X3 Pro — отдельное input-устройство
* `cyttsp5_mt` без ассоциированного дисплея, поэтому события не доходят до Compose и
* перехватываются в [dispatchTouchEvent]. Жесты уходят гостю через
* [VmGlassesController.sendTempleGesture].
*/
private val templeGestureDetector by lazy {
TempleGestureDetector { gesture -> controller.sendTempleGesture(gesture) }
}
/** id input-устройства дужки (`cyttsp5_mt`); -1 — не найдено. */
private val templeDeviceId: Int by lazy {
InputDevice.getDeviceIds().firstOrNull { id ->
InputDevice.getDevice(id)?.name?.contains(TEMPLE_DEVICE_HINT) == true
} ?: -1
}
/**
* Пользовательский диалог MediaProjection. При выставленном appop `PROJECT_MEDIA = allow`
* он не показывается — интент сразу возвращает RESULT_OK (нужно для задачи #2).
@@ -71,6 +94,13 @@ class MainActivity : ComponentActivity() {
}
enableEdgeToEdge()
ShizukuBootstrap.attach(this)
// Диагностика: печатаем input-устройства и id дужки, чтобы в логе было видно,
// куда приходят жесты (см. dispatchTouchEvent).
InputDevice.getDeviceIds().forEach { id ->
val d = InputDevice.getDevice(id)
Log.i(TAG, "input device id=$id name=${d?.name} sources=0x${d?.sources?.toString(16)}")
}
Log.i(TAG, "temple device id=$templeDeviceId (hint='$TEMPLE_DEVICE_HINT')")
// Запросы MediaProjection приходят из VmService через AudioBridge (задача #2).
// Запускаем интент только когда Activity в RESUMED (реально видима): если её запускать
// из не-foreground состояния, системная grant-активити отменяется и возвращает CANCELED.
@@ -89,6 +119,7 @@ class MainActivity : ComponentActivity() {
}
}
val controller = VmGlassesController.fromContext(this)
this.controller = controller
setContent {
CompositionLocalProvider(VmGlassesController.Local provides controller) {
@@ -116,6 +147,29 @@ class MainActivity : ComponentActivity() {
ShizukuBootstrap.refresh()
}
/**
* Перехватываем тач-события окна ДО Compose/View-иерархии. События с устройства дужки
* (`cyttsp6_mt`) не ассоциированы с дисплеем и иначе теряются: `onTouchEvent` Activity
* при этом может не вызываться вовсе. Всё, что пришло с дужки, отдаём распознавателю и
* потребляем, чтобы не утекло в UI.
*/
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
// Диагностика: на ACTION_DOWN печатаем устройство — видно, доходят ли касания дужки
// (deviceId тача дужки = 4, `cyttsp5_mt`) до окна Activity вообще.
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
Log.d(
TAG,
"touch down: deviceId=${event.deviceId} source=0x${event.source.toString(16)} " +
"x=${event.x} y=${event.y}",
)
}
val isTemple = event.deviceId == templeDeviceId && templeDeviceId != -1
if (!isTemple) return super.dispatchTouchEvent(event)
Log.d(TAG, "temple: action=${event.actionMasked} x=${event.x} y=${event.y}")
templeGestureDetector.onTouchEvent(event)
return true
}
override fun onPause() {
AudioBridge.activityResumed = false
super.onPause()
@@ -142,6 +196,13 @@ class MainActivity : ComponentActivity() {
companion object {
private const val TAG = "MainActivity"
/**
* Имя input-устройства тач-сенсора дужки RayNeo X3 Pro (проверено через `getevent`:
* все касания дужки приходят на `/dev/input/event2`, устройство `cyttsp5_mt`).
* Второй контроллер `cyttsp6_mt` не генерирует событий.
*/
private const val TEMPLE_DEVICE_HINT = "cyttsp5_mt"
}
}
@@ -38,6 +38,7 @@ import pw.binom.rayneovm.shared.TouchEvent
import pw.binom.rayneovm.shared.VmMessage
import pw.binom.rayneovm.shared.VmProtocol
import pw.binom.rayneovm.shared.GetAppList
import pw.binom.rayneovm.shared.ForceStopAll
import pw.binom.rayneovm.shared.AppEntry
import pw.binom.rayneovm.shared.AppListResponse
import pw.binom.rayneovm.shared.CursorScroll
@@ -46,6 +47,9 @@ import pw.binom.rayneovm.shared.CursorMove
import pw.binom.rayneovm.shared.RequestShizukuStatus
import pw.binom.rayneovm.shared.ScreenControl
import pw.binom.rayneovm.shared.ShizukuStatus
import pw.binom.rayneovm.shared.audio.AudioConfig
import pw.binom.rayneovm.shared.audio.AudioDatagram
import pw.binom.rayneovm.shared.audio.OpusAudioEncoder
/**
* Foreground service на стороне очков.
@@ -73,16 +77,22 @@ class VmService : LifecycleService() {
private var audioCapture: GlassesAudioCapture? = null
private var audioEnsureJob: kotlinx.coroutines.Job? = null
/** Opus-кодер: используется только из аудио-пампа, поэтому не нужна синхронизация. */
private val audioEncoder = OpusAudioEncoder()
/** seq аудио-датаграмм; оборачивается через переполнение Int — это нормально. */
private var audioSeq = 0
@Volatile
private var audioMode: AudioMode = AudioMode.ON_GLASSES
/**
* Очередь кадров PCM между потоком чтения [AudioRecord] и отправкой в сокет.
* При переполнении выкидываем самый старый кадр — для live-аудио важнее свежесть,
* чем полнота (иначе задержка растёт безгранично).
* Очередь кадров PCM (interleaved shorts 48 кГц stereo) между потоком чтения
* [AudioRecord] и Opus-кодером. Крошечная ёмкость + DROP_OLDEST: для live-аудио
* важнее свежесть, чем полнота — иначе задержка растёт безгранично.
*/
private val audioFrames = Channel<ByteArray>(
capacity = 16,
private val audioFrames = Channel<ShortArray>(
capacity = 4,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
@@ -230,6 +240,10 @@ class VmService : LifecycleService() {
Log.i(TAG, "Shutdown")
controller.stopGuest()
}
is ForceStopAll -> {
Log.i(TAG, "ForceStopAll")
controller.forceStopAll()
}
is GetAppList -> {
Log.i(TAG, "GetAppList")
val apps = listInstalledLaunchableApps()
@@ -250,20 +264,26 @@ class VmService : LifecycleService() {
// ---------------- Аудио-мост (задача #2) ----------------
/** Сервер байтового канала `/vm-audio`: телефон подключается сюда за PCM-потоком. */
/**
* UDP-сервер аудио. Телефон шлёт сюда keepalive/подписку, мы запоминаем адрес
* источника (`ip:port`) как пира и отвечаем Opus-датаграммами.
*
* IP телефона НЕ константа — он берётся из адреса входящей датаграммы. В реальной жизни
* очки подключены к точке доступа самого телефона, адреса меняются от сессии к сессии.
*/
private fun startAudioServer() {
audioServer = Transports.wifiServerBytes(port = AUDIO_PORT, path = AUDIO_PATH)
audioServer = Transports.udpServer(port = AudioConfig.UDP_PORT)
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try {
audioServer!!.accept(
onMessage = { _, _ -> /* телефон в этот канал ничего не шлёт */ },
onMessage = { _, _ -> /* keepalive/подписка: содержимое не важно, важен источник */ },
onConnected = { connId ->
audioSessions.add(connId)
Log.i(TAG, "Audio client connected: $connId")
Log.i(TAG, "Audio peer connected: $connId")
},
onDisconnected = { connId ->
audioSessions.remove(connId)
Log.i(TAG, "Audio client disconnected: $connId")
Log.i(TAG, "Audio peer disconnected: $connId")
},
)
} catch (t: Throwable) {
@@ -272,14 +292,24 @@ class VmService : LifecycleService() {
}
}
/** Разбирает очередь кадров и рассылает их всем подключённым аудио-клиентам. */
/** Кодирует кадры PCM в Opus и рассылает датаграммы всем подключённым аудио-пирам. */
private fun startAudioPump() {
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
for (bytes in audioFrames) {
val packet = ByteArray(AudioConfig.MAX_DATAGRAM)
for (pcm in audioFrames) {
val server = audioServer ?: continue
if (audioSessions.isEmpty()) continue
val payload = try {
audioEncoder.encode(pcm, 0, AudioConfig.FRAME_SAMPLES)
} catch (t: Throwable) {
Log.w(TAG, "opus encode failed: ${t.message}")
continue
}
val len = AudioDatagram.pack(audioSeq++, payload, payload.size, packet)
val datagram = packet.copyOf(len)
for (connId in audioSessions) {
try {
server.send(connId, bytes)
server.send(connId, datagram)
} catch (t: Throwable) {
Log.w(TAG, "audio send failed: ${t.message}")
}
@@ -538,10 +568,6 @@ class VmService : LifecycleService() {
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
@@ -11,21 +11,25 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import pw.binom.rayneovm.shared.audio.AudioConfig
/**
* Захват системного аудио очков через [AudioPlaybackCapture] (задача #2).
*
* Требует [MediaProjection] (см. [AudioBridge]) и appop `PROJECT_MEDIA = allow`.
*
* Собранные PCM-байты отдаются в [sink] на `Dispatchers.IO`. Раз в ~1 c пишется RMS
* в лог: `0 = тишина` (источник молчит), `>0 = реальный звук`.
* Пишет 48 кГц stereo 16-bit кадрами ровно по [AudioConfig.FRAME_MS] мс и отдаёт их
* в [sink] на `Dispatchers.IO` как `ShortArray` (interleaved). Кадр фиксированного
* размера нужен Opus-кодеру: он кодирует ровно [AudioConfig.FRAME_SAMPLES] сэмплов/канал.
*
* Раз в ~1 c пишется RMS в лог: `0 = тишина` (источник молчит), `>0 = реальный звук`.
*
* Если [onStopped] не `null`, вызывается когда цикл захвата умер сам
* (например, [AudioRecord.read] вернул ошибку) и нужно пере-стартовать.
*/
class GlassesAudioCapture(
private val projection: MediaProjection,
private val sink: suspend (ByteArray) -> Unit,
private val sink: suspend (ShortArray) -> Unit,
/** Вызывается, если цикл чтения завершился сам (ошибка [AudioRecord]) — не при [stop]. */
private val onStopped: (() -> Unit)? = null,
) {
@@ -35,7 +39,7 @@ class GlassesAudioCapture(
fun start(scope: CoroutineScope): Boolean {
if (running) return true
val sampleRate = SAMPLE_RATE
val sampleRate = AudioConfig.SAMPLE_RATE
val channelMask = AudioFormat.CHANNEL_IN_STEREO
val encoding = AudioFormat.ENCODING_PCM_16BIT
@@ -44,7 +48,9 @@ class GlassesAudioCapture(
Log.w(TAG, "getMinBufferSize=$minBuf")
return false
}
val bufferSize = minBuf * 4
// Небольшой буфер = меньшая задержка. Берём минимум HAL, но не меньше двух кадров,
// чтобы мгновенный джиттер планировщика не приводил к overrun/потере сэмплов.
val bufferSize = maxOf(minBuf, AudioConfig.FRAME_BYTES * 2)
val rec = try {
val config = AudioPlaybackCaptureConfiguration.Builder(projection)
@@ -78,21 +84,26 @@ class GlassesAudioCapture(
running = true
rec.startRecording()
job = scope.launch(Dispatchers.IO) {
val buf = ByteArray(FRAME_BYTES)
val frame = ShortArray(AudioConfig.FRAME_SHORTS)
var filled = 0
var frameCount = 0
var totalBytes = 0L
var totalSamples = 0L
while (isActive && running) {
val read = rec.read(buf, 0, buf.size)
val read = rec.read(frame, filled, frame.size - filled)
if (read > 0) {
filled += read
if (filled == frame.size) {
try {
sink(buf.copyOf(read))
sink(frame.copyOf())
} catch (t: Throwable) {
Log.w(TAG, "sink упал: ${t.message}")
}
totalBytes += read
totalSamples += filled
frameCount++
if (frameCount % 40 == 0) {
Log.i(TAG, "capture: bytes=$totalBytes rms=${rms16(buf, read).toInt()}")
if (frameCount % 100 == 0) {
Log.i(TAG, "capture: samples=$totalSamples rms=${rms16(frame).toInt()}")
}
filled = 0
}
} else if (read < 0) {
Log.w(TAG, "AudioRecord.read ошибка: $read")
@@ -105,7 +116,7 @@ class GlassesAudioCapture(
onStopped?.invoke()
}
}
Log.i(TAG, "AudioPlaybackCapture запущен: $sampleRate Гц stereo, buffer=$bufferSize")
Log.i(TAG, "AudioPlaybackCapture запущен: $sampleRate Гц stereo, кадр=${AudioConfig.FRAME_MS}мс, buffer=$bufferSize")
return true
}
@@ -126,23 +137,16 @@ class GlassesAudioCapture(
Log.i(TAG, "AudioPlaybackCapture остановлен (проекцию сохраняю)")
}
private fun rms16(buf: ByteArray, len: Int): Double {
if (len < 2) return 0.0
private fun rms16(buf: ShortArray): Double {
if (buf.isEmpty()) 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()
for (s in buf) {
sumSq += s.toDouble() * s
i += 2
}
return Math.sqrt(sumSq / (len / 2))
return Math.sqrt(sumSq / buf.size)
}
companion object {
private const val TAG = "GlassesAudioCapture"
const val SAMPLE_RATE = 44100
/** ~23 мс @ 44.1 кГц 16-bit stereo — маленькая задержка без перегрузки кадрами. */
private const val FRAME_BYTES = 4096
}
}
@@ -0,0 +1,117 @@
package pw.binom.rayneovm.glasses.input
/**
* Жест, распознанный на тач-сенсоре дужки RayNeo X3 Pro.
*
* SDK RayNeo ([com.rayneo.arsdk.android.touch.TempleAction]) даёт похожий набор, но мы
* распознаём сами: логика тривиальна, а обфусцированный `TouchDispatcher` тянуть не хочется.
*
* Дужка на X3 Pro — отдельное input-устройство `cyttsp5_mt` (`/dev/input/event2`), у которого
* НЕТ ассоциированного дисплея (`AssociatedDisplay: displayId=''`), поэтому события приходят
* в окно Activity, но Compose/View-иерархия их не интерпретирует как касание. Перехватываем
* их в [android.app.Activity.dispatchTouchEvent] и превращаем в [TempleGesture].
*/
sealed interface TempleGesture {
/** Короткое одиночное касание. */
data object Click : TempleGesture
/** Два касания подряд — по умолчанию «назад» в госте. */
data object DoubleClick : TempleGesture
/**
* Завершённый свайп по дужке с итоговым смещением.
*
* @param dx смещение по X в пикселях сенсора (дужка — длина тач-зоны, ось «вдоль»).
* @param dy смещение по Y в пикселях сенсора (ось «поперёк»).
* @param velocity приблизительная скорость, px/с.
*/
data class Swipe(val dx: Float, val dy: Float, val velocity: Float) : TempleGesture
}
/**
* Распознаватель жестов тач-сенсора дужки.
*
* Питается обычными [android.view.MotionEvent], отфильтрованными по устройству дужки
* (см. `MainActivity.TEMPLE_DEVICE_HINT`). Классификация на ACTION_UP:
* - путь < [TAP_SLOP_PX] и время < [LONG_PRESS_MS] → [TempleGesture.Click];
* - путь ≥ [SLIDE_SLOP_PX] → [TempleGesture.Swipe].
*
* Двойной клик собирается по двум [TempleGesture.Click] подряд с интервалом ≤ [DOUBLE_TAP_MS]:
* второй превращается в [TempleGesture.DoubleClick], первый «съедается» ожиданием.
*
* Состояния между касаниями почти нет (только таймер двойного клика). Вызывать строго
* с UI-потока (`dispatchTouchEvent`).
*/
class TempleGestureDetector(
private val onGesture: (TempleGesture) -> Unit,
) {
private var downX = 0f
private var downY = 0f
private var downTime = 0L
/**
* Одиночный клик откладываем на [DOUBLE_TAP_MS]: если второй тап придёт в окно, первый
* превратится в [TempleGesture.DoubleClick], а не в отдельный [TempleGesture.Click].
* Без этой задержки двойной тап давал бы сначала лишний «выбор», и только потом «назад».
*/
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
private var clickPending = false
private val emitPendingClick = Runnable {
clickPending = false
onGesture(TempleGesture.Click)
}
fun onTouchEvent(event: android.view.MotionEvent) {
when (event.actionMasked) {
android.view.MotionEvent.ACTION_DOWN -> {
downX = event.x
downY = event.y
downTime = event.eventTime
}
android.view.MotionEvent.ACTION_UP -> {
val dt = event.eventTime - downTime
val dx = event.x - downX
val dy = event.y - downY
val distance = kotlin.math.hypot(dx, dy)
if (distance < TAP_SLOP_PX && dt < LONG_PRESS_MS) {
if (clickPending) {
handler.removeCallbacks(emitPendingClick)
clickPending = false
onGesture(TempleGesture.DoubleClick)
} else {
clickPending = true
handler.postDelayed(emitPendingClick, DOUBLE_TAP_MS)
}
} else if (distance >= SLIDE_SLOP_PX) {
cancelPendingClick()
val velocity = distance / dt.coerceAtLeast(1L).toFloat() * 1000f
onGesture(TempleGesture.Swipe(dx = dx, dy = dy, velocity = velocity))
}
}
android.view.MotionEvent.ACTION_CANCEL -> {
downTime = 0L
}
}
}
private fun cancelPendingClick() {
handler.removeCallbacks(emitPendingClick)
}
companion object {
/** Движение короче этого считаем касанием, а не свайпом (в пикселях сенсора). */
const val TAP_SLOP_PX = 40f
/** Смещение, с которого касание становится свайпом. */
const val SLIDE_SLOP_PX = 80f
/** Нажатие дольше этого — не клик (пока не обрабатываем, резерв). */
const val LONG_PRESS_MS = 600L
/**
* Максимальный интервал между двумя кликами для склейки в двойной и одновременно
* задержка одиночного клика (см. [emitPendingClick]).
*/
const val DOUBLE_TAP_MS = 300L
}
}
@@ -27,6 +27,7 @@ import pw.binom.mercury.binocular.FrameSource
import pw.binom.mercury.shizuku.ShizukuVdBinder
import pw.binom.mercury.shizuku.ShizukuVdClient
import pw.binom.rayneovm.glasses.VmShizukuService
import pw.binom.rayneovm.glasses.input.TempleGesture
import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
import pw.binom.rayneovm.shared.VmDisplay
@@ -330,6 +331,49 @@ class VmGlassesController {
}
}
/**
* Жест тач-сенсора дужки → событие гостю на VirtualDisplay.
*
* Маппинг — DPAD-клавиши (крестовина джойстика, которую понимает гость, например ZONA):
* - [TempleGesture.Swipe] с доминирующей осью X → LEFT/RIGHT, осью Y → UP/DOWN;
* - [TempleGesture.DoubleClick] → KEYCODE_BACK («назад»);
* - [TempleGesture.Click] → KEYCODE_DPAD_CENTER (подтверждение).
*
* Знаки осей (какой свайп = «вверх») подбираются эмпирически на устройстве.
* Каждому событию инжектим DOWN+UP парой, как одиночное нажатие клавиши.
*/
fun sendTempleGesture(gesture: TempleGesture) {
val src = activeSource ?: run {
Log.d(TAG, "sendTempleGesture($gesture): нет активного гостя")
return
}
val binder = src.shizukuBinder
val displayId = src.virtualDisplayId
val keyCode: Int = when (gesture) {
is TempleGesture.Swipe -> {
if (kotlin.math.abs(gesture.dx) >= kotlin.math.abs(gesture.dy)) {
if (gesture.dx >= 0f) android.view.KeyEvent.KEYCODE_DPAD_RIGHT
else android.view.KeyEvent.KEYCODE_DPAD_LEFT
} else {
if (gesture.dy >= 0f) android.view.KeyEvent.KEYCODE_DPAD_DOWN
else android.view.KeyEvent.KEYCODE_DPAD_UP
}
}
is TempleGesture.DoubleClick -> android.view.KeyEvent.KEYCODE_BACK
is TempleGesture.Click -> android.view.KeyEvent.KEYCODE_DPAD_CENTER
}
Log.i(TAG, "sendTempleGesture: $gesture -> keyCode=$keyCode")
testScope.launch {
val down = ShizukuVdBinder.injectKeyEvent(
binder, displayId, keyCode, android.view.KeyEvent.ACTION_DOWN,
)
val up = ShizukuVdBinder.injectKeyEvent(
binder, displayId, keyCode, android.view.KeyEvent.ACTION_UP,
)
if (!down || !up) Log.w(TAG, "sendTempleGesture: injectKeyEvent failed (keyCode=$keyCode)")
}
}
/**
* DEBUG: запустить гостя напрямую (без Compose-UI) — то же, что и [launchGuest].
* Используется для тестирования через adb broadcast.
@@ -413,6 +457,43 @@ class VmGlassesController {
}
}
/**
* «Убить всё лишнее» на очках: force-stop всех сторонних приложений + RayNeo XR-runtime
* (держит залипшую камеру и греет) + `kill -9` процессов-сирот вроде scrcpy.
*
* Выполняется нашим Shizuku UserService от shell UID. Защищены: сам GlassesApp, Shizuku
* (через него же и работаем) и активный гость на VirtualDisplay — по просьбе пользователя
* его не трогаем. Дисплей гостя при этом не освобождаем: убьём только процессы вокруг.
*/
fun forceStopAll() {
testScope.launch {
// Страж живёт постоянно (watchdog), его binder — надёжный долгоживущий вариант.
// Если стража нет, берём binder активного гостя.
val binder = guardBinder ?: activeSource?.shizukuBinder
if (binder == null) {
Log.w(TAG, "forceStopAll: нет Shizuku binder (стража нет, гостя нет)")
return@launch
}
val protected = buildList {
add(GLASSES_PACKAGE)
add(SHIZUKU_PACKAGE)
activeGuestPackage?.let { add(it) }
}
val killed = runCatching {
ShizukuVdBinder.forceStopAll(
binder = binder,
protectedPackages = protected,
extraPackages = listOf(XR_RUNTIME_PACKAGE),
processNameSubstrings = listOf(SCRCPY_PROCESS),
)
}.getOrElse {
Log.w(TAG, "forceStopAll failed", it)
-1
}
Log.i(TAG, "forceStopAll: killed=$killed (protected=$protected)")
}
}
/**
* Force-stop активного гостя, дисплей НЕ трогает. Вызывать под [acquireMutex].
*
@@ -619,6 +700,21 @@ class VmGlassesController {
private const val GUARD_ACTIVITY =
"com.ffalconxr.mercury.launcher.wizard.netconfig.NetConfigActivity"
/** Наш пакет — не убиваем себя при «убить всё» (см. [forceStopAll]). */
private const val GLASSES_PACKAGE = "pw.binom.rayneovm.glasses"
/** Shizuku — не убиваем: через его UserService и выполняем force-stop. */
private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
/**
* RayNeo XR-runtime. Формально системный (`system_ext/priv-app`), но именно он держит
* открытой аппаратную камеру и жрёт ~100% CPU — поэтому его принудительно снимаем.
*/
private const val XR_RUNTIME_PACKAGE = "com.rayneo.xr.runtime"
/** Подстрока командной строки процессов-зеркал (scrcpy), которые тоже добиваем. */
private const val SCRCPY_PROCESS = "scrcpy"
private const val GUARD_DISPLAY_NAME = "VmKillGuard"
private const val GUARD_WIDTH = 320
private const val GUARD_HEIGHT = 240
@@ -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 written = t.write(pcm, 0, pcm.size)
if (written < 0) {
Log.w(TAG, "playing: AudioTrack.write вернул $written (size=${pcm.size})")
}
totalBytes += pcm.size
frames++
},
onConnected = { Log.i(TAG, "audio connected to $wsUrl") },
onDisconnected = { Log.w(TAG, "audio disconnected from $wsUrl") },
val decoded = try {
decoder.decode(
datagram,
AudioDatagram.HEADER_SIZE,
datagram.size - AudioDatagram.HEADER_SIZE,
pcmOut,
)
} catch (t: kotlinx.coroutines.CancellationException) {
Log.i(TAG, "start: stream job cancelled (stop)")
// Штатная остановка (stop()/смена режима) — не ошибка.
} catch (e: Throwable) {
Log.w(TAG, "opus decode failed: ${e.message}")
-1
}
if (decoded > 0) {
t.write(pcmOut, 0, decoded * AudioConfig.CHANNELS)
}
frames++
if (frames % 200 == 0) {
Log.i(TAG, "playing: frames=$frames lost=$lost lastSeq=$seq")
}
},
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: 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?,
+2 -2
View File
@@ -11,9 +11,9 @@ shizukuApi = "13.1.5"
shizukuProvider = "13.1.5"
navCompose = "2.10.2"
mercury = "0.1.1-SNAPSHOT"
mercuryTransport = "0.1.1-SNAPSHOT"
mercuryTransport = "0.1.2-SNAPSHOT"
mercuryBinocular = "0.1.0-SNAPSHOT"
mercuryShizuku = "0.1.0-SNAPSHOT"
mercuryShizuku = "0.1.1-SNAPSHOT"
[libraries]
# kotlinx
+4
View File
@@ -35,6 +35,10 @@ dependencies {
api(libs.mercury.transport)
// Concentus — чистый JVM-порт libopus (BSD-3), вендорнут в libs/ чтобы сборка не
// зависела от внешнего JitPack. Используется обёрткой pw.binom.rayneovm.shared.audio.OpusCodec.
implementation(files("libs/concentus.jar"))
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.junit.jupiter)
testImplementation(libs.kotlin.test) // need for kotlin.test.Test
Binary file not shown.
@@ -141,6 +141,18 @@ data class ScreenControl(
@SerialName("get_app_list")
data object GetAppList : VmMessage
/**
* «Убить всё лишнее» на очках: force-stop всех сторонних приложений + RayNeo XR-runtime
* (держит залипшую камеру и греет) + добить процессы-сироты вроде scrcpy.
*
* Выполняется на очках их Shizuku UserService (shell UID). Защищены от убийства: сам
* GlassesApp, Shizuku, все системные/привилегированные пакеты и текущий гость на
* VirtualDisplay.
*/
@Serializable
@SerialName("force_stop_all")
data object ForceStopAll : VmMessage
/** Просьба к очкам прислать текущее состояние Shizuku (см. [ShizukuStatus]). */
@Serializable
@SerialName("request_shizuku_status")
@@ -0,0 +1,46 @@
package pw.binom.rayneovm.shared.audio
/**
* Единые параметры аудио-тракта очки → телефон.
*
* Opus не поддерживает 44.1 кГц, поэтому весь тракт (захват, кодирование, вывод) работает
* на 48 кГц. Кадр 10 мс держит задержку маленькой, а сжатие Opus (~64 кбит/с против
* ~1.5 Мбит/с сырого PCM) уменьшает трафик примерно в 20 раз.
*/
object AudioConfig {
const val SAMPLE_RATE = 48_000
const val CHANNELS = 2
/** Длительность одного Opus-кадра. Компромисс: меньше — меньше задержка, больше накладные. */
const val FRAME_MS = 10
/** Сэмплов на канал в одном кадре (480 @ 10 мс / 48 кГц). */
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000
/** Всего сэмплов (interleaved) в кадре. */
const val FRAME_SHORTS = FRAME_SAMPLES * CHANNELS
/** Размер кадра PCM (interleaved 16-bit). */
const val FRAME_BYTES = FRAME_SHORTS * 2
const val BITRATE = 64_000
/**
* Сложность Opus-кодера. Чуть ниже максимума (10): Concentus — JVM-порт, а кодирует
* он на очках в реальном времени; при 8 качество на 64 кбит/с практически то же,
* зато есть запас по CPU.
*/
const val COMPLEXITY = 8
/** Ожидаемый процент потерь — для настройки inband FEC/PLC Opus. */
const val PACKET_LOSS_PERCENT = 10
/** UDP-порт аудио на очках (control-канал остаётся на TCP 8080). */
const val UDP_PORT = 8082
/** Верхняя граница размера датаграммы (Opus 10 мс stereo ≪ этого). */
const val MAX_DATAGRAM = 1500
/** Интервал keepalive-датаграмм от телефона: держит пира «живым» на UDP-сервере очков. */
const val KEEPALIVE_MS = 1_000L
}
@@ -0,0 +1,37 @@
package pw.binom.rayneovm.shared.audio
/**
* Формат аудио-датаграммы: `[4 байта seq big-endian][Opus payload]`.
*
* `seq` позволяет телефону детектить потери/дубли и понимать, что поток «жив», а не завис.
* Заголовок намеренно минимальный — каждые лишние байты это лишняя задержка.
*/
object AudioDatagram {
const val HEADER_SIZE = 4
const val MAX_PAYLOAD = AudioConfig.MAX_DATAGRAM - HEADER_SIZE
/**
* Кладёт [payload] в [out] с заголовком seq. Возвращает полную длину датаграммы.
* Буфер [out] должен быть не меньше `HEADER_SIZE + payloadLength`.
*/
fun pack(seq: Int, payload: ByteArray, payloadLength: Int, out: ByteArray): Int {
out[0] = (seq ushr 24).toByte()
out[1] = (seq ushr 16).toByte()
out[2] = (seq ushr 8).toByte()
out[3] = seq.toByte()
System.arraycopy(payload, 0, out, HEADER_SIZE, payloadLength)
return HEADER_SIZE + payloadLength
}
/** Читает seq из датаграммы; `-1`, если данных меньше заголовка (например keepalive). */
fun seq(data: ByteArray, length: Int = data.size): Int {
if (length < HEADER_SIZE) return -1
return ((data[0].toInt() and 0xFF) shl 24) or
((data[1].toInt() and 0xFF) shl 16) or
((data[2].toInt() and 0xFF) shl 8) or
(data[3].toInt() and 0xFF)
}
/** Есть ли в датаграмме вообще полезная нагрузка (не keepalive). */
fun hasPayload(data: ByteArray, length: Int = data.size): Boolean = length > HEADER_SIZE
}
@@ -0,0 +1,72 @@
package pw.binom.rayneovm.shared.audio
import org.concentus.OpusApplication
import org.concentus.OpusDecoder
import org.concentus.OpusEncoder
/**
* Обёртка над Concentus (чистый JVM-порт libopus, без NDK).
*
* Один экземпляр держит один Opus-поток, поэтому его нельзя использовать из нескольких
* потоков одновременно. Класс не потокобезопасен — вызывающий синхронизирует сам.
*/
class OpusAudioEncoder(
sampleRate: Int = AudioConfig.SAMPLE_RATE,
channels: Int = AudioConfig.CHANNELS,
bitrate: Int = AudioConfig.BITRATE,
complexity: Int = AudioConfig.COMPLEXITY,
) {
private val encoder = OpusEncoder(sampleRate, channels, OpusApplication.OPUS_APPLICATION_AUDIO).apply {
this.bitrate = bitrate
this.complexity = complexity
useVBR = true
useInbandFEC = true
packetLossPercent = AudioConfig.PACKET_LOSS_PERCENT
}
/** Переиспользуемый буфер вывода; копия отдаётся наружу только по факту кодирования. */
private val out = ByteArray(AudioConfig.MAX_DATAGRAM)
/**
* @param pcm interleaved 16-bit PCM (см. [AudioConfig.FRAME_SHORTS])
* @param offset смещение в [pcm]
* @param samplesPerChannel сэмплов на канал (см. [AudioConfig.FRAME_SAMPLES])
* @return закодированный Opus-пакет
*/
fun encode(pcm: ShortArray, offset: Int, samplesPerChannel: Int): ByteArray {
val n = encoder.encode(pcm, offset, samplesPerChannel, out, 0, out.size)
return out.copyOf(n)
}
}
/**
* Обёртка декодера Opus (Concentus). Не потокобезопасна.
*/
class OpusAudioDecoder(
sampleRate: Int = AudioConfig.SAMPLE_RATE,
channels: Int = AudioConfig.CHANNELS,
) {
private val decoder = OpusDecoder(sampleRate, channels)
/**
* @return число декодированных сэмплов на канал, либо отрицательное значение при ошибке.
*/
fun decode(
data: ByteArray,
offset: Int,
length: Int,
pcmOut: ShortArray,
frameSamplesPerChannel: Int = AudioConfig.FRAME_SAMPLES,
): Int = decoder.decode(data, offset, length, pcmOut, 0, frameSamplesPerChannel, false)
/**
* Packet Loss Concealment: синтезирует «заменитель» потерянного пакета, чтобы не было
* щелчка/тишины. Вызывать, когда обнаружен пропуск seq.
*
* @return число сэмплов на канал.
*/
fun decodeLost(
pcmOut: ShortArray,
frameSamplesPerChannel: Int = AudioConfig.FRAME_SAMPLES,
): Int = decoder.decode(null, 0, 0, pcmOut, 0, frameSamplesPerChannel, false)
}
@@ -0,0 +1,88 @@
package pw.binom.rayneovm.shared.audio
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class OpusCodecTest {
@Test
fun `opus roundtrip compresses and decodes the expected frame`() {
val encoder = OpusAudioEncoder()
val decoder = OpusAudioDecoder()
val pcm = ShortArray(AudioConfig.FRAME_SHORTS)
val out = ShortArray(AudioConfig.FRAME_SHORTS)
var totalEncoded = 0
val frames = 10
for (f in 0 until frames) {
// 1 кГц синус, одинаковый в обоих каналах
for (i in 0 until AudioConfig.FRAME_SAMPLES) {
val v = (Math.sin(2 * Math.PI * 1000 * i / AudioConfig.SAMPLE_RATE) * 12000).toInt().toShort()
pcm[i * 2] = v
pcm[i * 2 + 1] = v
}
val payload = encoder.encode(pcm, 0, AudioConfig.FRAME_SAMPLES)
assertTrue(payload.isNotEmpty(), "encoded payload пустой")
assertTrue(payload.size < AudioConfig.FRAME_BYTES, "Opus обязан сжимать PCM")
totalEncoded += payload.size
val decoded = decoder.decode(payload, 0, payload.size, out)
assertEquals(AudioConfig.FRAME_SAMPLES, decoded)
}
assertTrue(
totalEncoded < frames * AudioConfig.FRAME_BYTES,
"трафик Opus должен быть меньше сырого PCM",
)
}
@Test
fun `packet loss concealment produces a frame`() {
val encoder = OpusAudioEncoder()
val decoder = OpusAudioDecoder()
val pcm = ShortArray(AudioConfig.FRAME_SHORTS)
// «прогреваем» декодер одним реальным пакетом, затем прячем потерю
for (i in 0 until AudioConfig.FRAME_SAMPLES) {
val v = (Math.sin(2 * Math.PI * 440 * i / AudioConfig.SAMPLE_RATE) * 8000).toInt().toShort()
pcm[i * 2] = v
pcm[i * 2 + 1] = v
}
val payload = encoder.encode(pcm, 0, AudioConfig.FRAME_SAMPLES)
decoder.decode(payload, 0, payload.size, ShortArray(AudioConfig.FRAME_SHORTS))
val out = ShortArray(AudioConfig.FRAME_SHORTS)
val concealed = decoder.decodeLost(out)
assertEquals(AudioConfig.FRAME_SAMPLES, concealed)
}
@Test
fun `keepalive datagram has no payload and seq -1`() {
val keepAlive = ByteArray(0)
assertEquals(-1, AudioDatagram.seq(keepAlive))
assertTrue(!AudioDatagram.hasPayload(keepAlive))
}
@Test
fun `datagram pack and seq roundtrip`() {
val payload = byteArrayOf(1, 2, 3, 4, 5)
val out = ByteArray(AudioConfig.MAX_DATAGRAM)
val seq = 0x01020304
val len = AudioDatagram.pack(seq, payload, payload.size, out)
assertEquals(AudioDatagram.HEADER_SIZE + payload.size, len)
assertEquals(seq, AudioDatagram.seq(out, len))
assertTrue(AudioDatagram.hasPayload(out, len))
assertEquals(1, out[AudioDatagram.HEADER_SIZE].toInt())
assertEquals(5, out[AudioDatagram.HEADER_SIZE + 4].toInt())
}
@Test
fun `negative seq survives the roundtrip`() {
val out = ByteArray(AudioConfig.MAX_DATAGRAM)
val len = AudioDatagram.pack(-1, byteArrayOf(9), 1, out)
assertEquals(-1, AudioDatagram.seq(out, len))
}
}