diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt index 3767ca1..75e22e2 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt @@ -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" } } diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt index 78a5e70..0b3c0ab 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt @@ -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( - capacity = 16, + private val audioFrames = Channel( + 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 diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/GlassesAudioCapture.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/GlassesAudioCapture.kt index 7fd08e5..315ef63 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/GlassesAudioCapture.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/GlassesAudioCapture.kt @@ -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) { - 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()}") + filled += read + if (filled == frame.size) { + try { + sink(frame.copyOf()) + } catch (t: Throwable) { + Log.w(TAG, "sink упал: ${t.message}") + } + totalSamples += filled + frameCount++ + 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 } } diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/input/TempleGesture.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/input/TempleGesture.kt new file mode 100644 index 0000000..b3bb8de --- /dev/null +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/input/TempleGesture.kt @@ -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 + } +} diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt index c89e174..56bf92b 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt @@ -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 diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt index 042252d..3f41638 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt @@ -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" } } diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt index 8860d9b..b73aa93 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt @@ -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 diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt index 89db5ec..d0c0d0b 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt @@ -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(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?, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc31994..e48259d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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 diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 595e3f5..a8905ca 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -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 diff --git a/shared/libs/concentus.jar b/shared/libs/concentus.jar new file mode 100644 index 0000000..adc87c5 Binary files /dev/null and b/shared/libs/concentus.jar differ diff --git a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt index 0e1ed50..d2cb95a 100644 --- a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt +++ b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt @@ -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") diff --git a/shared/src/main/kotlin/pw/binom/rayneovm/shared/audio/AudioConfig.kt b/shared/src/main/kotlin/pw/binom/rayneovm/shared/audio/AudioConfig.kt new file mode 100644 index 0000000..528c8ae --- /dev/null +++ b/shared/src/main/kotlin/pw/binom/rayneovm/shared/audio/AudioConfig.kt @@ -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 +} diff --git a/shared/src/main/kotlin/pw/binom/rayneovm/shared/audio/AudioDatagram.kt b/shared/src/main/kotlin/pw/binom/rayneovm/shared/audio/AudioDatagram.kt new file mode 100644 index 0000000..225ec37 --- /dev/null +++ b/shared/src/main/kotlin/pw/binom/rayneovm/shared/audio/AudioDatagram.kt @@ -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 +} diff --git a/shared/src/main/kotlin/pw/binom/rayneovm/shared/audio/OpusCodec.kt b/shared/src/main/kotlin/pw/binom/rayneovm/shared/audio/OpusCodec.kt new file mode 100644 index 0000000..bd81e22 --- /dev/null +++ b/shared/src/main/kotlin/pw/binom/rayneovm/shared/audio/OpusCodec.kt @@ -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) +} diff --git a/shared/src/test/kotlin/pw/binom/rayneovm/shared/audio/OpusCodecTest.kt b/shared/src/test/kotlin/pw/binom/rayneovm/shared/audio/OpusCodecTest.kt new file mode 100644 index 0000000..52f58ea --- /dev/null +++ b/shared/src/test/kotlin/pw/binom/rayneovm/shared/audio/OpusCodecTest.kt @@ -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)) + } +}