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