Compare commits

...

5 Commits

13 changed files with 331 additions and 27 deletions
+3
View File
@@ -61,6 +61,9 @@ dependencies {
implementation(project(":lib-core")) implementation(project(":lib-core"))
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
// VAD на очках (WebRTC VAD — лёгкий, без моделей; схема pair-view автора)
implementation("com.github.gkonovalov.android-vad:webrtc:2.0.10")
// WS-клиент очков (OkHttp — Android-движок) // WS-клиент очков (OkHttp — Android-движок)
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.websockets) implementation(libs.ktor.client.websockets)
@@ -52,6 +52,9 @@ class GlassesApp : Application() {
/** Масштаб экрана очков (0.5..1.0). Загружается в onCreate (prefs — после привязки контекста). */ /** Масштаб экрана очков (0.5..1.0). Загружается в onCreate (prefs — после привязки контекста). */
val scale = MutableStateFlow(1f) val scale = MutableStateFlow(1f)
/** Вертикаль экрана очков (0..1): 1.0 — у верха (как всегда было), 0.0 — нижняя кромка у низа. */
val vertical = MutableStateFlow(1f)
/** Идёт распознавание речи (TripleClick стартовал, Click/DoubleClick/тишина завершили). */ /** Идёт распознавание речи (TripleClick стартовал, Click/DoubleClick/тишина завершили). */
val listening = MutableStateFlow(false) val listening = MutableStateFlow(false)
@@ -92,10 +95,18 @@ class GlassesApp : Application() {
prefs.edit().putFloat("scale", v).apply() prefs.edit().putFloat("scale", v).apply()
} }
/** Применить и сохранить вертикаль (0..1). */
fun applyVertical(value: Float) {
val v = value.coerceIn(0f, 1f)
vertical.value = v
prefs.edit().putFloat("vertical", v).apply()
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f) scale.value = prefs.getFloat("scale", 1f).coerceIn(0.5f, 1f)
vertical.value = prefs.getFloat("vertical", 1f).coerceIn(0f, 1f)
MercurySDK.init(this) MercurySDK.init(this)
movieController = MovieController(applicationContext) movieController = MovieController(applicationContext)
movieController.eventLogger = { msg -> logToFile("mc: $msg") } movieController.eventLogger = { msg -> logToFile("mc: $msg") }
@@ -36,6 +36,7 @@ import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.PlaybackPosition import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SetMode import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.SetScale import pw.binom.viewmate.core.protocol.SetScale
import pw.binom.viewmate.core.protocol.SetVertical
import pw.binom.viewmate.core.protocol.Shutdown import pw.binom.viewmate.core.protocol.Shutdown
import pw.binom.viewmate.core.protocol.ShowText import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.SttCancel import pw.binom.viewmate.core.protocol.SttCancel
@@ -309,6 +310,11 @@ class HostConnection(
log("app", "scale → ${msg.scale}") log("app", "scale → ${msg.scale}")
} }
is SetVertical -> {
GlassesApp.instance.applyVertical(msg.vertical)
log("app", "vertical → ${msg.vertical}")
}
is PlayVideo -> { is PlayVideo -> {
addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}") addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
reconnectResume.reset() reconnectResume.reset()
@@ -65,6 +65,7 @@ import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
import pw.binom.viewmate.glasses.ui.Side import pw.binom.viewmate.glasses.ui.Side
import pw.binom.viewmate.glasses.ui.px import pw.binom.viewmate.glasses.ui.px
import pw.binom.viewmate.glasses.ui.scaleOffsetX import pw.binom.viewmate.glasses.ui.scaleOffsetX
import pw.binom.viewmate.glasses.ui.verticalOffsetY
class MainActivity : BaseEventActivity() { class MainActivity : BaseEventActivity() {
private var resumeAfterScreenOn = false private var resumeAfterScreenOn = false
@@ -458,6 +459,7 @@ fun ConnectionScreen() {
val status by connection.status.collectAsState() val status by connection.status.collectAsState()
val messages by connection.messages.collectAsState() val messages by connection.messages.collectAsState()
val scale by GlassesApp.instance.scale.collectAsState() val scale by GlassesApp.instance.scale.collectAsState()
val vertical by GlassesApp.instance.vertical.collectAsState()
// Бинокулярно: контент дублируется в оба глаза, панель — по центру каждой // Бинокулярно: контент дублируется в оба глаза, панель — по центру каждой
// половины (явная ширина половины, иначе fillMaxSize растянет на 1280). // половины (явная ширина половины, иначе fillMaxSize растянет на 1280).
@@ -475,7 +477,10 @@ fun ConnectionScreen() {
modifier = Modifier modifier = Modifier
.width(containerW.px) .width(containerW.px)
.height(containerH.px) .height(containerH.px)
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px) .offset(
x = scaleOffsetX(side, screenWidthPx, scale).px,
y = verticalOffsetY(screenHeightPx, scale, vertical).px,
)
.background(Color.Black), .background(Color.Black),
) { ) {
Column( Column(
@@ -493,11 +498,16 @@ fun ConnectionScreen() {
} }
} }
// Оверлей распознавания/ответа — в AssistantOverlay (корень, поверх любого экрана).
}
// Панель статуса (время/заряд) — вне контейнера, всегда у верха половины:
// при vertical=0 контейнер у низа, и панель иначе повисала бы посередине.
Box(Modifier.fillMaxSize()) {
StatusPanelCompose( StatusPanelCompose(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp), modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
scale = scale, scale = scale,
) )
// Оверлей распознавания/ответа — в AssistantOverlay (корень, поверх любого экрана).
} }
} }
} }
@@ -3,6 +3,10 @@ package pw.binom.viewmate.glasses.stt
import android.media.AudioFormat import android.media.AudioFormat
import android.media.AudioRecord import android.media.AudioRecord
import android.media.MediaRecorder import android.media.MediaRecorder
import com.konovalov.vad.webrtc.VadWebRTC
import com.konovalov.vad.webrtc.config.FrameSize
import com.konovalov.vad.webrtc.config.Mode
import com.konovalov.vad.webrtc.config.SampleRate
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.time.LocalTime import java.time.LocalTime
@@ -18,11 +22,15 @@ import pw.binom.viewmate.glasses.log
/** /**
* Поток распознаваемого аудио очков: куски s16le (16 кГц, mono) → колбэк. * Поток распознаваемого аудио очков: куски s16le (16 кГц, mono) → колбэк.
* На эмуляторе микрофона нет — вместо него отправляется первый wav из
* filesDir/samples (отладка, см. TASK.md этап 2).
* *
* Список кусков/стоп — по [shouldRun] (наружу смотрит стейт listening): * VAD живёт НА ОЧКАХ (WebRTC VAD, схема pair-view автора): микрофон читается
* как только очки перестали слушать, поток обрывается. * фреймами по 20 мс (320 сэмплов @ 16 кГц), речевые фреймы накапливаются в
* сегмент (с пре-роллом тишины 200 мс), а наружу уходит только закрытый
* сегмент — межфразовая тишина (у микрофона ARGT78 её ~75%!) не шлётся.
* Телефон получает склеенную речь — whisper видит непрерывный звук.
*
* Список кусков/стоп — по [shouldRun]: как только очки перестали слушать,
* незакрытый сегмент отправляется (полный текст по клику).
*/ */
class SttMicStream( class SttMicStream(
private val scope: CoroutineScope, private val scope: CoroutineScope,
@@ -30,7 +38,7 @@ class SttMicStream(
) { ) {
private var job: Job? = null private var job: Job? = null
/** Debug-дампер микрофона: пишет УСИЛЕННЫЕ чанки — те же, что уходят на телефон (по маркеру stt_dump.marker). */ /** Debug-дампер микрофона: пишет УСИЛЕННЫЕ сегменты — те же, что уходят на телефон (по маркеру stt_dump.marker). */
private var dump: WavWriter? = null private var dump: WavWriter? = null
fun start(filesDir: File, shouldRun: () -> Boolean) { fun start(filesDir: File, shouldRun: () -> Boolean) {
@@ -58,20 +66,48 @@ class SttMicStream(
job = scope.launch(Dispatchers.IO) { job = scope.launch(Dispatchers.IO) {
try { try {
recorder.startRecording() recorder.startRecording()
log("stt", "микрофон запущен (16 кГц, s16, mono)") log("stt", "микрофон запущен (16 кГц, s16, mono, WebRTC VAD на очках)")
val buf = ByteArray(CHUNK_BYTES) // = 1600 кадров (16-бит моно) = 100 мс val vad = VadWebRTC(
SampleRate.SAMPLE_RATE_16K,
FrameSize.FRAME_SIZE_320,
Mode.VERY_AGGRESSIVE,
600, // silenceDurationMs: тишина 600 мс = конец реплики
50, // speechDurationMs: минимум 50 мс речи
)
val frame = ShortArray(VAD_FRAME_SIZE) // 320 сэмплов = 20 мс
val segment = ArrayList<ShortArray>()
val preRoll = ArrayDeque<ShortArray>()
var speeching = false
var silenceFrames = 0
while (isActive && shouldRun()) { while (isActive && shouldRun()) {
val frames = recorder.read(buf, 0, CHUNK_BYTES / 2) // inFrameCount — КАДРЫ val n = recorder.read(frame, 0, VAD_FRAME_SIZE) // кадры
if (frames > 0) { if (n <= 0) continue
val raw = micChunkBytes(buf, frames) val data = if (n == VAD_FRAME_SIZE) frame else frame.copyOf(n)
val chunk = gainChunk(raw) // усиление: в дамп и на телефон — один и тот же чанк val boosted = gainShort(data) // микрофон тихий (пик 1–9%) — ×10 и для VAD, и для whisper
if (dumpWriter != null) { if (vad.isSpeech(boosted)) {
runCatching { dumpWriter.write(chunk) } if (!speeching) {
.onFailure { log("stt", "dump микрофона: ${it.message}") } speeching = true
silenceFrames = 0
segment.clear()
while (preRoll.isNotEmpty()) segment.add(preRoll.removeFirst())
log("stt", "🎤 речь началась (${segment.size} фреймов с пре-роллом)")
} }
onChunk(chunk) segment.add(boosted)
} else {
if (speeching) {
segment.add(boosted)
silenceFrames++
if (silenceFrames >= SILENCE_FRAMES_TIMEOUT) { // 600 мс тишины
closeSegment(segment, dumpWriter)
speeching = false
}
} else {
preRoll.addLast(boosted)
if (preRoll.size > PRE_ROLL_FRAMES) preRoll.removeFirst() // 200 мс
} }
} }
}
if (speeching && segment.isNotEmpty()) closeSegment(segment, dumpWriter)
} catch (t: Throwable) { } catch (t: Throwable) {
log("stt", "микрофон: ${t.message}") log("stt", "микрофон: ${t.message}")
} finally { } finally {
@@ -91,10 +127,22 @@ class SttMicStream(
.onFailure { log("stt", "dump микрофона: ${it.message}") } .onFailure { log("stt", "dump микрофона: ${it.message}") }
} }
/** Закрытый речевой сегмент: склейка фреймов → s16le → усилен в дамп и на телефон. */
private suspend fun closeSegment(segment: List<ShortArray>, dumpWriter: WavWriter?) {
if (segment.isEmpty()) return
val pcm = shortsToPcm16(segment)
if (dumpWriter != null) {
runCatching { dumpWriter.write(pcm) }
.onFailure { log("stt", "dump микрофона: ${it.message}") }
}
log("stt", "🔇 сегмент речи: ${segment.size * VAD_FRAME_SIZE / 160} мс → на телефон")
onChunk(pcm)
}
/** /**
* Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker, * Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker,
* открыть stt_dump_HHmmss.wav — ВСЕ усиленные чанки живого микрофона допишутся туда * открыть stt_dump_HHmmss.wav — усиленные речевые сегменты допишутся туда
* до отправки в onChunk (эмуляторную ветку не трогаем). * (эмуляторную ветку не трогаем).
*/ */
private fun openLiveMicDump(filesDir: File): WavWriter? { private fun openLiveMicDump(filesDir: File): WavWriter? {
if (!File(filesDir, "stt_dump.marker").exists()) return null if (!File(filesDir, "stt_dump.marker").exists()) return null
@@ -140,9 +188,52 @@ class SttMicStream(
} }
} }
/** Фрейм VAD: 20 мс при 16 кГц = 320 сэмплов. */
internal const val VAD_FRAME_SIZE = 320
/** Пре-ролл тишины перед речью: 200 мс = 10 фреймов. */
internal const val PRE_ROLL_FRAMES = 10
/** Тишина 600 мс (30 фреймов) считается концом реплики. */
internal const val SILENCE_FRAMES_TIMEOUT = 30
/** Коэффициент усиления микрофона (микрофон RayNeo X2 пишет пик 1–9% шкалы). */
internal const val MIC_GAIN = 10f
/** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */ /** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */
internal const val CHUNK_BYTES = 3_200 internal const val CHUNK_BYTES = 3_200
/** Микрофон очков: родная 16 кГц (WebRTC VAD рекомендует 16 кГц, whisper тоже). */
internal const val MIC_SAMPLE_RATE = 16_000
/**
* Цифровое усиление фрейма s16le mono с насыщением (для VAD и whisper).
* Для gain = 1 не аллоцируем заново — возвращаем исходный массив.
*/
internal fun gainShort(samples: ShortArray, gain: Float = MIC_GAIN): ShortArray {
if (gain == 1f) return samples
val out = ShortArray(samples.size)
for (i in samples.indices) {
out[i] = (samples[i] * gain).roundToInt().coerceIn(-32_768, 32_767).toShort()
}
return out
}
/** Склейка речевых фреймов (ShortArray) в один s16le ByteArray. */
internal fun shortsToPcm16(frames: List<ShortArray>): ByteArray {
var total = 0
for (f in frames) total += f.size
val out = ByteArray(total * 2)
var i = 0
for (f in frames) {
for (s in f) {
out[i++] = (s.toInt() and 0xff).toByte()
out[i++] = ((s.toInt() shr 8) and 0xff).toByte()
}
}
return out
}
/** /**
* AudioRecord.read(byte[], int, inFrameCount) возвращает ЧИСЛО КАДРОВ, а не байт. * AudioRecord.read(byte[], int, inFrameCount) возвращает ЧИСЛО КАДРОВ, а не байт.
* 16-бит моно: кадр = 2 байта. Байты = frames * 2. * 16-бит моно: кадр = 2 байта. Байты = frames * 2.
@@ -154,9 +245,6 @@ internal fun micChunkBytes(buf: ByteArray, frames: Int): ByteArray {
return if (bytes == buf.size) buf else buf.copyOf(bytes) return if (bytes == buf.size) buf else buf.copyOf(bytes)
} }
/** Коэффициент усиления микрофона (микрофон RayNeo X2 пишет пик 1–9% шкалы). */
internal const val MIC_GAIN = 10f
/** /**
* Цифровое усиление чанка s16le mono с насыщением. * Цифровое усиление чанка s16le mono с насыщением.
* Каждый сэмпл: v = (s * gain).roundToInt().coerceIn(-32768, 32767). * Каждый сэмпл: v = (s * gain).roundToInt().coerceIn(-32768, 32767).
@@ -91,6 +91,7 @@ private fun overlayBox(
) { ) {
Binocular(scale = false) { Binocular(scale = false) {
val scale by app.scale.collectAsState() val scale by app.scale.collectAsState()
val vertical by app.vertical.collectAsState()
val screenWidthPx = BinocularScreenSize.current.width val screenWidthPx = BinocularScreenSize.current.width
val screenHeightPx = BinocularScreenSize.current.height val screenHeightPx = BinocularScreenSize.current.height
val side = BinocularScreenSide.current val side = BinocularScreenSide.current
@@ -100,7 +101,10 @@ private fun overlayBox(
modifier = Modifier modifier = Modifier
.width(containerW.px) .width(containerW.px)
.height(containerH.px) .height(containerH.px)
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px) .offset(
x = scaleOffsetX(side, screenWidthPx, scale).px,
y = verticalOffsetY(screenHeightPx, scale, vertical).px,
)
.background(Color.Black.copy(alpha = 0.72f)), .background(Color.Black.copy(alpha = 0.72f)),
) { ) {
AssistantOverlayContent(state, chatHistory, scrollState) AssistantOverlayContent(state, chatHistory, scrollState)
@@ -57,6 +57,7 @@ fun GlassesMovieScreen(
var videoHeight by remember { mutableIntStateOf(480) } var videoHeight by remember { mutableIntStateOf(480) }
val scale by GlassesApp.instance.scale.collectAsState() val scale by GlassesApp.instance.scale.collectAsState()
val vertical by GlassesApp.instance.vertical.collectAsState()
// Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый // Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый
// глаз из Binocular) регистрируют в нём свои SurfaceView. // глаз из Binocular) регистрируют в нём свои SurfaceView.
@@ -107,10 +108,16 @@ fun GlassesMovieScreen(
modifier = Modifier modifier = Modifier
.width(containerW.px) .width(containerW.px)
.height(containerH.px) .height(containerH.px)
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px), .offset(
x = scaleOffsetX(side, screenWidthPx, scale).px,
y = verticalOffsetY(screenHeightPx, scale, vertical).px,
),
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth().background(Color.Black), modifier = Modifier.fillMaxWidth().background(Color.Black),
// Видео в контейнере прижимается к верху (vertical=1) или низу (vertical=0):
// Row-центрирование съедало вертикаль — картинка оставалась «по центру».
verticalAlignment = Alignment.Top,
horizontalArrangement = if (side == Side.LEFT) Arrangement.End else Arrangement.Start, horizontalArrangement = if (side == Side.LEFT) Arrangement.End else Arrangement.Start,
) { ) {
if (SCALE_DEBUG_SQUARE) { if (SCALE_DEBUG_SQUARE) {
@@ -121,13 +128,19 @@ fun GlassesMovieScreen(
BinocularVideoPlayer( BinocularVideoPlayer(
player = player, player = player,
renderer = renderer, renderer = renderer,
modifier = Modifier.size(playerWidth.px, playerHeight.px), modifier = Modifier
.size(playerWidth.px, playerHeight.px)
.offset(y = videoOffsetY(containerH, playerHeight, vertical).px),
) )
} }
} }
// Панель по центру верхней letterbox-полосы: в каждом глазу — в центре // Панель статуса (время/заряд) — вне контейнера, всегда у верха половины:
// своей половины, бинокулярно сливается в одну по центру. // при vertical=0 контейнер у низа, и панель иначе повисала бы посередине
// чёрного поля между верхом экрана и видео.
}
Box(Modifier.fillMaxSize()) {
StatusPanelCompose( StatusPanelCompose(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp), modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
scale = scale, scale = scale,
@@ -143,6 +156,22 @@ fun GlassesMovieScreen(
fun scaleOffsetX(side: Side, screenWidthPx: Int, scale: Float): Int = fun scaleOffsetX(side: Side, screenWidthPx: Int, scale: Float): Int =
if (side == Side.LEFT) (screenWidthPx * (1f - scale)).toInt() else 0 if (side == Side.LEFT) (screenWidthPx * (1f - scale)).toInt() else 0
/**
* Вертикальное смещение контейнера глаза (с учётом масштаба):
* vertical = 1.0 — у верха (как всегда было); 0.0 — нижняя кромка контейнера
* прижата к низу экрана. Свободный ход по вертикали = (1 - scale) высоты.
*/
fun verticalOffsetY(screenHeightPx: Int, scale: Float, vertical: Float): Int =
((1f - vertical) * screenHeightPx * (1f - scale)).toInt()
/**
* Смещение видео внутри контейнера глаза: vertical=1.0 — у верха контейнера,
* 0.0 — у низа (нижняя кромка видео прижата к низу контейнера, который сам
* у низа экрана при vertical=0). Row-центрирование иначе съедало вертикаль.
*/
fun videoOffsetY(containerHeightPx: Int, videoHeightPx: Int, vertical: Float): Int =
((1f - vertical) * (containerHeightPx - videoHeightPx).coerceAtLeast(0)).toInt()
/** Отладочный режим: красный квадрат вместо видео (проверка масштаба/позиций скрином). Перед финалом — false. */ /** Отладочный режим: красный квадрат вместо видео (проверка масштаба/позиций скрином). Перед финалом — false. */
private const val SCALE_DEBUG_SQUARE = false private const val SCALE_DEBUG_SQUARE = false
@@ -4,6 +4,8 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import pw.binom.viewmate.glasses.ui.Side import pw.binom.viewmate.glasses.ui.Side
import pw.binom.viewmate.glasses.ui.scaleOffsetX import pw.binom.viewmate.glasses.ui.scaleOffsetX
import pw.binom.viewmate.glasses.ui.verticalOffsetY
import pw.binom.viewmate.glasses.ui.videoOffsetY
/** /**
* scaleOffsetX — смещение контейнера глаза к центру (к носу): * scaleOffsetX — смещение контейнера глаза к центру (к носу):
@@ -34,4 +36,56 @@ class ScaleOffsetTest {
assertEquals(0, scaleOffsetX(Side.RIGHT, 640, 0.5f)) assertEquals(0, scaleOffsetX(Side.RIGHT, 640, 0.5f))
assertEquals(0, scaleOffsetX(Side.RIGHT, 480, 0.5f)) assertEquals(0, scaleOffsetX(Side.RIGHT, 480, 0.5f))
} }
/*
* verticalOffsetY — вертикальное положение контейнера с учётом масштаба:
* 1.0 — у верха (offset 0), 0.0 — нижняя кромка у низа ((1 - scale) высоты).
*/
@Test
fun verticalTop_isZero() {
assertEquals(0, verticalOffsetY(480, 1f, 1f))
assertEquals(0, verticalOffsetY(480, 0.7f, 1f))
assertEquals(0, verticalOffsetY(480, 0.5f, 1f))
}
@Test
fun verticalBottom_pushesToBottomEdge() {
// 0.7 scale: свободный ход = 480 * 0.3 = 144
assertEquals(144, verticalOffsetY(480, 0.7f, 0f))
// 1.0 scale: хода нет — экран и так на весь дисплей
assertEquals(0, verticalOffsetY(480, 1f, 0f))
}
@Test
fun verticalMiddle_halfOfFreeRun() {
// 0.7 scale, vertical 0.5: (1-0.5)*480*0.3 = 72
assertEquals(72, verticalOffsetY(480, 0.7f, 0.5f))
assertEquals(0, verticalOffsetY(480, 1f, 0.5f))
}
/*
* videoOffsetY — видео внутри контейнера: верх при vertical=1, низ при 0.
*/
@Test
fun videoTop_zero() {
assertEquals(0, videoOffsetY(432, 268, 1f))
}
@Test
fun videoBottom_pushedToContainerBottom() {
// 432 контейнер, 268 видео: ход = 164
assertEquals(164, videoOffsetY(432, 268, 0f))
}
@Test
fun videoMiddle_halfOfRun() {
assertEquals(82, videoOffsetY(432, 268, 0.5f))
}
@Test
fun videoLargerThanContainer_noNegativeOffset() {
assertEquals(0, videoOffsetY(200, 268, 0f), "видео выше контейнера — хода нет")
}
} }
@@ -118,4 +118,52 @@ class MicChunkTest {
} }
private fun recorderWouldReturn(frames: Int) = frames private fun recorderWouldReturn(frames: Int) = frames
/*
* VAD на очках (схема pair-view): фреймы 20 мс (320 сэмплов @ 16 кГц),
* усиление ×10 перед VAD и whisper, склейка речевых сегментов в s16le.
*/
@Test
fun gainShort_quietSamples_boosted10_saturated() {
val samples = shortArrayOf(1_000, -1_000, 20_000, -32_768, 0)
val out = gainShort(samples, 10f)
assertEquals(samples.size, out.size)
assertEquals(10_000, out[0].toInt(), "1000 × 10")
assertEquals(-10_000, out[1].toInt(), "-1000 × 10")
assertEquals(32_767, out[2].toInt(), "20000 × 10 → насыщение")
assertEquals(-32_768, out[3].toInt(), "-32768 × 10 → насыщение")
assertEquals(0, out[4].toInt())
}
@Test
fun gainShort_gain1_returnsSameArray() {
val samples = shortArrayOf(1, 2, 3)
assertSame(samples, gainShort(samples, 1f), "gain=1 не аллоцирует")
}
@Test
fun shortsToPcm16_concatenatesLittleEndian() {
val frames = listOf(shortArrayOf(1, 2), shortArrayOf(3))
val out = shortsToPcm16(frames)
assertEquals(6, out.size, "3 сэмпла × 2 байта")
assertEquals(1, out[0].toInt() and 0xff)
assertEquals(0, out[1].toInt())
assertEquals(2, out[2].toInt() and 0xff)
assertEquals(0, out[3].toInt())
assertEquals(3, out[4].toInt() and 0xff)
assertEquals(0, out[5].toInt())
}
@Test
fun shortsToPcm16_negativeSample_twoBytesComplement() {
val out = shortsToPcm16(listOf(shortArrayOf(-1)))
assertEquals(0xff, out[0].toInt() and 0xff)
assertEquals(0xff, out[1].toInt() and 0xff)
}
@Test
fun shortsToPcm16_empty_returnsEmpty() {
assertEquals(0, shortsToPcm16(emptyList()).size)
}
} }
@@ -71,6 +71,9 @@ fun GlassesScreen(
// масштаб экрана очков (0.5..1): слайдер пишет в PhoneState, в очки — при отпускании // масштаб экрана очков (0.5..1): слайдер пишет в PhoneState, в очки — при отпускании
var scale by remember { mutableStateOf(app.state.scale) } var scale by remember { mutableStateOf(app.state.scale) }
// высота экрана очков (0..1): 1 — у верха, 0 — нижняя кромка у низа
var vertical by remember { mutableStateOf(app.state.vertical) }
val actions = app.actions val actions = app.actions
// активное скачивание на очки: первое с незавершённым файлом // активное скачивание на очки: первое с незавершённым файлом
@@ -183,6 +186,31 @@ fun GlassesScreen(
) )
} }
// высота экрана очков: 1.0 — у верха (как сейчас), 0.0 — нижняя кромка у низа (с учётом масштаба)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp),
) {
Text(
text = "Высота: ${"%.2f".format(vertical)}",
color = PhoneMuted,
fontSize = 11.sp,
modifier = Modifier.padding(start = 8.dp),
)
Slider(
value = vertical,
onValueChange = { vertical = it },
onValueChangeFinished = {
app.state.vertical = vertical
val a = actions
if (a != null) scope.launch { a.applyVertical(vertical) }
},
valueRange = 0f..1f,
steps = 9, // шаг 0.1
)
}
// активная загрузка контента на очки // активная загрузка контента на очки
if (activeGlassesItem != null) { if (activeGlassesItem != null) {
Text( Text(
@@ -17,6 +17,7 @@ import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.core.protocol.PlayVideo import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.SetMode import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.SetScale import pw.binom.viewmate.core.protocol.SetScale
import pw.binom.viewmate.core.protocol.SetVertical
import pw.binom.viewmate.core.protocol.ShowText import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.Shutdown import pw.binom.viewmate.core.protocol.Shutdown
@@ -275,6 +276,13 @@ class PhoneActions(
return "масштаб → $clamped" return "масштаб → $clamped"
} }
/** Вертикаль экрана очков: SetVertical (0..1; 1 — верх, 0 — низ). */
suspend fun applyVertical(vertical: Float): String {
val clamped = vertical.coerceIn(0f, 1f)
sender.send(SetVertical(clamped))
return "высота → $clamped"
}
/** Закрыть приложение на очках (телефон остаётся открытым). */ /** Закрыть приложение на очках (телефон остаётся открытым). */
suspend fun shutdownGlasses(): String { suspend fun shutdownGlasses(): String {
sender.send(Shutdown()) sender.send(Shutdown())
@@ -39,6 +39,16 @@ class PhoneState {
log("state", "масштаб → $value") log("state", "масштаб → $value")
} }
private val _vertical = MutableStateFlow(1f)
val verticalFlow: StateFlow<Float> = _vertical.asStateFlow()
/** Вертикаль экрана очков (0..1): 1.0 — у верха, 0.0 — нижняя кромка у низа. */
var vertical: Float
get() = _vertical.value
set(value) {
_vertical.value = value.coerceIn(0f, 1f)
log("state", "высота → $value")
}
private val _currentItem = MutableStateFlow<JellyfinItem?>(null) private val _currentItem = MutableStateFlow<JellyfinItem?>(null)
val currentItemFlow: StateFlow<JellyfinItem?> = _currentItem.asStateFlow() val currentItemFlow: StateFlow<JellyfinItem?> = _currentItem.asStateFlow()
var currentItem: JellyfinItem? var currentItem: JellyfinItem?
@@ -81,6 +81,11 @@ data class ChatSessionMsg(val sessionId: String, val title: String) : HostToGlas
@SerialName("set_scale") @SerialName("set_scale")
data class SetScale(val scale: Float) : HostToGlasses data class SetScale(val scale: Float) : HostToGlasses
/** Вертикальное положение экрана очков (0..1); 1.0 — у верха, 0.0 — нижняя кромка у низа. */
@Serializable
@SerialName("set_vertical")
data class SetVertical(val vertical: Float) : HostToGlasses
/** Фраза распознана (streaming): phrase — фраза, full — весь накопленный текст сессии. */ /** Фраза распознана (streaming): phrase — фраза, full — весь накопленный текст сессии. */
@Serializable @Serializable
@SerialName("stt_phrase") @SerialName("stt_phrase")