аудио 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:
@@ -141,6 +141,18 @@ data class ScreenControl(
|
||||
@SerialName("get_app_list")
|
||||
data object GetAppList : VmMessage
|
||||
|
||||
/**
|
||||
* «Убить всё лишнее» на очках: force-stop всех сторонних приложений + RayNeo XR-runtime
|
||||
* (держит залипшую камеру и греет) + добить процессы-сироты вроде scrcpy.
|
||||
*
|
||||
* Выполняется на очках их Shizuku UserService (shell UID). Защищены от убийства: сам
|
||||
* GlassesApp, Shizuku, все системные/привилегированные пакеты и текущий гость на
|
||||
* VirtualDisplay.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("force_stop_all")
|
||||
data object ForceStopAll : VmMessage
|
||||
|
||||
/** Просьба к очкам прислать текущее состояние Shizuku (см. [ShizukuStatus]). */
|
||||
@Serializable
|
||||
@SerialName("request_shizuku_status")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package pw.binom.rayneovm.shared.audio
|
||||
|
||||
/**
|
||||
* Единые параметры аудио-тракта очки → телефон.
|
||||
*
|
||||
* Opus не поддерживает 44.1 кГц, поэтому весь тракт (захват, кодирование, вывод) работает
|
||||
* на 48 кГц. Кадр 10 мс держит задержку маленькой, а сжатие Opus (~64 кбит/с против
|
||||
* ~1.5 Мбит/с сырого PCM) уменьшает трафик примерно в 20 раз.
|
||||
*/
|
||||
object AudioConfig {
|
||||
const val SAMPLE_RATE = 48_000
|
||||
const val CHANNELS = 2
|
||||
|
||||
/** Длительность одного Opus-кадра. Компромисс: меньше — меньше задержка, больше накладные. */
|
||||
const val FRAME_MS = 10
|
||||
|
||||
/** Сэмплов на канал в одном кадре (480 @ 10 мс / 48 кГц). */
|
||||
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000
|
||||
|
||||
/** Всего сэмплов (interleaved) в кадре. */
|
||||
const val FRAME_SHORTS = FRAME_SAMPLES * CHANNELS
|
||||
|
||||
/** Размер кадра PCM (interleaved 16-bit). */
|
||||
const val FRAME_BYTES = FRAME_SHORTS * 2
|
||||
|
||||
const val BITRATE = 64_000
|
||||
|
||||
/**
|
||||
* Сложность Opus-кодера. Чуть ниже максимума (10): Concentus — JVM-порт, а кодирует
|
||||
* он на очках в реальном времени; при 8 качество на 64 кбит/с практически то же,
|
||||
* зато есть запас по CPU.
|
||||
*/
|
||||
const val COMPLEXITY = 8
|
||||
|
||||
/** Ожидаемый процент потерь — для настройки inband FEC/PLC Opus. */
|
||||
const val PACKET_LOSS_PERCENT = 10
|
||||
|
||||
/** UDP-порт аудио на очках (control-канал остаётся на TCP 8080). */
|
||||
const val UDP_PORT = 8082
|
||||
|
||||
/** Верхняя граница размера датаграммы (Opus 10 мс stereo ≪ этого). */
|
||||
const val MAX_DATAGRAM = 1500
|
||||
|
||||
/** Интервал keepalive-датаграмм от телефона: держит пира «живым» на UDP-сервере очков. */
|
||||
const val KEEPALIVE_MS = 1_000L
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package pw.binom.rayneovm.shared.audio
|
||||
|
||||
/**
|
||||
* Формат аудио-датаграммы: `[4 байта seq big-endian][Opus payload]`.
|
||||
*
|
||||
* `seq` позволяет телефону детектить потери/дубли и понимать, что поток «жив», а не завис.
|
||||
* Заголовок намеренно минимальный — каждые лишние байты это лишняя задержка.
|
||||
*/
|
||||
object AudioDatagram {
|
||||
const val HEADER_SIZE = 4
|
||||
const val MAX_PAYLOAD = AudioConfig.MAX_DATAGRAM - HEADER_SIZE
|
||||
|
||||
/**
|
||||
* Кладёт [payload] в [out] с заголовком seq. Возвращает полную длину датаграммы.
|
||||
* Буфер [out] должен быть не меньше `HEADER_SIZE + payloadLength`.
|
||||
*/
|
||||
fun pack(seq: Int, payload: ByteArray, payloadLength: Int, out: ByteArray): Int {
|
||||
out[0] = (seq ushr 24).toByte()
|
||||
out[1] = (seq ushr 16).toByte()
|
||||
out[2] = (seq ushr 8).toByte()
|
||||
out[3] = seq.toByte()
|
||||
System.arraycopy(payload, 0, out, HEADER_SIZE, payloadLength)
|
||||
return HEADER_SIZE + payloadLength
|
||||
}
|
||||
|
||||
/** Читает seq из датаграммы; `-1`, если данных меньше заголовка (например keepalive). */
|
||||
fun seq(data: ByteArray, length: Int = data.size): Int {
|
||||
if (length < HEADER_SIZE) return -1
|
||||
return ((data[0].toInt() and 0xFF) shl 24) or
|
||||
((data[1].toInt() and 0xFF) shl 16) or
|
||||
((data[2].toInt() and 0xFF) shl 8) or
|
||||
(data[3].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
/** Есть ли в датаграмме вообще полезная нагрузка (не keepalive). */
|
||||
fun hasPayload(data: ByteArray, length: Int = data.size): Boolean = length > HEADER_SIZE
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package pw.binom.rayneovm.shared.audio
|
||||
|
||||
import org.concentus.OpusApplication
|
||||
import org.concentus.OpusDecoder
|
||||
import org.concentus.OpusEncoder
|
||||
|
||||
/**
|
||||
* Обёртка над Concentus (чистый JVM-порт libopus, без NDK).
|
||||
*
|
||||
* Один экземпляр держит один Opus-поток, поэтому его нельзя использовать из нескольких
|
||||
* потоков одновременно. Класс не потокобезопасен — вызывающий синхронизирует сам.
|
||||
*/
|
||||
class OpusAudioEncoder(
|
||||
sampleRate: Int = AudioConfig.SAMPLE_RATE,
|
||||
channels: Int = AudioConfig.CHANNELS,
|
||||
bitrate: Int = AudioConfig.BITRATE,
|
||||
complexity: Int = AudioConfig.COMPLEXITY,
|
||||
) {
|
||||
private val encoder = OpusEncoder(sampleRate, channels, OpusApplication.OPUS_APPLICATION_AUDIO).apply {
|
||||
this.bitrate = bitrate
|
||||
this.complexity = complexity
|
||||
useVBR = true
|
||||
useInbandFEC = true
|
||||
packetLossPercent = AudioConfig.PACKET_LOSS_PERCENT
|
||||
}
|
||||
|
||||
/** Переиспользуемый буфер вывода; копия отдаётся наружу только по факту кодирования. */
|
||||
private val out = ByteArray(AudioConfig.MAX_DATAGRAM)
|
||||
|
||||
/**
|
||||
* @param pcm interleaved 16-bit PCM (см. [AudioConfig.FRAME_SHORTS])
|
||||
* @param offset смещение в [pcm]
|
||||
* @param samplesPerChannel сэмплов на канал (см. [AudioConfig.FRAME_SAMPLES])
|
||||
* @return закодированный Opus-пакет
|
||||
*/
|
||||
fun encode(pcm: ShortArray, offset: Int, samplesPerChannel: Int): ByteArray {
|
||||
val n = encoder.encode(pcm, offset, samplesPerChannel, out, 0, out.size)
|
||||
return out.copyOf(n)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обёртка декодера Opus (Concentus). Не потокобезопасна.
|
||||
*/
|
||||
class OpusAudioDecoder(
|
||||
sampleRate: Int = AudioConfig.SAMPLE_RATE,
|
||||
channels: Int = AudioConfig.CHANNELS,
|
||||
) {
|
||||
private val decoder = OpusDecoder(sampleRate, channels)
|
||||
|
||||
/**
|
||||
* @return число декодированных сэмплов на канал, либо отрицательное значение при ошибке.
|
||||
*/
|
||||
fun decode(
|
||||
data: ByteArray,
|
||||
offset: Int,
|
||||
length: Int,
|
||||
pcmOut: ShortArray,
|
||||
frameSamplesPerChannel: Int = AudioConfig.FRAME_SAMPLES,
|
||||
): Int = decoder.decode(data, offset, length, pcmOut, 0, frameSamplesPerChannel, false)
|
||||
|
||||
/**
|
||||
* Packet Loss Concealment: синтезирует «заменитель» потерянного пакета, чтобы не было
|
||||
* щелчка/тишины. Вызывать, когда обнаружен пропуск seq.
|
||||
*
|
||||
* @return число сэмплов на канал.
|
||||
*/
|
||||
fun decodeLost(
|
||||
pcmOut: ShortArray,
|
||||
frameSamplesPerChannel: Int = AudioConfig.FRAME_SAMPLES,
|
||||
): Int = decoder.decode(null, 0, 0, pcmOut, 0, frameSamplesPerChannel, false)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package pw.binom.rayneovm.shared.audio
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class OpusCodecTest {
|
||||
|
||||
@Test
|
||||
fun `opus roundtrip compresses and decodes the expected frame`() {
|
||||
val encoder = OpusAudioEncoder()
|
||||
val decoder = OpusAudioDecoder()
|
||||
|
||||
val pcm = ShortArray(AudioConfig.FRAME_SHORTS)
|
||||
val out = ShortArray(AudioConfig.FRAME_SHORTS)
|
||||
var totalEncoded = 0
|
||||
val frames = 10
|
||||
|
||||
for (f in 0 until frames) {
|
||||
// 1 кГц синус, одинаковый в обоих каналах
|
||||
for (i in 0 until AudioConfig.FRAME_SAMPLES) {
|
||||
val v = (Math.sin(2 * Math.PI * 1000 * i / AudioConfig.SAMPLE_RATE) * 12000).toInt().toShort()
|
||||
pcm[i * 2] = v
|
||||
pcm[i * 2 + 1] = v
|
||||
}
|
||||
val payload = encoder.encode(pcm, 0, AudioConfig.FRAME_SAMPLES)
|
||||
assertTrue(payload.isNotEmpty(), "encoded payload пустой")
|
||||
assertTrue(payload.size < AudioConfig.FRAME_BYTES, "Opus обязан сжимать PCM")
|
||||
totalEncoded += payload.size
|
||||
|
||||
val decoded = decoder.decode(payload, 0, payload.size, out)
|
||||
assertEquals(AudioConfig.FRAME_SAMPLES, decoded)
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
totalEncoded < frames * AudioConfig.FRAME_BYTES,
|
||||
"трафик Opus должен быть меньше сырого PCM",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `packet loss concealment produces a frame`() {
|
||||
val encoder = OpusAudioEncoder()
|
||||
val decoder = OpusAudioDecoder()
|
||||
val pcm = ShortArray(AudioConfig.FRAME_SHORTS)
|
||||
// «прогреваем» декодер одним реальным пакетом, затем прячем потерю
|
||||
for (i in 0 until AudioConfig.FRAME_SAMPLES) {
|
||||
val v = (Math.sin(2 * Math.PI * 440 * i / AudioConfig.SAMPLE_RATE) * 8000).toInt().toShort()
|
||||
pcm[i * 2] = v
|
||||
pcm[i * 2 + 1] = v
|
||||
}
|
||||
val payload = encoder.encode(pcm, 0, AudioConfig.FRAME_SAMPLES)
|
||||
decoder.decode(payload, 0, payload.size, ShortArray(AudioConfig.FRAME_SHORTS))
|
||||
|
||||
val out = ShortArray(AudioConfig.FRAME_SHORTS)
|
||||
val concealed = decoder.decodeLost(out)
|
||||
assertEquals(AudioConfig.FRAME_SAMPLES, concealed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keepalive datagram has no payload and seq -1`() {
|
||||
val keepAlive = ByteArray(0)
|
||||
assertEquals(-1, AudioDatagram.seq(keepAlive))
|
||||
assertTrue(!AudioDatagram.hasPayload(keepAlive))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `datagram pack and seq roundtrip`() {
|
||||
val payload = byteArrayOf(1, 2, 3, 4, 5)
|
||||
val out = ByteArray(AudioConfig.MAX_DATAGRAM)
|
||||
val seq = 0x01020304
|
||||
|
||||
val len = AudioDatagram.pack(seq, payload, payload.size, out)
|
||||
|
||||
assertEquals(AudioDatagram.HEADER_SIZE + payload.size, len)
|
||||
assertEquals(seq, AudioDatagram.seq(out, len))
|
||||
assertTrue(AudioDatagram.hasPayload(out, len))
|
||||
assertEquals(1, out[AudioDatagram.HEADER_SIZE].toInt())
|
||||
assertEquals(5, out[AudioDatagram.HEADER_SIZE + 4].toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `negative seq survives the roundtrip`() {
|
||||
val out = ByteArray(AudioConfig.MAX_DATAGRAM)
|
||||
val len = AudioDatagram.pack(-1, byteArrayOf(9), 1, out)
|
||||
assertEquals(-1, AudioDatagram.seq(out, len))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user