аудио 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:
@@ -7,6 +7,8 @@ import android.media.projection.MediaProjectionManager
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Display
|
import android.view.Display
|
||||||
|
import android.view.InputDevice
|
||||||
|
import android.view.MotionEvent
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
@@ -29,6 +31,7 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import pw.binom.rayneovm.glasses.audio.AudioBridge
|
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.protocol.VmGlassesController
|
||||||
import pw.binom.rayneovm.glasses.ui.GlassesScreen
|
import pw.binom.rayneovm.glasses.ui.GlassesScreen
|
||||||
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
|
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
|
||||||
@@ -38,6 +41,26 @@ class MainActivity : ComponentActivity() {
|
|||||||
/** Последний обработанный tick запроса проекции (см. onCreate). */
|
/** Последний обработанный tick запроса проекции (см. onCreate). */
|
||||||
private var handledTick = 0L
|
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`
|
* Пользовательский диалог MediaProjection. При выставленном appop `PROJECT_MEDIA = allow`
|
||||||
* он не показывается — интент сразу возвращает RESULT_OK (нужно для задачи #2).
|
* он не показывается — интент сразу возвращает RESULT_OK (нужно для задачи #2).
|
||||||
@@ -71,6 +94,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
ShizukuBootstrap.attach(this)
|
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).
|
// Запросы MediaProjection приходят из VmService через AudioBridge (задача #2).
|
||||||
// Запускаем интент только когда Activity в RESUMED (реально видима): если её запускать
|
// Запускаем интент только когда Activity в RESUMED (реально видима): если её запускать
|
||||||
// из не-foreground состояния, системная grant-активити отменяется и возвращает CANCELED.
|
// из не-foreground состояния, системная grant-активити отменяется и возвращает CANCELED.
|
||||||
@@ -89,6 +119,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val controller = VmGlassesController.fromContext(this)
|
val controller = VmGlassesController.fromContext(this)
|
||||||
|
this.controller = controller
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
CompositionLocalProvider(VmGlassesController.Local provides controller) {
|
CompositionLocalProvider(VmGlassesController.Local provides controller) {
|
||||||
@@ -116,6 +147,29 @@ class MainActivity : ComponentActivity() {
|
|||||||
ShizukuBootstrap.refresh()
|
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() {
|
override fun onPause() {
|
||||||
AudioBridge.activityResumed = false
|
AudioBridge.activityResumed = false
|
||||||
super.onPause()
|
super.onPause()
|
||||||
@@ -142,6 +196,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MainActivity"
|
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.VmMessage
|
||||||
import pw.binom.rayneovm.shared.VmProtocol
|
import pw.binom.rayneovm.shared.VmProtocol
|
||||||
import pw.binom.rayneovm.shared.GetAppList
|
import pw.binom.rayneovm.shared.GetAppList
|
||||||
|
import pw.binom.rayneovm.shared.ForceStopAll
|
||||||
import pw.binom.rayneovm.shared.AppEntry
|
import pw.binom.rayneovm.shared.AppEntry
|
||||||
import pw.binom.rayneovm.shared.AppListResponse
|
import pw.binom.rayneovm.shared.AppListResponse
|
||||||
import pw.binom.rayneovm.shared.CursorScroll
|
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.RequestShizukuStatus
|
||||||
import pw.binom.rayneovm.shared.ScreenControl
|
import pw.binom.rayneovm.shared.ScreenControl
|
||||||
import pw.binom.rayneovm.shared.ShizukuStatus
|
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 на стороне очков.
|
* Foreground service на стороне очков.
|
||||||
@@ -73,16 +77,22 @@ class VmService : LifecycleService() {
|
|||||||
private var audioCapture: GlassesAudioCapture? = null
|
private var audioCapture: GlassesAudioCapture? = null
|
||||||
private var audioEnsureJob: kotlinx.coroutines.Job? = null
|
private var audioEnsureJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
|
/** Opus-кодер: используется только из аудио-пампа, поэтому не нужна синхронизация. */
|
||||||
|
private val audioEncoder = OpusAudioEncoder()
|
||||||
|
|
||||||
|
/** seq аудио-датаграмм; оборачивается через переполнение Int — это нормально. */
|
||||||
|
private var audioSeq = 0
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var audioMode: AudioMode = AudioMode.ON_GLASSES
|
private var audioMode: AudioMode = AudioMode.ON_GLASSES
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Очередь кадров PCM между потоком чтения [AudioRecord] и отправкой в сокет.
|
* Очередь кадров PCM (interleaved shorts 48 кГц stereo) между потоком чтения
|
||||||
* При переполнении выкидываем самый старый кадр — для live-аудио важнее свежесть,
|
* [AudioRecord] и Opus-кодером. Крошечная ёмкость + DROP_OLDEST: для live-аудио
|
||||||
* чем полнота (иначе задержка растёт безгранично).
|
* важнее свежесть, чем полнота — иначе задержка растёт безгранично.
|
||||||
*/
|
*/
|
||||||
private val audioFrames = Channel<ByteArray>(
|
private val audioFrames = Channel<ShortArray>(
|
||||||
capacity = 16,
|
capacity = 4,
|
||||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -230,6 +240,10 @@ class VmService : LifecycleService() {
|
|||||||
Log.i(TAG, "Shutdown")
|
Log.i(TAG, "Shutdown")
|
||||||
controller.stopGuest()
|
controller.stopGuest()
|
||||||
}
|
}
|
||||||
|
is ForceStopAll -> {
|
||||||
|
Log.i(TAG, "ForceStopAll")
|
||||||
|
controller.forceStopAll()
|
||||||
|
}
|
||||||
is GetAppList -> {
|
is GetAppList -> {
|
||||||
Log.i(TAG, "GetAppList")
|
Log.i(TAG, "GetAppList")
|
||||||
val apps = listInstalledLaunchableApps()
|
val apps = listInstalledLaunchableApps()
|
||||||
@@ -250,20 +264,26 @@ class VmService : LifecycleService() {
|
|||||||
|
|
||||||
// ---------------- Аудио-мост (задача #2) ----------------
|
// ---------------- Аудио-мост (задача #2) ----------------
|
||||||
|
|
||||||
/** Сервер байтового канала `/vm-audio`: телефон подключается сюда за PCM-потоком. */
|
/**
|
||||||
|
* UDP-сервер аудио. Телефон шлёт сюда keepalive/подписку, мы запоминаем адрес
|
||||||
|
* источника (`ip:port`) как пира и отвечаем Opus-датаграммами.
|
||||||
|
*
|
||||||
|
* IP телефона НЕ константа — он берётся из адреса входящей датаграммы. В реальной жизни
|
||||||
|
* очки подключены к точке доступа самого телефона, адреса меняются от сессии к сессии.
|
||||||
|
*/
|
||||||
private fun startAudioServer() {
|
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) {
|
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
audioServer!!.accept(
|
audioServer!!.accept(
|
||||||
onMessage = { _, _ -> /* телефон в этот канал ничего не шлёт */ },
|
onMessage = { _, _ -> /* keepalive/подписка: содержимое не важно, важен источник */ },
|
||||||
onConnected = { connId ->
|
onConnected = { connId ->
|
||||||
audioSessions.add(connId)
|
audioSessions.add(connId)
|
||||||
Log.i(TAG, "Audio client connected: $connId")
|
Log.i(TAG, "Audio peer connected: $connId")
|
||||||
},
|
},
|
||||||
onDisconnected = { connId ->
|
onDisconnected = { connId ->
|
||||||
audioSessions.remove(connId)
|
audioSessions.remove(connId)
|
||||||
Log.i(TAG, "Audio client disconnected: $connId")
|
Log.i(TAG, "Audio peer disconnected: $connId")
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
@@ -272,14 +292,24 @@ class VmService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Разбирает очередь кадров и рассылает их всем подключённым аудио-клиентам. */
|
/** Кодирует кадры PCM в Opus и рассылает датаграммы всем подключённым аудио-пирам. */
|
||||||
private fun startAudioPump() {
|
private fun startAudioPump() {
|
||||||
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||||
for (bytes in audioFrames) {
|
val packet = ByteArray(AudioConfig.MAX_DATAGRAM)
|
||||||
|
for (pcm in audioFrames) {
|
||||||
val server = audioServer ?: continue
|
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) {
|
for (connId in audioSessions) {
|
||||||
try {
|
try {
|
||||||
server.send(connId, bytes)
|
server.send(connId, datagram)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Log.w(TAG, "audio send failed: ${t.message}")
|
Log.w(TAG, "audio send failed: ${t.message}")
|
||||||
}
|
}
|
||||||
@@ -538,10 +568,6 @@ class VmService : LifecycleService() {
|
|||||||
const val WS_PORT = 8080
|
const val WS_PORT = 8080
|
||||||
const val WS_PATH = "/vm"
|
const val WS_PATH = "/vm"
|
||||||
|
|
||||||
/** Отдельный порт/путь для PCM-аудио (задача #2). Телефон знает их по константе. */
|
|
||||||
const val AUDIO_PORT = 8081
|
|
||||||
const val AUDIO_PATH = "/vm-audio"
|
|
||||||
|
|
||||||
/** Интервал опроса готовности аудио-захвата (пока режим ON_PHONE). */
|
/** Интервал опроса готовности аудио-захвата (пока режим ON_PHONE). */
|
||||||
private const val AUDIO_ENSURE_MS = 2_500L
|
private const val AUDIO_ENSURE_MS = 2_500L
|
||||||
|
|
||||||
|
|||||||
+28
-24
@@ -11,21 +11,25 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import pw.binom.rayneovm.shared.audio.AudioConfig
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Захват системного аудио очков через [AudioPlaybackCapture] (задача #2).
|
* Захват системного аудио очков через [AudioPlaybackCapture] (задача #2).
|
||||||
*
|
*
|
||||||
* Требует [MediaProjection] (см. [AudioBridge]) и appop `PROJECT_MEDIA = allow`.
|
* Требует [MediaProjection] (см. [AudioBridge]) и appop `PROJECT_MEDIA = allow`.
|
||||||
*
|
*
|
||||||
* Собранные PCM-байты отдаются в [sink] на `Dispatchers.IO`. Раз в ~1 c пишется RMS
|
* Пишет 48 кГц stereo 16-bit кадрами ровно по [AudioConfig.FRAME_MS] мс и отдаёт их
|
||||||
* в лог: `0 = тишина` (источник молчит), `>0 = реальный звук`.
|
* в [sink] на `Dispatchers.IO` как `ShortArray` (interleaved). Кадр фиксированного
|
||||||
|
* размера нужен Opus-кодеру: он кодирует ровно [AudioConfig.FRAME_SAMPLES] сэмплов/канал.
|
||||||
|
*
|
||||||
|
* Раз в ~1 c пишется RMS в лог: `0 = тишина` (источник молчит), `>0 = реальный звук`.
|
||||||
*
|
*
|
||||||
* Если [onStopped] не `null`, вызывается когда цикл захвата умер сам
|
* Если [onStopped] не `null`, вызывается когда цикл захвата умер сам
|
||||||
* (например, [AudioRecord.read] вернул ошибку) и нужно пере-стартовать.
|
* (например, [AudioRecord.read] вернул ошибку) и нужно пере-стартовать.
|
||||||
*/
|
*/
|
||||||
class GlassesAudioCapture(
|
class GlassesAudioCapture(
|
||||||
private val projection: MediaProjection,
|
private val projection: MediaProjection,
|
||||||
private val sink: suspend (ByteArray) -> Unit,
|
private val sink: suspend (ShortArray) -> Unit,
|
||||||
/** Вызывается, если цикл чтения завершился сам (ошибка [AudioRecord]) — не при [stop]. */
|
/** Вызывается, если цикл чтения завершился сам (ошибка [AudioRecord]) — не при [stop]. */
|
||||||
private val onStopped: (() -> Unit)? = null,
|
private val onStopped: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
@@ -35,7 +39,7 @@ class GlassesAudioCapture(
|
|||||||
|
|
||||||
fun start(scope: CoroutineScope): Boolean {
|
fun start(scope: CoroutineScope): Boolean {
|
||||||
if (running) return true
|
if (running) return true
|
||||||
val sampleRate = SAMPLE_RATE
|
val sampleRate = AudioConfig.SAMPLE_RATE
|
||||||
val channelMask = AudioFormat.CHANNEL_IN_STEREO
|
val channelMask = AudioFormat.CHANNEL_IN_STEREO
|
||||||
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
||||||
|
|
||||||
@@ -44,7 +48,9 @@ class GlassesAudioCapture(
|
|||||||
Log.w(TAG, "getMinBufferSize=$minBuf")
|
Log.w(TAG, "getMinBufferSize=$minBuf")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val bufferSize = minBuf * 4
|
// Небольшой буфер = меньшая задержка. Берём минимум HAL, но не меньше двух кадров,
|
||||||
|
// чтобы мгновенный джиттер планировщика не приводил к overrun/потере сэмплов.
|
||||||
|
val bufferSize = maxOf(minBuf, AudioConfig.FRAME_BYTES * 2)
|
||||||
|
|
||||||
val rec = try {
|
val rec = try {
|
||||||
val config = AudioPlaybackCaptureConfiguration.Builder(projection)
|
val config = AudioPlaybackCaptureConfiguration.Builder(projection)
|
||||||
@@ -78,21 +84,26 @@ class GlassesAudioCapture(
|
|||||||
running = true
|
running = true
|
||||||
rec.startRecording()
|
rec.startRecording()
|
||||||
job = scope.launch(Dispatchers.IO) {
|
job = scope.launch(Dispatchers.IO) {
|
||||||
val buf = ByteArray(FRAME_BYTES)
|
val frame = ShortArray(AudioConfig.FRAME_SHORTS)
|
||||||
|
var filled = 0
|
||||||
var frameCount = 0
|
var frameCount = 0
|
||||||
var totalBytes = 0L
|
var totalSamples = 0L
|
||||||
while (isActive && running) {
|
while (isActive && running) {
|
||||||
val read = rec.read(buf, 0, buf.size)
|
val read = rec.read(frame, filled, frame.size - filled)
|
||||||
if (read > 0) {
|
if (read > 0) {
|
||||||
|
filled += read
|
||||||
|
if (filled == frame.size) {
|
||||||
try {
|
try {
|
||||||
sink(buf.copyOf(read))
|
sink(frame.copyOf())
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Log.w(TAG, "sink упал: ${t.message}")
|
Log.w(TAG, "sink упал: ${t.message}")
|
||||||
}
|
}
|
||||||
totalBytes += read
|
totalSamples += filled
|
||||||
frameCount++
|
frameCount++
|
||||||
if (frameCount % 40 == 0) {
|
if (frameCount % 100 == 0) {
|
||||||
Log.i(TAG, "capture: bytes=$totalBytes rms=${rms16(buf, read).toInt()}")
|
Log.i(TAG, "capture: samples=$totalSamples rms=${rms16(frame).toInt()}")
|
||||||
|
}
|
||||||
|
filled = 0
|
||||||
}
|
}
|
||||||
} else if (read < 0) {
|
} else if (read < 0) {
|
||||||
Log.w(TAG, "AudioRecord.read ошибка: $read")
|
Log.w(TAG, "AudioRecord.read ошибка: $read")
|
||||||
@@ -105,7 +116,7 @@ class GlassesAudioCapture(
|
|||||||
onStopped?.invoke()
|
onStopped?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.i(TAG, "AudioPlaybackCapture запущен: $sampleRate Гц stereo, buffer=$bufferSize")
|
Log.i(TAG, "AudioPlaybackCapture запущен: $sampleRate Гц stereo, кадр=${AudioConfig.FRAME_MS}мс, buffer=$bufferSize")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,23 +137,16 @@ class GlassesAudioCapture(
|
|||||||
Log.i(TAG, "AudioPlaybackCapture остановлен (проекцию сохраняю)")
|
Log.i(TAG, "AudioPlaybackCapture остановлен (проекцию сохраняю)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rms16(buf: ByteArray, len: Int): Double {
|
private fun rms16(buf: ShortArray): Double {
|
||||||
if (len < 2) return 0.0
|
if (buf.isEmpty()) return 0.0
|
||||||
var sumSq = 0.0
|
var sumSq = 0.0
|
||||||
var i = 0
|
for (s in buf) {
|
||||||
while (i + 1 < len) {
|
|
||||||
val s = ((buf[i].toInt() and 0xFF) or (buf[i + 1].toInt() shl 8)).toShort().toInt()
|
|
||||||
sumSq += s.toDouble() * s
|
sumSq += s.toDouble() * s
|
||||||
i += 2
|
|
||||||
}
|
}
|
||||||
return Math.sqrt(sumSq / (len / 2))
|
return Math.sqrt(sumSq / buf.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "GlassesAudioCapture"
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
@@ -27,6 +27,7 @@ import pw.binom.mercury.binocular.FrameSource
|
|||||||
import pw.binom.mercury.shizuku.ShizukuVdBinder
|
import pw.binom.mercury.shizuku.ShizukuVdBinder
|
||||||
import pw.binom.mercury.shizuku.ShizukuVdClient
|
import pw.binom.mercury.shizuku.ShizukuVdClient
|
||||||
import pw.binom.rayneovm.glasses.VmShizukuService
|
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.shizuku.VirtualDisplayFrameSource
|
||||||
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
|
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
|
||||||
import pw.binom.rayneovm.shared.VmDisplay
|
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].
|
* DEBUG: запустить гостя напрямую (без Compose-UI) — то же, что и [launchGuest].
|
||||||
* Используется для тестирования через adb broadcast.
|
* Используется для тестирования через 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].
|
* Force-stop активного гостя, дисплей НЕ трогает. Вызывать под [acquireMutex].
|
||||||
*
|
*
|
||||||
@@ -619,6 +700,21 @@ class VmGlassesController {
|
|||||||
private const val GUARD_ACTIVITY =
|
private const val GUARD_ACTIVITY =
|
||||||
"com.ffalconxr.mercury.launcher.wizard.netconfig.NetConfigActivity"
|
"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_DISPLAY_NAME = "VmKillGuard"
|
||||||
private const val GUARD_WIDTH = 320
|
private const val GUARD_WIDTH = 320
|
||||||
private const val GUARD_HEIGHT = 240
|
private const val GUARD_HEIGHT = 240
|
||||||
|
|||||||
+75
-46
@@ -9,17 +9,25 @@ import android.os.PowerManager
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import pw.binom.mercury.transport.ByteTransport
|
import pw.binom.mercury.transport.ByteTransport
|
||||||
import pw.binom.mercury.transport.Transports
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
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.phone.protocol.VmPhoneController] при получении
|
||||||
* [pw.binom.rayneovm.shared.SetAudioMode] с режимом ON_PHONE.
|
* [pw.binom.rayneovm.shared.SetAudioMode] с режимом ON_PHONE.
|
||||||
@@ -30,9 +38,16 @@ class AudioPlaybackManager(private val context: Context) {
|
|||||||
private var track: AudioTrack? = null
|
private var track: AudioTrack? = null
|
||||||
private var transport: ByteTransport? = null
|
private var transport: ByteTransport? = null
|
||||||
private var streamJob: Job? = null
|
private var streamJob: Job? = null
|
||||||
|
private var keepAliveJob: Job? = null
|
||||||
private var wifiLock: WifiManager.WifiLock? = null
|
private var wifiLock: WifiManager.WifiLock? = null
|
||||||
private var wakeLock: PowerManager.WakeLock? = 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) {
|
fun start(glassesHost: String) {
|
||||||
if (track != null) {
|
if (track != null) {
|
||||||
Log.w(TAG, "start: уже running — пропускаю (host=$glassesHost)")
|
Log.w(TAG, "start: уже running — пропускаю (host=$glassesHost)")
|
||||||
@@ -40,12 +55,12 @@ class AudioPlaybackManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
Log.i(TAG, "start: host=$glassesHost")
|
Log.i(TAG, "start: host=$glassesHost")
|
||||||
|
|
||||||
val sampleRate = SAMPLE_RATE
|
val sampleRate = AudioConfig.SAMPLE_RATE
|
||||||
val channelMask = AudioFormat.CHANNEL_OUT_STEREO
|
val channelMask = AudioFormat.CHANNEL_OUT_STEREO
|
||||||
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
||||||
|
|
||||||
val minBuffer = AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding)
|
val minBuffer = AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding)
|
||||||
val bufferSize = minBuffer * 4
|
val bufferSize = maxOf(minBuffer, AudioConfig.FRAME_BYTES)
|
||||||
|
|
||||||
track = AudioTrack.Builder()
|
track = AudioTrack.Builder()
|
||||||
.setAudioAttributes(
|
.setAudioAttributes(
|
||||||
@@ -63,9 +78,10 @@ class AudioPlaybackManager(private val context: Context) {
|
|||||||
)
|
)
|
||||||
.setBufferSizeInBytes(bufferSize)
|
.setBufferSizeInBytes(bufferSize)
|
||||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||||
|
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
|
||||||
.build()
|
.build()
|
||||||
track!!.play()
|
track!!.play()
|
||||||
Log.i(TAG, "AudioTrack started: $sampleRate Hz, buffer=$bufferSize")
|
Log.i(TAG, "AudioTrack started: $sampleRate Hz stereo, buffer=$bufferSize")
|
||||||
|
|
||||||
// WiFi + Wake lock
|
// WiFi + Wake lock
|
||||||
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||||
@@ -75,43 +91,72 @@ class AudioPlaybackManager(private val context: Context) {
|
|||||||
acquire(60 * 60 * 1000L)
|
acquire(60 * 60 * 1000L)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Подключаемся к очкам по тому же ws://host:port/vm-audio
|
running = true
|
||||||
val wsUrl = "ws://$glassesHost:${GLASSES_PORT}/vm-audio"
|
transport = Transports.udp(glassesHost, AudioConfig.UDP_PORT)
|
||||||
Log.i(TAG, "start: подключаюсь к $wsUrl")
|
val subscribe = ByteArray(0)
|
||||||
transport = Transports.wifiBytes(wsUrl, autoReconnect = true)
|
|
||||||
|
|
||||||
streamJob = scope.launch {
|
streamJob = scope.launch {
|
||||||
var totalBytes = 0L
|
|
||||||
var frames = 0
|
var frames = 0
|
||||||
|
var lastSeq = -1
|
||||||
|
var lost = 0
|
||||||
try {
|
try {
|
||||||
transport!!.connect(
|
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
|
val t = track ?: return@connect
|
||||||
if (frames == 0 || frames % 40 == 0) {
|
val decoded = try {
|
||||||
Log.i(TAG, "playing: frame#=$frames size=${pcm.size} bytes=$totalBytes rms=${rms16(pcm).toInt()}")
|
decoder.decode(
|
||||||
}
|
datagram,
|
||||||
val written = t.write(pcm, 0, pcm.size)
|
AudioDatagram.HEADER_SIZE,
|
||||||
if (written < 0) {
|
datagram.size - AudioDatagram.HEADER_SIZE,
|
||||||
Log.w(TAG, "playing: AudioTrack.write вернул $written (size=${pcm.size})")
|
pcmOut,
|
||||||
}
|
|
||||||
totalBytes += pcm.size
|
|
||||||
frames++
|
|
||||||
},
|
|
||||||
onConnected = { Log.i(TAG, "audio connected to $wsUrl") },
|
|
||||||
onDisconnected = { Log.w(TAG, "audio disconnected from $wsUrl") },
|
|
||||||
)
|
)
|
||||||
} catch (t: kotlinx.coroutines.CancellationException) {
|
} catch (e: Throwable) {
|
||||||
Log.i(TAG, "start: stream job cancelled (stop)")
|
Log.w(TAG, "opus decode failed: ${e.message}")
|
||||||
// Штатная остановка (stop()/смена режима) — не ошибка.
|
-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) {
|
} catch (t: Throwable) {
|
||||||
Log.e(TAG, "audio transport failed", t)
|
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() {
|
fun stop() {
|
||||||
Log.i(TAG, "stop: track=${track != null} transport=${transport != null}")
|
Log.i(TAG, "stop: track=${track != null} transport=${transport != null}")
|
||||||
|
running = false
|
||||||
|
keepAliveJob?.cancel(); keepAliveJob = null
|
||||||
streamJob?.cancel(); streamJob = null
|
streamJob?.cancel(); streamJob = null
|
||||||
transport?.close(); transport = null
|
transport?.close(); transport = null
|
||||||
track?.stop(); track?.release(); track = null
|
track?.stop(); track?.release(); track = null
|
||||||
@@ -120,23 +165,7 @@ class AudioPlaybackManager(private val context: Context) {
|
|||||||
Log.i(TAG, "AudioPlaybackManager stopped")
|
Log.i(TAG, "AudioPlaybackManager stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rms16(buf: ByteArray): Double {
|
private companion object {
|
||||||
if (buf.size < 2) return 0.0
|
const val TAG = "AudioPlaybackMgr"
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import pw.binom.rayneovm.shared.AppLaunchedAck
|
|||||||
import pw.binom.rayneovm.shared.CursorClick
|
import pw.binom.rayneovm.shared.CursorClick
|
||||||
import pw.binom.rayneovm.shared.CursorMove
|
import pw.binom.rayneovm.shared.CursorMove
|
||||||
import pw.binom.rayneovm.shared.CursorScroll
|
import pw.binom.rayneovm.shared.CursorScroll
|
||||||
|
import pw.binom.rayneovm.shared.ForceStopAll
|
||||||
import pw.binom.rayneovm.shared.GetAppList
|
import pw.binom.rayneovm.shared.GetAppList
|
||||||
import pw.binom.rayneovm.shared.GlassesStatus
|
import pw.binom.rayneovm.shared.GlassesStatus
|
||||||
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
|
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
|
||||||
@@ -190,6 +191,14 @@ class VmPhoneController(
|
|||||||
send(RequestShizukuStatus)
|
send(RequestShizukuStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Убить всё лишнее» на очках: force-stop сторонних приложений + RayNeo XR-runtime
|
||||||
|
* (греющая камера) + процессы-сироты вроде scrcpy. Наши приложения и системное не трогает.
|
||||||
|
*/
|
||||||
|
suspend fun forceStopAll() {
|
||||||
|
send(ForceStopAll)
|
||||||
|
}
|
||||||
|
|
||||||
/** Отправляет сообщение очкам. */
|
/** Отправляет сообщение очкам. */
|
||||||
suspend fun send(msg: VmMessage) {
|
suspend fun send(msg: VmMessage) {
|
||||||
val t = currentTransport ?: return
|
val t = currentTransport ?: return
|
||||||
|
|||||||
@@ -173,6 +173,11 @@ fun GlassesStatusScreen(
|
|||||||
onConnect = { ssid, password -> mercuryController.connectWifi(ssid, password) },
|
onConnect = { ssid, password -> mercuryController.connectWifi(ssid, password) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ForceStopSection(
|
||||||
|
enabled = connection is ConnectionState.Connected,
|
||||||
|
onForceStop = { controller.forceStopAll() },
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
LaunchGlassesAppButton(
|
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
|
@Composable
|
||||||
private fun GlassesWifiSection(
|
private fun GlassesWifiSection(
|
||||||
status: GeneralStatus?,
|
status: GeneralStatus?,
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ shizukuApi = "13.1.5"
|
|||||||
shizukuProvider = "13.1.5"
|
shizukuProvider = "13.1.5"
|
||||||
navCompose = "2.10.2"
|
navCompose = "2.10.2"
|
||||||
mercury = "0.1.1-SNAPSHOT"
|
mercury = "0.1.1-SNAPSHOT"
|
||||||
mercuryTransport = "0.1.1-SNAPSHOT"
|
mercuryTransport = "0.1.2-SNAPSHOT"
|
||||||
mercuryBinocular = "0.1.0-SNAPSHOT"
|
mercuryBinocular = "0.1.0-SNAPSHOT"
|
||||||
mercuryShizuku = "0.1.0-SNAPSHOT"
|
mercuryShizuku = "0.1.1-SNAPSHOT"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# kotlinx
|
# kotlinx
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ dependencies {
|
|||||||
|
|
||||||
api(libs.mercury.transport)
|
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.kotlinx.coroutines.test)
|
||||||
testImplementation(libs.junit.jupiter)
|
testImplementation(libs.junit.jupiter)
|
||||||
testImplementation(libs.kotlin.test) // need for kotlin.test.Test
|
testImplementation(libs.kotlin.test) // need for kotlin.test.Test
|
||||||
|
|||||||
Binary file not shown.
@@ -141,6 +141,18 @@ data class ScreenControl(
|
|||||||
@SerialName("get_app_list")
|
@SerialName("get_app_list")
|
||||||
data object GetAppList : VmMessage
|
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]). */
|
/** Просьба к очкам прислать текущее состояние Shizuku (см. [ShizukuStatus]). */
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("request_shizuku_status")
|
@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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user