Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8958370507 | |||
| 883fd8e5e4 | |||
| 6d22f56bd8 | |||
| b17b5bf424 | |||
| d78ed4429e |
@@ -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} фреймов с пре-роллом)")
|
||||||
|
}
|
||||||
|
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 мс
|
||||||
}
|
}
|
||||||
onChunk(chunk)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user