Compare commits
7 Commits
f656f43e10
...
79a500150d
| Author | SHA1 | Date | |
|---|---|---|---|
| 79a500150d | |||
| f68ea10342 | |||
| 5c86ed00b4 | |||
| 0b3f9ff975 | |||
| 40723fa584 | |||
| 728116662e | |||
| 0cfd92a68f |
@@ -1,3 +1,5 @@
|
|||||||
|
import java.io.File
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
@@ -5,6 +7,20 @@ plugins {
|
|||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Подпись release ключом Binom (assembleRelease сам подпишет APK, v2+v3). Кестор + креды —
|
||||||
|
// из скилла android-sign (вне git); переопределение: -Pbinom.keystore.dir=... / env BINOM_KEYSTORE_DIR.
|
||||||
|
val binomCertsDir: File = File(
|
||||||
|
(project.findProperty("binom.keystore.dir") as? String)
|
||||||
|
?: System.getenv("BINOM_KEYSTORE_DIR")
|
||||||
|
?: "${System.getProperty("user.home")}/.config/opencode/skills/android-sign/certs"
|
||||||
|
)
|
||||||
|
val binomStoreFile = File(binomCertsDir, "android-release.keystore")
|
||||||
|
val binomCreds: Map<String, String> =
|
||||||
|
(File(binomCertsDir, "creds.env").let { c -> if (binomStoreFile.exists() && c.exists())
|
||||||
|
c.readLines().filter { it.isNotBlank() && !it.startsWith("#") && it.contains('=') }
|
||||||
|
.associate { val i = it.indexOf('='); it.substring(0, i) to it.substring(i + 1).trim().removeSurrounding("\"") }
|
||||||
|
else emptyMap() })
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "pw.binom.viewmate.glasses"
|
namespace = "pw.binom.viewmate.glasses"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -17,9 +33,30 @@ android {
|
|||||||
versionName = (project.findProperty("versionName") as? String) ?: "0.1.0"
|
versionName = (project.findProperty("versionName") as? String) ?: "0.1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Подпись release ключом Binom (всё из binomStoreFile/binomCreds выше).
|
||||||
|
// Без кестора binomCreds пусто → release остаётся неподписанным.
|
||||||
|
if (binomCreds.isNotEmpty()) {
|
||||||
|
val binomAlias = binomCreds.getValue("ANDROID_KEYSTORE_ALIAS")
|
||||||
|
val binomStorePass = binomCreds.getValue("ANDROID_KEYSTORE_PASSWORD")
|
||||||
|
val binomKeyPass = binomCreds["ANDROID_KEY_PASSWORD"] ?: binomStorePass
|
||||||
|
signingConfigs {
|
||||||
|
create("binom") {
|
||||||
|
storeFile = binomStoreFile
|
||||||
|
storePassword = binomStorePass
|
||||||
|
keyAlias = binomAlias
|
||||||
|
keyPassword = binomKeyPass
|
||||||
|
enableV1Signing = false
|
||||||
|
enableV2Signing = true
|
||||||
|
enableV3Signing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
|
if (binomCreds.isNotEmpty()) {
|
||||||
|
signingConfig = signingConfigs.getByName("binom")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +98,11 @@ 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")
|
||||||
|
|
||||||
|
// Локальный Qwen3-ASR на очках (движок + распознавание переезжают на очки из app-phone).
|
||||||
|
// asr-qwen3-android тянет sherpa-onnx (nativ) транзитивно; модель — в filesDir/models
|
||||||
|
// (SttModelProvider), как на телефоне.
|
||||||
|
implementation("pw.binom.asr:asr-qwen3-android:4")
|
||||||
|
|
||||||
// VAD на очках (WebRTC VAD — лёгкий, без моделей; схема pair-view автора)
|
// VAD на очках (WebRTC VAD — лёгкий, без моделей; схема pair-view автора)
|
||||||
// VAD (WebRTC) убран из прода: резал речь на рваном микрофоне ARGT78 —
|
// VAD (WebRTC) убран из прода: резал речь на рваном микрофоне ARGT78 —
|
||||||
// whisper распознаёт полный поток без VAD (проверено на сыром дампе).
|
// whisper распознаёт полный поток без VAD (проверено на сыром дампе).
|
||||||
|
|||||||
@@ -42,17 +42,19 @@ class BtGlassesTransport(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
* Runtime-пермишены Bluetooth, зависящие от версии платформы
|
* Runtime-пермишены, зависящие от версии платформы
|
||||||
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
|
* (TASK-transport.md п.3, п.5: Android 12+ / до 12) + RECORD_AUDIO:
|
||||||
|
* микрофон локального STT — без него AudioRecord не стартует и сессия
|
||||||
|
* молча падает в файловую эмуляцию (пустой буфер).
|
||||||
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
|
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
|
||||||
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
|
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
|
||||||
*/
|
*/
|
||||||
fun runtimePermissions(): Array<String> =
|
fun runtimePermissions(): Array<String> =
|
||||||
if (Build.VERSION.SDK_INT >= 31) {
|
(if (Build.VERSION.SDK_INT >= 31) {
|
||||||
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
|
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
|
||||||
} else {
|
} else {
|
||||||
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
|
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
|
||||||
}
|
}) + Manifest.permission.RECORD_AUDIO
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
private fun hasBtPermissions(ctx: Context): Boolean =
|
private fun hasBtPermissions(ctx: Context): Boolean =
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import pw.binom.viewmate.core.log.BatchingLogCollector
|
import pw.binom.viewmate.core.log.BatchingLogCollector
|
||||||
import pw.binom.viewmate.core.log.FileLogSpool
|
import pw.binom.viewmate.core.log.FileLogSpool
|
||||||
import pw.binom.viewmate.core.log.SystemClock
|
import pw.binom.viewmate.core.log.SystemClock
|
||||||
|
import pw.binom.viewmate.glasses.stt.GlassesAsr
|
||||||
|
import pw.binom.viewmate.glasses.stt.GlassesSttSession
|
||||||
|
import pw.binom.viewmate.glasses.stt.SttModelDownloader
|
||||||
|
|
||||||
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
|
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
|
||||||
data class ChatEntry(val role: String, val text: String)
|
data class ChatEntry(val role: String, val text: String)
|
||||||
@@ -51,6 +54,19 @@ class GlassesApp : Application() {
|
|||||||
lateinit var downloadManager: GlassesDownloadManager
|
lateinit var downloadManager: GlassesDownloadManager
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** Модель Qwen3-ASR в filesDir/models: скачивает недостающее с зеркала при старте. */
|
||||||
|
lateinit var sttModel: SttModelDownloader
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Локальный Qwen3-ASR распознаватель очков (нативка лениво, один раз на процесс). */
|
||||||
|
lateinit var asr: GlassesAsr
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Локальная STT-сессия очков: микрофон копит PCM, распознавание на клике, 30s-лимит. */
|
||||||
|
lateinit var sttSession: GlassesSttSession
|
||||||
|
private set
|
||||||
|
|
||||||
|
|
||||||
/** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */
|
/** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */
|
||||||
lateinit var logCollector: BatchingLogCollector
|
lateinit var logCollector: BatchingLogCollector
|
||||||
private set
|
private set
|
||||||
@@ -157,6 +173,21 @@ class GlassesApp : Application() {
|
|||||||
attachLogCollector(logCollector)
|
attachLogCollector(logCollector)
|
||||||
pw.binom.viewmate.core.logCollector = logCollector
|
pw.binom.viewmate.core.logCollector = logCollector
|
||||||
log("app", "лог-коллектор запущен (пачки → телефон, спул: filesDir/logspool)")
|
log("app", "лог-коллектор запущен (пачки → телефон, спул: filesDir/logspool)")
|
||||||
|
|
||||||
|
// Модель Qwen3-ASR: если локально отсутствует — докачивать с зеркала (idempotent, по Range).
|
||||||
|
sttModel = SttModelDownloader(File(filesDir, "models"), bgScope)
|
||||||
|
sttModel.ensure()
|
||||||
|
|
||||||
|
// Локальное распознавание на очках: копит микрофонный PCM, распознаёт Qwen3-ом на клике
|
||||||
|
// (нативка лениво) и 30s-лимит на тишину. Результат улетает на [onSttResult] в MainActivity.
|
||||||
|
asr = GlassesAsr(File(filesDir, "models"))
|
||||||
|
sttSession = GlassesSttSession(
|
||||||
|
filesDir = filesDir,
|
||||||
|
scope = bgScope,
|
||||||
|
recognizer = { asr.recognize(it) },
|
||||||
|
modelReady = { sttModel.status.value.ready },
|
||||||
|
onResult = { text, auto -> mainActivity?.onSttResult(text, auto) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -48,15 +48,13 @@ import com.rayneo.arsdk.android.ui.activity.BaseEventActivity
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import pw.binom.viewmate.core.MediaCommand
|
import pw.binom.viewmate.core.MediaCommand
|
||||||
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||||||
import pw.binom.viewmate.core.protocol.SttAudio
|
import pw.binom.viewmate.core.protocol.SttText
|
||||||
import pw.binom.viewmate.core.protocol.StopStt
|
|
||||||
import pw.binom.viewmate.glasses.gestures.AssistantState
|
import pw.binom.viewmate.glasses.gestures.AssistantState
|
||||||
import pw.binom.viewmate.glasses.gestures.GestureDecision
|
import pw.binom.viewmate.glasses.gestures.GestureDecision
|
||||||
import pw.binom.viewmate.glasses.gestures.actionName
|
import pw.binom.viewmate.glasses.gestures.actionName
|
||||||
import pw.binom.viewmate.glasses.gestures.assistantMode
|
import pw.binom.viewmate.glasses.gestures.assistantMode
|
||||||
import pw.binom.viewmate.glasses.gestures.gestureDecision
|
import pw.binom.viewmate.glasses.gestures.gestureDecision
|
||||||
import pw.binom.viewmate.glasses.status.StatusPanelCompose
|
import pw.binom.viewmate.glasses.status.StatusPanelCompose
|
||||||
import pw.binom.viewmate.glasses.stt.SttMicStream
|
|
||||||
import pw.binom.viewmate.glasses.ui.AssistantOverlay
|
import pw.binom.viewmate.glasses.ui.AssistantOverlay
|
||||||
import pw.binom.viewmate.glasses.ui.Binocular
|
import pw.binom.viewmate.glasses.ui.Binocular
|
||||||
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
|
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
|
||||||
@@ -70,11 +68,6 @@ import pw.binom.viewmate.glasses.ui.panelOffsetX
|
|||||||
class MainActivity : BaseEventActivity() {
|
class MainActivity : BaseEventActivity() {
|
||||||
private var resumeAfterScreenOn = false
|
private var resumeAfterScreenOn = false
|
||||||
|
|
||||||
/** Поток аудио распознавания: микрофон, на эмуляторе — wav-файл из samples. */
|
|
||||||
private val sttMic = SttMicStream(lifecycleScope) { chunk ->
|
|
||||||
GlassesApp.instance.hostConnection.sendToHost(SttAudio(chunk))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DEBUG-имитация жестов (эмулятор без трекпада):
|
* DEBUG-имитация жестов (эмулятор без трекпада):
|
||||||
* `adb shell am broadcast -a pw.binom.viewmate.glasses.DEBUG_GESTURE --es type triple_click`
|
* `adb shell am broadcast -a pw.binom.viewmate.glasses.DEBUG_GESTURE --es type triple_click`
|
||||||
@@ -265,7 +258,7 @@ class MainActivity : BaseEventActivity() {
|
|||||||
app.sttText.value = ""
|
app.sttText.value = ""
|
||||||
app.assistantText.value = ""
|
app.assistantText.value = ""
|
||||||
app.answerScrollDelta.value = 0f
|
app.answerScrollDelta.value = 0f
|
||||||
sttMic.start(filesDir) { app.listening.value }
|
app.sttSession.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click (кино): плей/пауза — как сегодня. */
|
/** Click (кино): плей/пауза — как сегодня. */
|
||||||
@@ -276,15 +269,13 @@ class MainActivity : BaseEventActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click («слушаю»): закрыть микрофон, отправить фразу на телефон (STT → LLM). */
|
/** Click («слушаю»): закрыть микрофон, распознать локально, готовый текст → [SttText] на телефон. */
|
||||||
private fun sendPhrase() {
|
private fun sendPhrase() {
|
||||||
log("gesture", "Click → отправить фразу")
|
log("gesture", "Click → отправить фразу (распознавание локально на очках)")
|
||||||
sttMic.stop()
|
val app = GlassesApp.instance
|
||||||
GlassesApp.instance.listening.value = false
|
app.listening.value = false
|
||||||
GlassesApp.instance.thinking.value = true
|
app.thinking.value = true
|
||||||
lifecycleScope.launch {
|
app.sttSession.send()
|
||||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
|
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
|
||||||
@@ -296,7 +287,7 @@ class MainActivity : BaseEventActivity() {
|
|||||||
app.thinking.value = false
|
app.thinking.value = false
|
||||||
app.answerScrollDelta.value = 0f
|
app.answerScrollDelta.value = 0f
|
||||||
app.listening.value = true
|
app.listening.value = true
|
||||||
sttMic.start(filesDir) { app.listening.value }
|
app.sttSession.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -308,7 +299,7 @@ class MainActivity : BaseEventActivity() {
|
|||||||
val app = GlassesApp.instance
|
val app = GlassesApp.instance
|
||||||
app.thinking.value = false
|
app.thinking.value = false
|
||||||
app.listening.value = true
|
app.listening.value = true
|
||||||
sttMic.start(filesDir) { app.listening.value }
|
app.sttSession.start()
|
||||||
log("stt", "возврат в «слушаю» (SttCancel)")
|
log("stt", "возврат в «слушаю» (SttCancel)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,13 +312,38 @@ class MainActivity : BaseEventActivity() {
|
|||||||
private fun cancelDictation() {
|
private fun cancelDictation() {
|
||||||
log("gesture", "DoubleClick → отмена записи: возврат в «слушаю» (оверлей остаётся)")
|
log("gesture", "DoubleClick → отмена записи: возврат в «слушаю» (оверлей остаётся)")
|
||||||
val app = GlassesApp.instance
|
val app = GlassesApp.instance
|
||||||
sttMic.stop()
|
app.sttSession.cancel()
|
||||||
app.sttText.value = ""
|
app.sttText.value = ""
|
||||||
app.thinking.value = false
|
app.thinking.value = false
|
||||||
app.listening.value = true
|
app.listening.value = true
|
||||||
sttMic.start(filesDir) { app.listening.value }
|
app.sttSession.start()
|
||||||
lifecycleScope.launch {
|
}
|
||||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
|
|
||||||
|
/**
|
||||||
|
* Результат локального распознавания ([GlassesSttSession.onResult], на главном потоке).
|
||||||
|
* [text] — распознанный на очках текст (пустой — тишина/модель не готова), [auto] —
|
||||||
|
* сработал 30с-лимит. Текст уходит на телефон [SttText]; пустой по клику — остаёмся
|
||||||
|
* слушать, пустой после лимита — выход из режима.
|
||||||
|
*/
|
||||||
|
fun onSttResult(text: String, auto: Boolean) {
|
||||||
|
val app = GlassesApp.instance
|
||||||
|
app.sttText.value = text
|
||||||
|
when {
|
||||||
|
text.isNotBlank() -> {
|
||||||
|
app.listening.value = false
|
||||||
|
app.thinking.value = true
|
||||||
|
log("stt", "фраза распознана локально (auto=$auto): '${text.take(40)}' → SttText на телефон")
|
||||||
|
lifecycleScope.launch {
|
||||||
|
app.hostConnection.sendToHost(SttText(text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto -> finishAssistantMode("тишина 30с (очки) — выход из режима")
|
||||||
|
else -> {
|
||||||
|
log("stt", "клик без голоса — остаюсь в «слушаю»")
|
||||||
|
app.thinking.value = false
|
||||||
|
app.listening.value = true
|
||||||
|
app.sttSession.start()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,10 +356,10 @@ class MainActivity : BaseEventActivity() {
|
|||||||
*/
|
*/
|
||||||
fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима")
|
fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима")
|
||||||
|
|
||||||
/** Общая логика выхода из режима общения: стоп микрофона + сброс состояний. */
|
/** Общая логика выхода из режима общения: стоп сессии + сброс состояний. */
|
||||||
private fun finishAssistantMode(reason: String) {
|
private fun finishAssistantMode(reason: String) {
|
||||||
log("gesture", reason)
|
log("gesture", reason)
|
||||||
sttMic.stop()
|
GlassesApp.instance.sttSession.stop()
|
||||||
val app = GlassesApp.instance
|
val app = GlassesApp.instance
|
||||||
app.listening.value = false
|
app.listening.value = false
|
||||||
app.thinking.value = false
|
app.thinking.value = false
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package pw.binom.viewmate.glasses.gestures
|
|||||||
|
|
||||||
import com.rayneo.arsdk.android.touch.TempleAction
|
import com.rayneo.arsdk.android.touch.TempleAction
|
||||||
import pw.binom.viewmate.core.MediaCommand
|
import pw.binom.viewmate.core.MediaCommand
|
||||||
|
import pw.binom.viewmate.glasses.stt.SttModelStatus
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Режимы общения с ассистентом (матрица TASK-gestures-modes.md):
|
* Режимы общения с ассистентом (матрица TASK-gestures-modes.md):
|
||||||
@@ -159,6 +160,19 @@ fun overlayHeader(state: AssistantState): String? = when {
|
|||||||
else -> state.sttText.takeIf { it.isNotBlank() }
|
else -> state.sttText.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строка статуса модели Qwen3 для оверлея (показывается, когда модель не READY и
|
||||||
|
* микрофон открыт — распознавание гарантированно пустое): null — модель готова
|
||||||
|
* (ничего не показывать); DOWNLOADING — «качается N%»; MISSING — не скачана;
|
||||||
|
* FAILED — не скачалась (пойдут авто-повторы). Чистая функция — юнит-тесты без Android.
|
||||||
|
*/
|
||||||
|
fun modelStatusLine(status: SttModelStatus): String? = when (status.phase) {
|
||||||
|
SttModelStatus.Phase.READY -> null
|
||||||
|
SttModelStatus.Phase.DOWNLOADING -> "модель качается: ${status.percent}%"
|
||||||
|
SttModelStatus.Phase.MISSING -> "модель не скачана — распознавание недоступно"
|
||||||
|
SttModelStatus.Phase.FAILED -> "модель не скачалась — повторю автоматически"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Позиция скролла ответа после применения дельты [delta] (px): зажим в [0, maxScroll].
|
* Позиция скролла ответа после применения дельты [delta] (px): зажим в [0, maxScroll].
|
||||||
* [currentOffset], [maxScroll] — [androidx.compose.foundation.ScrollState.value/maxValue].
|
* [currentOffset], [maxScroll] — [androidx.compose.foundation.ScrollState.value/maxValue].
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package pw.binom.viewmate.glasses.stt
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import pw.binom.asr.qwen3.Qwen3Config
|
||||||
|
import pw.binom.asr.qwen3.createQwen3Sherpa
|
||||||
|
import pw.binom.viewmate.glasses.log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Локальный Qwen3-ASR на очках (asr-qwen3-android): recognizer — single-shot,
|
||||||
|
* тяжёлая нативка создаётся лениво, один раз на процесс. Модель читается из
|
||||||
|
* [modelsDir] (filesDir/models — иерархия как на телефоне, выкачивается
|
||||||
|
* [SttModelDownloader]).
|
||||||
|
*/
|
||||||
|
class GlassesAsr(
|
||||||
|
modelsDir: File,
|
||||||
|
numThreads: Int = 4,
|
||||||
|
) {
|
||||||
|
private val modelsDir = modelsDir
|
||||||
|
private val numThreads = numThreads
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Раз в процессе: открыть конфиг с путями 4 компонентов модели и загрузить
|
||||||
|
* нативку. by lazy — чтобы очки не грузили ~960 МБ при старте, если не слушаем.
|
||||||
|
*/
|
||||||
|
private val recognizer by lazy {
|
||||||
|
log("stt", "создаю Qwen3-recognizer (модель в ${modelsDir.absolutePath})")
|
||||||
|
createQwen3Sherpa(
|
||||||
|
Qwen3Config(
|
||||||
|
convFrontendPath = File(modelsDir, "conv_frontend.onnx").absolutePath,
|
||||||
|
encoderPath = File(modelsDir, "encoder.int8.onnx").absolutePath,
|
||||||
|
decoderPath = File(modelsDir, "decoder.int8.onnx").absolutePath,
|
||||||
|
tokenizerDir = File(modelsDir, "tokenizer").absolutePath,
|
||||||
|
numThreads = numThreads,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Распознать 16 кГц mono PCM. Возвращает текст (пустой — если модель не готова/тишина). */
|
||||||
|
fun recognize(samples: FloatArray, sampleRate: Int = 16000): String =
|
||||||
|
recognizer.recognize(samples, sampleRate)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package pw.binom.viewmate.glasses.stt
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import kotlin.concurrent.withLock
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import pw.binom.viewmate.core.stt.s16leToFloat
|
||||||
|
import pw.binom.viewmate.core.stt.splitPhrases
|
||||||
|
import pw.binom.viewmate.core.stt.trimEdges
|
||||||
|
import pw.binom.viewmate.glasses.log
|
||||||
|
|
||||||
|
/** Тонкий интерфейс микрофона: [SttMicStream] — продакшн, фейк — в JVM-тестах. */
|
||||||
|
interface GlassesMic {
|
||||||
|
fun start(filesDir: File, shouldRun: () -> Boolean)
|
||||||
|
fun stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Локальная STT-сессия очков: микрофон копит PCM на очках, по клику распознаём
|
||||||
|
* локально (Qwen3 через [recognizer]) и возвращаем готовый текст — телефон больше
|
||||||
|
* не участвует в распознавании. UX остаётся «говори → клик → текст».
|
||||||
|
*
|
||||||
|
* Заменяет старый путь: очки стримили сырой PCM чанками (SttAudio) на телефон,
|
||||||
|
* а телефон копил и распознавал. Теперь и накопление, и распознавание — на очках,
|
||||||
|
* на телефон уходит фактический текст (SttText). Пустой результат по клику —
|
||||||
|
* остаёмся слушать (как телефонный StopStt(cancel=false) с пустым стримером);
|
||||||
|
* пусто после 30s-лимита (timeout) — автозавершение.
|
||||||
|
*
|
||||||
|
* @param onResult результат распознавания: text — фактический текст (может быть
|
||||||
|
* пустым, если тишина/модель не готова), auto=true — лимит 30 с (timeout).
|
||||||
|
*/
|
||||||
|
class GlassesSttSession(
|
||||||
|
private val filesDir: File,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val recognizer: (FloatArray) -> String,
|
||||||
|
private val modelReady: () -> Boolean,
|
||||||
|
private val onResult: (text: String, auto: Boolean) -> Unit,
|
||||||
|
private val micFactory: (scope: CoroutineScope, onChunk: (ByteArray) -> Unit) -> GlassesMic = { s, chunk -> SttMicStream(s, chunk) },
|
||||||
|
) {
|
||||||
|
/** Лимит сессии: как на телефоне (SttStreamer 30s watchdog на тишину). */
|
||||||
|
private val sessionLimitMs = 30_000L
|
||||||
|
|
||||||
|
private var mic: GlassesMic? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [pcm] — на очках (поток микрофона [GlassesMic] пишет чанки), а [finish] читает
|
||||||
|
* его на главном/IO-потоке: без лоча чтение могло бы потерять последние чанки.
|
||||||
|
*/
|
||||||
|
private val pcmLock = ReentrantLock()
|
||||||
|
private var pcm = ByteArray(0)
|
||||||
|
private var watchdog: Job? = null
|
||||||
|
@Volatile
|
||||||
|
private var running = false
|
||||||
|
|
||||||
|
val isRunning: Boolean get() = running
|
||||||
|
|
||||||
|
/** Начать слушать: чистый буфер + микрофон + таймер автозавершения. */
|
||||||
|
fun start() {
|
||||||
|
if (running) return
|
||||||
|
running = true
|
||||||
|
pcmLock.withLock { pcm = ByteArray(0) }
|
||||||
|
val m = micFactory(scope) { chunk -> pcmLock.withLock { pcm = pcm + chunk } }
|
||||||
|
mic = m
|
||||||
|
m.start(filesDir) { running }
|
||||||
|
watchdog = scope.launch {
|
||||||
|
delay(sessionLimitMs)
|
||||||
|
finish(auto = true)
|
||||||
|
}
|
||||||
|
log("stt", "STT-сессия очков: слушаю (лимит ${sessionLimitMs / 1000} с)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Клик: распознать накопленный PCM и вернуть текст (пустой — остаёмся слушать). */
|
||||||
|
fun send() = finish(auto = false)
|
||||||
|
|
||||||
|
/** Отмена (двойной клик): выбросить буфер, вернуться в слушание. */
|
||||||
|
fun cancel() {
|
||||||
|
stopMic()
|
||||||
|
watchdog?.cancel()
|
||||||
|
pcmLock.withLock { pcm = ByteArray(0) }
|
||||||
|
log("stt", "STT-сессия очков: отмена")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Полный стоп (выход из режима ассистента). */
|
||||||
|
fun stop() {
|
||||||
|
running = false
|
||||||
|
stopMic()
|
||||||
|
watchdog?.cancel()
|
||||||
|
pcmLock.withLock { pcm = ByteArray(0) }
|
||||||
|
log("stt", "STT-сессия очков: закрыта")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish(auto: Boolean) {
|
||||||
|
if (!running) return
|
||||||
|
running = false
|
||||||
|
stopMic()
|
||||||
|
watchdog?.cancel()
|
||||||
|
val audio = pcmLock.withLock { pcm.also { pcm = ByteArray(0) } }
|
||||||
|
scope.launch(Dispatchers.Default) {
|
||||||
|
val text = if (audio.isNotEmpty() && modelReady()) {
|
||||||
|
val phrases = splitPhrases(trimEdges(s16leToFloat(audio)))
|
||||||
|
phrases.joinToString(" ") { recognizer(it) }
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) { onResult(text, auto) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopMic() {
|
||||||
|
mic?.stop()
|
||||||
|
mic = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,13 +30,13 @@ import kotlin.math.roundToInt
|
|||||||
class SttMicStream(
|
class SttMicStream(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val onChunk: suspend (ByteArray) -> Unit,
|
private val onChunk: suspend (ByteArray) -> Unit,
|
||||||
) {
|
) : GlassesMic {
|
||||||
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) {
|
override fun start(filesDir: File, shouldRun: () -> Boolean) {
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
val minBuffer = AudioRecord.getMinBufferSize(
|
val minBuffer = AudioRecord.getMinBufferSize(
|
||||||
MIC_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
|
MIC_SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
|
||||||
@@ -96,7 +96,7 @@ class SttMicStream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
override fun stop() {
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
job = null
|
job = null
|
||||||
// dump закрывает цикл в finally (иначе «Stream Closed»: stop() закрывает раньше IO-цикла)
|
// dump закрывает цикл в finally (иначе «Stream Closed»: stop() закрывает раньше IO-цикла)
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package pw.binom.viewmate.glasses.stt
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlin.concurrent.Volatile
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import pw.binom.viewmate.glasses.log
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модель Qwen3-ASR 0.6B int8 для очек: 4 компонента в [filesDir]/models
|
||||||
|
* (та же иерархия, что на телефоне — `PhoneApp.ensureStt`: conv/encoder/decoder +
|
||||||
|
* tokenizer/). Скачиваются с зеркала [SttModel.BASE_URL] при отсутствии локально:
|
||||||
|
* докачка с места обрыва (Range), проверка SHA-256, сквозной прогресс.
|
||||||
|
*
|
||||||
|
* Зеркало — внутренний `static.binom.pw/models/qwen3_06/` (обычная иерархия
|
||||||
|
* корневых .onnx + подкаталог tokenizer/), URL зашит константой.
|
||||||
|
*/
|
||||||
|
object SttModel {
|
||||||
|
/** Корень зеркала модели: обычные имена файлов + подкаталог tokenizer/. */
|
||||||
|
const val BASE_URL = "https://static.binom.pw/models/qwen3_06"
|
||||||
|
|
||||||
|
/** Иерархия модели + размер и SHA-256 каждого файла (контроль целостности). */
|
||||||
|
internal val FILES: List<ModelFile> = listOf(
|
||||||
|
ModelFile("conv_frontend.onnx", 44_148_281L, "d22dc4423e0940e49884e903d2ea2f7e5567c14fc1aed97e4e26d6b8f208ef9e"),
|
||||||
|
ModelFile("encoder.int8.onnx", 182_491_662L, "60748d3e6744a57c9c91e1b17424a6c2990567e8adceb0783940c03ed98fa9d9"),
|
||||||
|
ModelFile("decoder.int8.onnx", 755_914_231L, "4f6885be5959ae26af3089d38ee7972c5fafbeeb1cf8d5e76eab6d8b61ca5771"),
|
||||||
|
ModelFile("tokenizer/merges.txt", 1_671_853L, "8831e4f1a044471340f7c0a83d7bd71306a5b867e95fd870f74d0c5308a904d5"),
|
||||||
|
ModelFile("tokenizer/vocab.json", 2_776_833L, "ca10d7e9fb3ed18575dd1e277a2579c16d108e32f27439684afa0e10b1440910"),
|
||||||
|
ModelFile("tokenizer/tokenizer_config.json", 12_487L, "4942d005604266809309cabc9f4e9cb89ce855d59b14681fdc0e1cc62ea26c4c"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Один файл модели: относительный путь (в models/), размер и SHA-256. */
|
||||||
|
internal data class ModelFile(
|
||||||
|
val relPath: String,
|
||||||
|
val sizeBytes: Long,
|
||||||
|
val sha256Hex: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Быстрый «довольны ли файлом»: есть + размер совпадает (SHA проверяем на докатке). */
|
||||||
|
internal fun isSatisfied(modelsDir: File, f: ModelFile): Boolean {
|
||||||
|
val final = File(modelsDir, f.relPath)
|
||||||
|
return final.isFile && final.length() == f.sizeBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Какие файлы [files] нужны (отсутствуют или битые по размеру). */
|
||||||
|
internal fun whichFilesNeeded(modelsDir: File, files: List<ModelFile>): List<ModelFile> =
|
||||||
|
files.filter { !isSatisfied(modelsDir, it) }
|
||||||
|
|
||||||
|
/** Модель на диске полностью: все файлы [files] на месте с верным размером. */
|
||||||
|
internal fun isModelComplete(modelsDir: File, files: List<ModelFile> = SttModel.FILES): Boolean =
|
||||||
|
whichFilesNeeded(modelsDir, files).isEmpty()
|
||||||
|
|
||||||
|
/** Все байты всех файлов модели (для сквозного процента). */
|
||||||
|
internal fun modelTotalBytes(files: List<ModelFile> = SttModel.FILES): Long = files.sumOf { it.sizeBytes }
|
||||||
|
|
||||||
|
/** SHA-256 файла (потоком, без загрузки всего в память). */
|
||||||
|
internal fun sha256Hex(file: File): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
file.inputStream().use { input ->
|
||||||
|
val buf = ByteArray(1 shl 16)
|
||||||
|
while (true) {
|
||||||
|
val n = input.read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
digest.update(buf, 0, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Статус модели: что происходит со скачиванием (для индикатора в UI очков). */
|
||||||
|
data class SttModelStatus(
|
||||||
|
val phase: Phase,
|
||||||
|
val percent: Int = 0,
|
||||||
|
val detail: String = "",
|
||||||
|
) {
|
||||||
|
enum class Phase { MISSING, DOWNLOADING, READY, FAILED }
|
||||||
|
|
||||||
|
val ready: Boolean get() = phase == Phase.READY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скачивание модели Qwen3-ASR в [modelsDir] (filesDir/models). Прогресс — в [status].
|
||||||
|
* Идиомпотентно: [ensure] не делает ничего, если модель уже на месте; иначе докачивает
|
||||||
|
* недостающие/битые файлы с Range и проверяет SHA-256.
|
||||||
|
*/
|
||||||
|
class SttModelDownloader(
|
||||||
|
private val modelsDir: File,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val okHttp: OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(0, TimeUnit.MILLISECONDS) // большие файлы, паузы в потоке
|
||||||
|
.build(),
|
||||||
|
) {
|
||||||
|
private val _status = MutableStateFlow(SttModelStatus(SttModelStatus.Phase.MISSING))
|
||||||
|
val status: StateFlow<SttModelStatus> = _status.asStateFlow()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var downloadJob: Job? = null
|
||||||
|
|
||||||
|
/** Таймер авто-повтора после ошибки (сеть могла появиться позже — см. [scheduleRetry]). */
|
||||||
|
@Volatile
|
||||||
|
private var retryJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Пауза до авто-повтора скачивания после FAILED: сеть могла появиться позже. */
|
||||||
|
private const val RETRY_DELAY_MS = 15_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Перечитать с диска: READY (готово) или MISSING (не хватает). */
|
||||||
|
fun refresh() {
|
||||||
|
_status.value = if (isModelComplete(modelsDir)) {
|
||||||
|
SttModelStatus(SttModelStatus.Phase.READY, 100, "модель готова")
|
||||||
|
} else {
|
||||||
|
SttModelStatus(SttModelStatus.Phase.MISSING, overallPercent(), "нет локально — скачаю при старте")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Скачать недостающее (idempotent; повторный вызов пока идёт — игнор). */
|
||||||
|
fun ensure() {
|
||||||
|
if (isModelComplete(modelsDir)) {
|
||||||
|
refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (downloadJob?.isActive == true) return
|
||||||
|
downloadJob = scope.launch {
|
||||||
|
try {
|
||||||
|
val total = modelTotalBytes()
|
||||||
|
val needed = whichFilesNeeded(modelsDir, SttModel.FILES)
|
||||||
|
var done = total - needed.sumOf { it.sizeBytes } // байты уже готовых файлов
|
||||||
|
needed.forEach { f ->
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
downloadOne(f) { fileDone ->
|
||||||
|
val overall = ((done + fileDone) * 100 / total).toInt().coerceIn(0, 100)
|
||||||
|
_status.value = SttModelStatus(SttModelStatus.Phase.DOWNLOADING, overall, "${f.relPath}: $overall%")
|
||||||
|
}
|
||||||
|
done += f.sizeBytes
|
||||||
|
log("stt", "модель: скачан ${f.relPath} (${f.sizeBytes} Б)")
|
||||||
|
}
|
||||||
|
_status.value = SttModelStatus(SttModelStatus.Phase.READY, 100, "модель готова")
|
||||||
|
log("stt", "модель Qwen3-ASR готова в ${modelsDir.absolutePath}")
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("stt", "ошибка скачивания модели: ${e.message}")
|
||||||
|
_status.value = SttModelStatus(SttModelStatus.Phase.FAILED, overallPercent(), "ошибка: ${e.message}")
|
||||||
|
scheduleRetry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Авто-повтор после [SttModelStatus.Phase.FAILED]: сеть могла появиться позже
|
||||||
|
* (Wi-Fi очков выключен при старте, DNS не резолвится — фиксированные случаи).
|
||||||
|
* Один таймер: новый провал перезапускает его; при готовности/идущем скачивании
|
||||||
|
* [ensure] — no-op, так что повторы сами завершаются.
|
||||||
|
*/
|
||||||
|
private fun scheduleRetry() {
|
||||||
|
retryJob?.cancel()
|
||||||
|
retryJob = scope.launch {
|
||||||
|
delay(RETRY_DELAY_MS)
|
||||||
|
log("stt", "модель: авто-повтор скачивания (ошибка могла быть сессионной — сеть)")
|
||||||
|
ensure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Скачать один файл: Range-докачка в .part, SHA-256, rename в финальный. onProgress — байты файла. */
|
||||||
|
private suspend fun downloadOne(f: ModelFile, onProgress: (Long) -> Unit) {
|
||||||
|
val parent = File(modelsDir, f.relPath).parentFile
|
||||||
|
parent?.mkdirs()
|
||||||
|
val final = File(modelsDir, f.relPath)
|
||||||
|
val part = File(modelsDir, "$f.relPath.part")
|
||||||
|
val existing = if (part.exists()) part.length() else 0L
|
||||||
|
|
||||||
|
val builder = Request.Builder().url(SttModel.BASE_URL + "/" + f.relPath)
|
||||||
|
if (existing > 0) builder.header("Range", "bytes=$existing-")
|
||||||
|
|
||||||
|
okHttp.newCall(builder.build()).execute().use { response ->
|
||||||
|
val code = response.code
|
||||||
|
if (code != 200 && code != 206) error("HTTP $code для ${f.relPath}")
|
||||||
|
val body = response.body
|
||||||
|
|
||||||
|
val resumed = code == 206 && existing > 0
|
||||||
|
val input = body.byteStream()
|
||||||
|
FileOutputStream(part, resumed).use { out ->
|
||||||
|
var done = if (resumed) existing else 0L
|
||||||
|
onProgress(done)
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
while (true) {
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
out.write(buffer, 0, read)
|
||||||
|
done += read
|
||||||
|
onProgress(done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sha256Hex(part) != f.sha256Hex) {
|
||||||
|
part.delete()
|
||||||
|
error("SHA-256 не совпал для ${f.relPath}")
|
||||||
|
}
|
||||||
|
if (final.exists()) final.delete()
|
||||||
|
if (!part.renameTo(final)) {
|
||||||
|
part.copyTo(final, overwrite = true)
|
||||||
|
part.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сквозной процент: сколько всех байтов модели уже на диске (по размеру). */
|
||||||
|
private fun overallPercent(): Int {
|
||||||
|
val total = modelTotalBytes()
|
||||||
|
if (total <= 0) return 0
|
||||||
|
val have = SttModel.FILES.filter { isSatisfied(modelsDir, it) }.sumOf { it.sizeBytes }
|
||||||
|
return (have * 100 / total).toInt().coerceIn(0, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import pw.binom.viewmate.glasses.ChatEntry
|
|||||||
import pw.binom.viewmate.glasses.GlassesApp
|
import pw.binom.viewmate.glasses.GlassesApp
|
||||||
import pw.binom.viewmate.glasses.gestures.AssistantState
|
import pw.binom.viewmate.glasses.gestures.AssistantState
|
||||||
import pw.binom.viewmate.glasses.gestures.overlayHeader
|
import pw.binom.viewmate.glasses.gestures.overlayHeader
|
||||||
|
import pw.binom.viewmate.glasses.gestures.modelStatusLine
|
||||||
import pw.binom.viewmate.glasses.gestures.overlayVisible
|
import pw.binom.viewmate.glasses.gestures.overlayVisible
|
||||||
import pw.binom.viewmate.glasses.gestures.scrollOffset
|
import pw.binom.viewmate.glasses.gestures.scrollOffset
|
||||||
|
|
||||||
@@ -55,6 +56,10 @@ fun AssistantOverlay() {
|
|||||||
val chatHistory by app.chatHistory.collectAsState()
|
val chatHistory by app.chatHistory.collectAsState()
|
||||||
val state = AssistantState(listening, thinking, sttText, assistantText)
|
val state = AssistantState(listening, thinking, sttText, assistantText)
|
||||||
if (!overlayVisible(state)) return
|
if (!overlayVisible(state)) return
|
||||||
|
val modelStatus by app.sttModel.status.collectAsState()
|
||||||
|
// Строка статуса модели — только когда микрофон открыт («слушаю»): распознавание
|
||||||
|
// локально, поэтому модель не READY = клик гарантированно пустой — объясняем почему.
|
||||||
|
val modelLine = if (state.listening) modelStatusLine(modelStatus) else null
|
||||||
// Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды
|
// Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды
|
||||||
// (левый/правый глаз), поэтому scrollState и коллекторы должны существовать
|
// (левый/правый глаз), поэтому scrollState и коллекторы должны существовать
|
||||||
// в единственном экземпляре — иначе глаза скроллят по-разному.
|
// в единственном экземпляре — иначе глаза скроллят по-разному.
|
||||||
@@ -81,7 +86,7 @@ fun AssistantOverlay() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
overlayBox(app, state, chatHistory, scrollState)
|
overlayBox(app, state, chatHistory, scrollState, modelLine)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,6 +102,7 @@ private fun overlayBox(
|
|||||||
state: AssistantState,
|
state: AssistantState,
|
||||||
chatHistory: List<ChatEntry>,
|
chatHistory: List<ChatEntry>,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
|
modelLine: String?,
|
||||||
) {
|
) {
|
||||||
Binocular(scale = false) {
|
Binocular(scale = false) {
|
||||||
val scale by app.scale.collectAsState()
|
val scale by app.scale.collectAsState()
|
||||||
@@ -119,7 +125,7 @@ private fun overlayBox(
|
|||||||
// вертикаль (vertical) — только для видео; оверлей всегда у верха
|
// вертикаль (vertical) — только для видео; оверлей всегда у верха
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
AssistantOverlayContent(state, chatHistory, scrollState)
|
AssistantOverlayContent(state, chatHistory, scrollState, modelLine)
|
||||||
|
|
||||||
if (state.listening) {
|
if (state.listening) {
|
||||||
Box(
|
Box(
|
||||||
@@ -140,6 +146,7 @@ private fun AssistantOverlayContent(
|
|||||||
state: AssistantState,
|
state: AssistantState,
|
||||||
chatHistory: List<ChatEntry>,
|
chatHistory: List<ChatEntry>,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
|
modelLine: String?,
|
||||||
) {
|
) {
|
||||||
val hasHistory = chatHistory.isNotEmpty()
|
val hasHistory = chatHistory.isNotEmpty()
|
||||||
val fallbackAnswer = chatHistory.isEmpty() && state.assistantText.isNotBlank()
|
val fallbackAnswer = chatHistory.isEmpty() && state.assistantText.isNotBlank()
|
||||||
@@ -160,6 +167,17 @@ private fun AssistantOverlayContent(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Строка статуса модели (только «слушаю» + модель не READY) — объясняет, почему
|
||||||
|
// распознавание сейчас не работает и что происходит (качается / не скачалась).
|
||||||
|
if (!modelLine.isNullOrBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = modelLine,
|
||||||
|
color = Color(0xFFB0BEC5),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (hasHistory) {
|
if (hasHistory) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
+28
@@ -15,6 +15,7 @@ import kotlin.test.assertFalse
|
|||||||
import kotlin.test.assertNull
|
import kotlin.test.assertNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
import pw.binom.viewmate.core.MediaCommand
|
import pw.binom.viewmate.core.MediaCommand
|
||||||
|
import pw.binom.viewmate.glasses.stt.SttModelStatus
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Карта жеста (docs/gestures-and-modes.md): переходы состояний, свайпы → ±30с в кино,
|
* Карта жеста (docs/gestures-and-modes.md): переходы состояний, свайпы → ±30с в кино,
|
||||||
@@ -227,6 +228,33 @@ class AssistantGesturesTest {
|
|||||||
assertNull(overlayHeader(movie))
|
assertNull(overlayHeader(movie))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ————— строка статуса модели —————
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun modelStatusLineReadyIsNull() {
|
||||||
|
assertNull(modelStatusLine(SttModelStatus(SttModelStatus.Phase.READY, 100)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun modelStatusLineDownloadingShowsPercent() {
|
||||||
|
assertEquals(
|
||||||
|
"модель качается: 45%",
|
||||||
|
modelStatusLine(SttModelStatus(SttModelStatus.Phase.DOWNLOADING, 45)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun modelStatusLineMissingAndFailed() {
|
||||||
|
assertEquals(
|
||||||
|
"модель не скачана — распознавание недоступно",
|
||||||
|
modelStatusLine(SttModelStatus(SttModelStatus.Phase.MISSING)),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"модель не скачалась — повторю автоматически",
|
||||||
|
modelStatusLine(SttModelStatus(SttModelStatus.Phase.FAILED, 10, "ошибка: нет сети")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ————— скролл ответа —————
|
// ————— скролл ответа —————
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package pw.binom.viewmate.glasses.stt
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class SttModelProviderTest {
|
||||||
|
|
||||||
|
private fun newModelDir(): File {
|
||||||
|
val dir = Files.createTempDirectory("stt-model-test").toFile()
|
||||||
|
dir.deleteOnExit()
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun smallFiles() = listOf(
|
||||||
|
ModelFile("a.onnx", 4L, "sha-a"),
|
||||||
|
ModelFile("tokenizer/tok.json", 5L, "sha-b"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun write(modelDir: File, relPath: String, size: Int) {
|
||||||
|
val f = File(modelDir, relPath)
|
||||||
|
f.parentFile?.mkdirs()
|
||||||
|
f.writeBytes(ByteArray(size))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun manifestIsSane() {
|
||||||
|
assertEquals(6, SttModel.FILES.size)
|
||||||
|
assertEquals(6, SttModel.FILES.map { it.relPath }.toSet().size, "пути дублируются")
|
||||||
|
SttModel.FILES.forEach { f ->
|
||||||
|
assertTrue(f.sha256Hex.length == 64, "некорректный SHA-256: ${f.relPath}")
|
||||||
|
assertTrue(f.sizeBytes > 0, "нулевой размер: ${f.relPath}")
|
||||||
|
}
|
||||||
|
// Иерархия 1:1 с filesDir/models телефона: 3 корневых .onnx + tokenizer/ (3 файла).
|
||||||
|
assertTrue(SttModel.FILES.any { it.relPath == "conv_frontend.onnx" })
|
||||||
|
assertTrue(SttModel.FILES.any { it.relPath == "tokenizer/merges.txt" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyDirNeedsAllFiles() {
|
||||||
|
val dir = newModelDir()
|
||||||
|
val files = smallFiles()
|
||||||
|
val needed = whichFilesNeeded(dir, files)
|
||||||
|
assertEquals(files, needed)
|
||||||
|
assertTrue(!isModelComplete(dir, files))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun completeDirNeedsNothing() {
|
||||||
|
val dir = newModelDir()
|
||||||
|
val files = smallFiles()
|
||||||
|
write(dir, "a.onnx", 4)
|
||||||
|
write(dir, "tokenizer/tok.json", 5)
|
||||||
|
assertEquals(emptyList(), whichFilesNeeded(dir, files))
|
||||||
|
assertTrue(isModelComplete(dir, files))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wrongSizeTreatedAsNeeded() {
|
||||||
|
val dir = newModelDir()
|
||||||
|
val files = smallFiles()
|
||||||
|
write(dir, "a.onnx", 3) // короче ожидаемых 4
|
||||||
|
write(dir, "tokenizer/tok.json", 5)
|
||||||
|
val needed = whichFilesNeeded(dir, files)
|
||||||
|
assertEquals(listOf("a.onnx"), needed.map { it.relPath })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun modelTotalBytesIsSum() {
|
||||||
|
assertEquals(9L, modelTotalBytes(smallFiles()))
|
||||||
|
assertTrue(
|
||||||
|
modelTotalBytes() in 900_000_000..1_000_000_000,
|
||||||
|
"сумма байт модели не в ожидаемом порядке: ${modelTotalBytes()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sha256HexOfKnownInput() {
|
||||||
|
val dir = newModelDir()
|
||||||
|
val f = File(dir, "known.bin")
|
||||||
|
f.writeBytes("abc".encodeToByteArray())
|
||||||
|
assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", sha256Hex(f))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
@@ -7,6 +8,20 @@ plugins {
|
|||||||
alias(libs.plugins.compose.compiler)
|
alias(libs.plugins.compose.compiler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Подпись release ключом Binom (assembleRelease сам подпишет APK, v2+v3). Кестор + креды —
|
||||||
|
// из скилла android-sign (вне git); переопределение: -Pbinom.keystore.dir=... / env BINOM_KEYSTORE_DIR.
|
||||||
|
val binomCertsDir: File = File(
|
||||||
|
(project.findProperty("binom.keystore.dir") as? String)
|
||||||
|
?: System.getenv("BINOM_KEYSTORE_DIR")
|
||||||
|
?: "${System.getProperty("user.home")}/.config/opencode/skills/android-sign/certs"
|
||||||
|
)
|
||||||
|
val binomStoreFile = File(binomCertsDir, "android-release.keystore")
|
||||||
|
val binomCreds: Map<String, String> =
|
||||||
|
(File(binomCertsDir, "creds.env").let { c -> if (binomStoreFile.exists() && c.exists())
|
||||||
|
c.readLines().filter { it.isNotBlank() && !it.startsWith("#") && it.contains('=') }
|
||||||
|
.associate { val i = it.indexOf('='); it.substring(0, i) to it.substring(i + 1).trim().removeSurrounding("\"") }
|
||||||
|
else emptyMap() })
|
||||||
|
|
||||||
// LLM-ключ из local.properties (llm.apiKey) — файл вне git; пустой = ещё не задан.
|
// LLM-ключ из local.properties (llm.apiKey) — файл вне git; пустой = ещё не задан.
|
||||||
// providers.gradleProperty не читает local.properties, поэтому грузим файл вручную.
|
// providers.gradleProperty не читает local.properties, поэтому грузим файл вручную.
|
||||||
val llmApiKey: String = run {
|
val llmApiKey: String = run {
|
||||||
@@ -47,9 +62,30 @@ android {
|
|||||||
buildConfigField("String", "LLM_MODEL", "\"$llmModel\"")
|
buildConfigField("String", "LLM_MODEL", "\"$llmModel\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Подпись release ключом Binom (всё из binomStoreFile/binomCreds выше).
|
||||||
|
// Без кестора binomCreds пусто → release остаётся неподписанным.
|
||||||
|
if (binomCreds.isNotEmpty()) {
|
||||||
|
val binomAlias = binomCreds.getValue("ANDROID_KEYSTORE_ALIAS")
|
||||||
|
val binomStorePass = binomCreds.getValue("ANDROID_KEYSTORE_PASSWORD")
|
||||||
|
val binomKeyPass = binomCreds["ANDROID_KEY_PASSWORD"] ?: binomStorePass
|
||||||
|
signingConfigs {
|
||||||
|
create("binom") {
|
||||||
|
storeFile = binomStoreFile
|
||||||
|
storePassword = binomStorePass
|
||||||
|
keyAlias = binomAlias
|
||||||
|
keyPassword = binomKeyPass
|
||||||
|
enableV1Signing = false
|
||||||
|
enableV2Signing = true
|
||||||
|
enableV3Signing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
|
if (binomCreds.isNotEmpty()) {
|
||||||
|
signingConfig = signingConfigs.getByName("binom")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +133,9 @@ dependencies {
|
|||||||
// RayNeo Mercury BLE protocol (RayNeo X2): pair/connect/команды с телефона
|
// RayNeo Mercury BLE protocol (RayNeo X2): pair/connect/команды с телефона
|
||||||
implementation("pw.binom.mercury:rayneo-mercury:0.1.0-SNAPSHOT")
|
implementation("pw.binom.mercury:rayneo-mercury:0.1.0-SNAPSHOT")
|
||||||
|
|
||||||
|
// QR-код для паринга очков (pairCode) — рендер BitMatrix->Bitmap
|
||||||
|
implementation("com.google.zxing:core:3.5.2")
|
||||||
|
|
||||||
// Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
|
// Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
|
||||||
implementation(libs.koog.openai.client)
|
implementation(libs.koog.openai.client)
|
||||||
implementation(libs.koog.http.client.okhttp)
|
implementation(libs.koog.http.client.okhttp)
|
||||||
|
|||||||
@@ -121,9 +121,62 @@ object DebugHttpServer {
|
|||||||
// --- Mercury BLE (полевой тест либы rayneo-mercury) ---
|
// --- Mercury BLE (полевой тест либы rayneo-mercury) ---
|
||||||
method == "GET" && path.startsWith("/mercury/status") -> {
|
method == "GET" && path.startsWith("/mercury/status") -> {
|
||||||
val s = MercuryBridge.state.value
|
val s = MercuryBridge.state.value
|
||||||
response = """{"connected":${s.connected},"glasses":${s.glassesName?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)},"error":${s.lastError?.let { jsonEscape(it) } ?: "null"}}"""
|
response = """{"connected":${s.connected},"glasses":${s.glassesName?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)},"error":${s.lastError?.let { jsonEscape(it) } ?: "null"},"pairing":${s.pairCode != null},"pairCode":${s.pairCode?.let { "\"$it\"" } ?: "null"},"realAddress":${s.realAddress?.let { "\"$it\"" } ?: "null"}}"""
|
||||||
code = 200
|
code = 200
|
||||||
}
|
}
|
||||||
|
method == "GET" && path.startsWith("/mercury/bonded") -> {
|
||||||
|
val bonds = MercuryBridge.bondedAddresses()
|
||||||
|
val items = bonds.joinToString(",") { (addr, name) ->
|
||||||
|
"""{"address":"$addr","name":${name?.let { jsonEscape(it) } ?: "null"}}"""
|
||||||
|
}
|
||||||
|
response = """{"count":${bonds.size},"bonds":[$items]}"""
|
||||||
|
code = 200
|
||||||
|
}
|
||||||
|
method == "GET" && path.startsWith("/mercury/discover") -> {
|
||||||
|
val seconds = queryParams["time"]?.toIntOrNull() ?: 5
|
||||||
|
val found = MercuryBridge.discoverGlasses(seconds * 1_000L)
|
||||||
|
val items = found.joinToString(",") { (name, addr) ->
|
||||||
|
"""{"name":${name?.let { jsonEscape(it) } ?: "null"},"address":"$addr"}"""
|
||||||
|
}
|
||||||
|
val s = MercuryBridge.state.value
|
||||||
|
response = """{"count":${found.size},"found":[$items],"connected":${s.connected},"status":${jsonEscape(s.statusText)}}"""
|
||||||
|
code = 200
|
||||||
|
}
|
||||||
|
method == "POST" && path.startsWith("/mercury/pair/stop") -> {
|
||||||
|
MercuryBridge.cancelPairing()
|
||||||
|
val s = MercuryBridge.state.value
|
||||||
|
response = """{"ok":true,"status":${jsonEscape(s.statusText)}}"""
|
||||||
|
code = 200
|
||||||
|
}
|
||||||
|
method == "POST" && path.startsWith("/mercury/pair/classic") -> {
|
||||||
|
val address = queryParams["address"]
|
||||||
|
if (address.isNullOrBlank()) {
|
||||||
|
response = """{"error":"нет параметра address"}"""
|
||||||
|
code = 400
|
||||||
|
} else {
|
||||||
|
val ok = MercuryBridge.pairClassic(address)
|
||||||
|
val s = MercuryBridge.state.value
|
||||||
|
response = """{"ok":$ok,"address":"$address","connected":${s.connected},"status":${jsonEscape(s.statusText)}}"""
|
||||||
|
code = 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
method == "POST" && path.startsWith("/mercury/pair") -> {
|
||||||
|
val pairCode = MercuryBridge.startPairing()
|
||||||
|
val s = MercuryBridge.state.value
|
||||||
|
response = """{"ok":${pairCode != null},"pairCode":${pairCode?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)}}"""
|
||||||
|
code = 200
|
||||||
|
}
|
||||||
|
method == "POST" && path.startsWith("/mercury/unpair") -> {
|
||||||
|
val address = queryParams["address"]
|
||||||
|
if (address.isNullOrBlank()) {
|
||||||
|
response = """{"error":"нет параметра address"}"""
|
||||||
|
code = 400
|
||||||
|
} else {
|
||||||
|
val ok = MercuryBridge.unpair(address)
|
||||||
|
response = """{"ok":$ok,"address":"$address"}"""
|
||||||
|
code = 200
|
||||||
|
}
|
||||||
|
}
|
||||||
method == "POST" && path.startsWith("/mercury/connect") -> {
|
method == "POST" && path.startsWith("/mercury/connect") -> {
|
||||||
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.connectFirst() }
|
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.connectFirst() }
|
||||||
response = """{"ok":$ok,"connected":${MercuryBridge.state.value.connected},"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
|
response = """{"ok":$ok,"connected":${MercuryBridge.state.value.connected},"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
|
||||||
@@ -150,6 +203,18 @@ object DebugHttpServer {
|
|||||||
response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
|
response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
|
||||||
code = 200
|
code = 200
|
||||||
}
|
}
|
||||||
|
method == "POST" && path.startsWith("/mercury/wifi") -> {
|
||||||
|
val enable = "true".equals(queryParams["enable"], ignoreCase = true)
|
||||||
|
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.wifiToggle(enable) }
|
||||||
|
response = """{"ok":$ok,"enable":$enable,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
|
||||||
|
code = 200
|
||||||
|
}
|
||||||
|
method == "POST" && path.startsWith("/mercury/wifiscan") -> {
|
||||||
|
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.wifiScan() }
|
||||||
|
val s = MercuryBridge.state.value
|
||||||
|
response = """{"ok":$ok,"networks":${s.wifiNetworks.size},"status":${jsonEscape(s.statusText)}}"""
|
||||||
|
code = 200
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
response = """{"error":"нет такого пути: $method $path"}"""
|
response = """{"error":"нет такого пути: $method $path"}"""
|
||||||
code = 404
|
code = 404
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import pw.binom.viewmate.core.protocol.PlaybackPosition
|
|||||||
import pw.binom.viewmate.core.protocol.SttAudio
|
import pw.binom.viewmate.core.protocol.SttAudio
|
||||||
import pw.binom.viewmate.core.protocol.SttCancel
|
import pw.binom.viewmate.core.protocol.SttCancel
|
||||||
import pw.binom.viewmate.core.protocol.SttDone
|
import pw.binom.viewmate.core.protocol.SttDone
|
||||||
|
import pw.binom.viewmate.core.protocol.SttText
|
||||||
import pw.binom.viewmate.core.protocol.StopStt
|
import pw.binom.viewmate.core.protocol.StopStt
|
||||||
import pw.binom.viewmate.core.protocol.Welcome
|
import pw.binom.viewmate.core.protocol.Welcome
|
||||||
import pw.binom.viewmate.core.protocol.protocolJson
|
import pw.binom.viewmate.core.protocol.protocolJson
|
||||||
@@ -235,6 +236,19 @@ class GlassesHub(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Новый путь: очки распознали речь локально и прислали готовый текст —
|
||||||
|
// в LLM тем же каналом, что и StopStt(cancel=false). Old SttAudio/StopStt
|
||||||
|
// остаются фолбэком для старых очей.
|
||||||
|
is SttText -> {
|
||||||
|
val full = msg.text.trim()
|
||||||
|
if (full.isNotBlank()) {
|
||||||
|
log("stt", "STT-текст от очков (локально): $full → LLM")
|
||||||
|
onStopFullText?.invoke(full)
|
||||||
|
} else {
|
||||||
|
log("stt", "STT-текст от очков пуст — ignore")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.content.pm.PackageManager
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
|
import android.view.WindowManager
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
@@ -19,6 +20,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
requestBtPermissions()
|
requestBtPermissions()
|
||||||
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
setContent {
|
setContent {
|
||||||
ViewMateApp()
|
ViewMateApp()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ object MercuryBridge {
|
|||||||
val wifiEnabled: Boolean = false,
|
val wifiEnabled: Boolean = false,
|
||||||
val wifiSsid: String? = null,
|
val wifiSsid: String? = null,
|
||||||
val wifiNetworks: List<MyWifiInfo> = emptyList(),
|
val wifiNetworks: List<MyWifiInfo> = emptyList(),
|
||||||
|
/** Активный pairCode для QR-паринга (не null, пока очки не подключились). */
|
||||||
|
val pairCode: String? = null,
|
||||||
|
/** Реальный классический BT-адрес очков, полученный в pre-bond окне (null до паринга). */
|
||||||
|
val realAddress: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val _state = MutableStateFlow(UiState())
|
private val _state = MutableStateFlow(UiState())
|
||||||
@@ -60,7 +64,7 @@ object MercuryBridge {
|
|||||||
mercury!!.events.collect { event ->
|
mercury!!.events.collect { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
is RayNeoMercury.Event.Connected -> {
|
is RayNeoMercury.Event.Connected -> {
|
||||||
_state.value = _state.value.copy(connected = true)
|
_state.value = _state.value.copy(connected = true, pairCode = null)
|
||||||
setStatus("подключено (BLE)")
|
setStatus("подключено (BLE)")
|
||||||
// Запрашиваем актуальный статус (яркость/WiFi/батарея), чтобы
|
// Запрашиваем актуальный статус (яркость/WiFi/батарея), чтобы
|
||||||
// ползунок яркости и переключатель WiFi показывали реальные значения.
|
// ползунок яркости и переключатель WiFi показывали реальные значения.
|
||||||
@@ -122,6 +126,28 @@ object MercuryBridge {
|
|||||||
_state.value = _state.value.copy(wifiNetworks = event.networks)
|
_state.value = _state.value.copy(wifiNetworks = event.networks)
|
||||||
log("wifi", "сетей: ${event.networks.size}")
|
log("wifi", "сетей: ${event.networks.size}")
|
||||||
}
|
}
|
||||||
|
is RayNeoMercury.Event.RealBluetoothAddress -> {
|
||||||
|
// Pre-bond handshake: очки сообщили свой реальный классический
|
||||||
|
// BT-адрес (он же ожидает QR-pairCode). Создаём OS-level bond —
|
||||||
|
// это и есть «паринг» (в стоке: PairStateMachine → AutoClassic → createBond).
|
||||||
|
log("pair", "real bt address: ${event.address} name=${event.name} type=${event.type}")
|
||||||
|
_state.value = _state.value.copy(realAddress = event.address)
|
||||||
|
setStatus("получен адрес ${event.address}, настраиваю связь (bond)…")
|
||||||
|
scope.launch {
|
||||||
|
val m = mercury
|
||||||
|
val res = if (m != null) m.pairByRealAddress(event.address)
|
||||||
|
else PairManager.PairingResult.Failed("нет связи с библиотекой")
|
||||||
|
when (res) {
|
||||||
|
is PairManager.PairingResult.Success -> {
|
||||||
|
_state.value = _state.value.copy(connected = true, pairCode = null, glassesName = res.device.name)
|
||||||
|
setStatus("спаривание успешно: ${event.address}, подключаюсь…")
|
||||||
|
}
|
||||||
|
is PairManager.PairingResult.Failed -> {
|
||||||
|
setStatus("спаривание не удалось: ${res.reason}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,6 +296,96 @@ object MercuryBridge {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Старт паринга: перезапускает GATT-сервер с pairCode в рекламе и отдаёт код
|
||||||
|
* для QR. Пользователь показывает QR экрану очков — они его сканируют,
|
||||||
|
* подключаются в pre-bond окне и шлют свой реальный адрес
|
||||||
|
* (Event.RealBluetoothAddress), по которому мы делаем createBond.
|
||||||
|
*/
|
||||||
|
fun startPairing(): String? {
|
||||||
|
val m = mercury ?: return null
|
||||||
|
if (m.isAnyConnected) {
|
||||||
|
setStatus("очки уже подключены — паринг не нужен")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val code = m.startPairing()
|
||||||
|
_state.value = _state.value.copy(pairCode = code)
|
||||||
|
setStatus("паринг: покажите QR экрану очков (код $code)")
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отмена активного QR-паринга (прячет QR, перезапускает сервер в обычном режиме без pairCode). */
|
||||||
|
fun cancelPairing() {
|
||||||
|
_state.value = _state.value.copy(pairCode = null)
|
||||||
|
mercury?.stopPairing()
|
||||||
|
setStatus("паринг отменён")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Забывает очки (removeBond). Применяется, когда очки на своей стороне
|
||||||
|
* потеряли бонд, а телефон держит «старый» бонд — рассинхрон блокирует
|
||||||
|
* нормальное повторное паринг.
|
||||||
|
*/
|
||||||
|
fun unpair(address: String): Boolean {
|
||||||
|
val m = mercury ?: return false
|
||||||
|
return kotlinx.coroutines.runBlocking { m.unpairAddress(address) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текущие классические BT-бонды телефона (адрес → имя). Debug-помощник. */
|
||||||
|
fun bondedAddresses(): List<Pair<String, String?>> =
|
||||||
|
mercury?.bondedAddresses() ?: emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Классический паринг по известному классическому адресу — ручной путь без
|
||||||
|
* QR и камеры очков (то самое «подключите вручную в приложении» с экрана
|
||||||
|
* очков). createBond по адресу; очки после BONDED сами уходят из паринг-режима.
|
||||||
|
*/
|
||||||
|
fun pairClassic(address: String): Boolean {
|
||||||
|
val m = mercury ?: return false
|
||||||
|
val res = kotlinx.coroutines.runBlocking { m.pairByRealAddress(address) }
|
||||||
|
return if (res is PairManager.PairingResult.Success) {
|
||||||
|
_state.value = _state.value.copy(connected = true, pairCode = null, glassesName = res.device.name)
|
||||||
|
setStatus("классический паринг успешен: $address (${res.device.name})")
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
setError("классический паринг не удался: ${(res as? PairManager.PairingResult.Failed)?.reason}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Классический discovery: находит RayNeo-очки в режиме первого паринга вокруг
|
||||||
|
* телефона (без камеры). Возвращает список найденных (имя, адрес).
|
||||||
|
*/
|
||||||
|
fun discoverGlasses(timeoutMs: Long = 5_000): List<Pair<String, String?>> {
|
||||||
|
val m = mercury ?: return emptyList()
|
||||||
|
val found = kotlinx.coroutines.runBlocking { m.discoverClassic(timeoutMs) }
|
||||||
|
return found.map { it.device.name to it.address }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ручной паринг «в один клик»: классический discovery, выбор RayNeo-очков и
|
||||||
|
* createBond. Кнопка вместо QR — для случая, когда очки снова в режиме первого
|
||||||
|
* паринга (слетел бонд) и камера очков недоступна/не считывает QR.
|
||||||
|
*/
|
||||||
|
fun pairClassicAuto(timeoutMs: Long = 7_000): String? {
|
||||||
|
val found = discoverGlasses(timeoutMs)
|
||||||
|
if (found.isEmpty()) {
|
||||||
|
setError("ручной паринг: рядом не найдено ни одного классического устройства — включите очки")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val target = found.firstOrNull { (it.first)?.contains("rayneo", ignoreCase = true) == true }
|
||||||
|
if (target == null) {
|
||||||
|
setError("ручной паринг: RayNeo рядом не найден (проверьте, что очки включены и в режиме паринга)")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val address = target.second
|
||||||
|
val ok = pairClassic(address ?: return null)
|
||||||
|
if (!ok) return null
|
||||||
|
setStatus("ручной паринг: очки подключены ($address)")
|
||||||
|
return address
|
||||||
|
}
|
||||||
|
|
||||||
private fun setStatus(text: String) {
|
private fun setStatus(text: String) {
|
||||||
log("status", text)
|
log("status", text)
|
||||||
_state.value = _state.value.copy(statusText = text, lastError = null)
|
_state.value = _state.value.copy(statusText = text, lastError = null)
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
/** Прогресс просмотра элемента: как далеко досмотрели и длительность видео. */
|
||||||
|
@Serializable
|
||||||
|
data class WatchProgress(
|
||||||
|
val positionMs: Long,
|
||||||
|
/** Длительность видео, мс; -1 = неизвестно (старые очки не сообщали durationMs). */
|
||||||
|
val durationMs: Long = -1L,
|
||||||
|
) {
|
||||||
|
/** Процент просмотренного (0..100); null — длительность неизвестна. */
|
||||||
|
fun watchedPercent(): Int? =
|
||||||
|
if (durationMs <= 0L) null else ((positionMs.toDouble() / durationMs) * 100.0).toInt().coerceIn(0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class WatchProgressFile(val entries: Map<String, WatchProgress>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прогресс просмотра по элементам: `filesDir/watchprogress.json`.
|
||||||
|
*
|
||||||
|
* Заполняется из PlaybackPosition очков (раз в 5с, только при продвижении вперёд),
|
||||||
|
* используется в двух местах: (1) возобновление просмотра с того же места по «▶ Смотреть»
|
||||||
|
* и (2) индикация просмотренного на карточках каталога.
|
||||||
|
* Ключ — itemId (не зависит от дорожки звука — позиция общая).
|
||||||
|
* Конструктор принимает директорию (в проде — filesDir) для JVM-тестируемости.
|
||||||
|
*/
|
||||||
|
class WatchProgressStore(dir: File) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Понять «новое начало»: просмотрено меньше 15 с — возобновляем с начала. */
|
||||||
|
const val MIN_RESUME_MS = 15_000L
|
||||||
|
|
||||||
|
/** 98% и больше — элемент досмотрен: в следующий раз начинаем сначала. */
|
||||||
|
const val FINISHED_RATIO = 0.98
|
||||||
|
}
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true; prettyPrint = true }
|
||||||
|
private val file = File(dir, "watchprogress.json")
|
||||||
|
private val lock = Any()
|
||||||
|
private val entries = LinkedHashMap<String, WatchProgress>()
|
||||||
|
|
||||||
|
private val _flow = MutableStateFlow<Map<String, WatchProgress>>(emptyMap())
|
||||||
|
/** Снимки прогресса по itemId — для карточек каталога. */
|
||||||
|
val flow: StateFlow<Map<String, WatchProgress>> = _flow.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
_flow.value = entries.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Записать позицию элемента. На диск пишем только при продвижении вперёд
|
||||||
|
* или когда впервые известна длительность; дубликаты (пауза) — без записи.
|
||||||
|
* Позиция дальше длительности — мусор ExoPlayer (гашение экрана): игнор.
|
||||||
|
*/
|
||||||
|
fun upsert(itemId: String, positionMs: Long, durationMs: Long = -1L) {
|
||||||
|
if (positionMs < 0L) return
|
||||||
|
synchronized(lock) {
|
||||||
|
val old = entries[itemId] ?: WatchProgress(0L)
|
||||||
|
val knownDuration = if (durationMs > 0L) durationMs else old.durationMs
|
||||||
|
if (knownDuration > 0L && positionMs > knownDuration) {
|
||||||
|
log("progress", "упсерт отклонён: позиция $positionMs мс > длительность $knownDuration мс ($itemId)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val advances = positionMs > old.positionMs ||
|
||||||
|
(durationMs > 0L && durationMs != old.durationMs)
|
||||||
|
if (!advances) return
|
||||||
|
entries[itemId] = WatchProgress(positionMs, if (durationMs > 0L) durationMs else old.durationMs)
|
||||||
|
persist()
|
||||||
|
_flow.value = entries.toMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Позиция для возобновления просмотра: null = начинать с начала
|
||||||
|
* (не смотрели, смотрели меньше [MIN_RESUME_MS] или досмотрели до [FINISHED_RATIO]).
|
||||||
|
*/
|
||||||
|
fun resumePosition(itemId: String): Long? {
|
||||||
|
val e = synchronized(lock) { entries[itemId] } ?: return null
|
||||||
|
return when {
|
||||||
|
e.positionMs < MIN_RESUME_MS -> null
|
||||||
|
e.durationMs > 0L && e.positionMs >= e.durationMs * FINISHED_RATIO -> null
|
||||||
|
else -> e.positionMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Процент просмотренного (0..100); null — элемент не смотрели или длительность неизвестна. */
|
||||||
|
fun watchedPercent(itemId: String): Int? {
|
||||||
|
val e = synchronized(lock) { entries[itemId] } ?: return null
|
||||||
|
return e.watchedPercent()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load() {
|
||||||
|
val text = runCatching { file.readText() }.getOrNull() ?: return
|
||||||
|
val parsed = runCatching { json.decodeFromString(WatchProgressFile.serializer(), text).entries }.getOrNull()
|
||||||
|
if (parsed != null) {
|
||||||
|
entries.putAll(parsed)
|
||||||
|
} else {
|
||||||
|
log("progress", "watchprogress.json не прочитан — начинаю пустым")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persist() {
|
||||||
|
val result = runCatching { json.encodeToString(WatchProgressFile.serializer(), WatchProgressFile(entries.toMap())) }
|
||||||
|
result.onSuccess { text ->
|
||||||
|
runCatching { file.writeText(text) }.onFailure { log("progress", "запись watchprogress.json: ${it.message}") }
|
||||||
|
}.onFailure { log("progress", "сериализация watchprogress.json: ${it.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,169 +6,11 @@ import java.util.concurrent.Executors
|
|||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import kotlin.concurrent.withLock
|
import kotlin.concurrent.withLock
|
||||||
import kotlin.math.sqrt
|
import pw.binom.viewmate.core.stt.s16leToFloat
|
||||||
|
import pw.binom.viewmate.core.stt.splitPhrases
|
||||||
|
import pw.binom.viewmate.core.stt.trimEdges
|
||||||
import pw.binom.viewmate.phone.log
|
import pw.binom.viewmate.phone.log
|
||||||
|
|
||||||
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
|
|
||||||
internal fun s16leToFloat(pcm: ByteArray): FloatArray {
|
|
||||||
val n = pcm.size / 2
|
|
||||||
val out = FloatArray(n)
|
|
||||||
var i = 0
|
|
||||||
for (j in 0 until n) {
|
|
||||||
val lo = pcm[i].toInt() and 0xff
|
|
||||||
val hi = pcm[i + 1].toInt() and 0xff
|
|
||||||
i += 2
|
|
||||||
var s = lo or (hi shl 8)
|
|
||||||
if (s >= 0x8000) s -= 0x10000
|
|
||||||
out[j] = (s / 32768f).coerceIn(-1f, 1f)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Обрезает краевую тишину в начале и конце [samples], сохраняя внутренние паузы.
|
|
||||||
* Окно [windowSize] сэмплов (320 = 20 мс @ 16 кГц) — звучным считается окно,
|
|
||||||
* чей RMS >= [threshold]. Возвращает срез от первого до последнего звучного окна
|
|
||||||
* с отступом [padWindows] окон (15 = 300 мс) в обе стороны (clamp к границам).
|
|
||||||
*
|
|
||||||
* Если звучных окон нет — пустой массив. Внутренние тишины НЕ трогаются.
|
|
||||||
* Чистая функция (без Android) — покрыта JVM-тестами.
|
|
||||||
*/
|
|
||||||
internal fun trimEdges(
|
|
||||||
samples: FloatArray,
|
|
||||||
threshold: Float = 0.01f,
|
|
||||||
windowSize: Int = 320,
|
|
||||||
padWindows: Int = 15,
|
|
||||||
): FloatArray {
|
|
||||||
if (samples.isEmpty()) return samples
|
|
||||||
var first = -1
|
|
||||||
var lastEnd = -1
|
|
||||||
var idx = 0
|
|
||||||
while (idx < samples.size) {
|
|
||||||
val end = minOf(idx + windowSize, samples.size)
|
|
||||||
var sum = 0.0
|
|
||||||
for (k in idx until end) sum += samples[k] * samples[k].toDouble()
|
|
||||||
val rms = sqrt(sum / (end - idx)).toFloat()
|
|
||||||
if (rms >= threshold) {
|
|
||||||
if (first == -1) first = idx
|
|
||||||
lastEnd = end
|
|
||||||
}
|
|
||||||
idx = end
|
|
||||||
}
|
|
||||||
if (first == -1) return FloatArray(0)
|
|
||||||
val pad = padWindows * windowSize
|
|
||||||
val start = maxOf(0, first - pad)
|
|
||||||
val stop = minOf(samples.size, lastEnd + pad)
|
|
||||||
return samples.copyOfRange(start, stop)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон).
|
|
||||||
* Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с).
|
|
||||||
* Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) —
|
|
||||||
* НЕ менять без перепроверки на тех же дампах.
|
|
||||||
*
|
|
||||||
* Окно [windowSize] сэмплов — ТИШИНА, если RMS < [threshold] (0.005, НЕ 0.01
|
|
||||||
* как в [trimEdges]: 0.01 режет тихие начала слов на обрубки). Пауза =
|
|
||||||
* подряд ≥ [minSilenceWindows] тихих окон (0.6 с) — она РАЗДЕЛЯЕТ фразы.
|
|
||||||
* Фраза = звуковые окна между паузами + отступ [padWindows] тихих окон с
|
|
||||||
* каждой стороны (clamp к границам массива). Фраза короче
|
|
||||||
* [minPhraseWindows] (1.5 с) объединяется с соседней (обрубок); единственная
|
|
||||||
* — остаётся. Фраза длиннее [maxPhraseSamples] режется на куски ≤ 30 с по
|
|
||||||
* границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы
|
|
||||||
* < 0.6 с не трогаются. Чистая функция — покрыта JVM-тестами.
|
|
||||||
*/
|
|
||||||
internal fun splitPhrases(
|
|
||||||
samples: FloatArray,
|
|
||||||
threshold: Float = 0.005f,
|
|
||||||
windowSize: Int = 320, // 20 мс @ 16 кГц
|
|
||||||
minSilenceWindows: Int = 30, // 30 окон × 20 мс = пауза 0.6 с
|
|
||||||
minPhraseWindows: Int = 75, // 1.5 с — короче объединять с соседней
|
|
||||||
maxPhraseSamples: Int = 30 * 16_000, // страховка whisper ≤ 30 с
|
|
||||||
padWindows: Int = 15, // 300 мс пре/пост-ролла тишины у фразы
|
|
||||||
): List<FloatArray> {
|
|
||||||
if (samples.isEmpty()) return emptyList()
|
|
||||||
val winCount = (samples.size + windowSize - 1) / windowSize
|
|
||||||
val isSound = BooleanArray(winCount)
|
|
||||||
var anySound = false
|
|
||||||
for (w in 0 until winCount) {
|
|
||||||
val s = w * windowSize
|
|
||||||
val e = minOf(s + windowSize, samples.size)
|
|
||||||
var sum = 0.0
|
|
||||||
for (k in s until e) sum += samples[k] * samples[k].toDouble()
|
|
||||||
if (sqrt(sum / (e - s)).toFloat() >= threshold) {
|
|
||||||
isSound[w] = true
|
|
||||||
anySound = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!anySound) return emptyList()
|
|
||||||
|
|
||||||
// Диапазоны звукового содержимого фраз как индексы окон [startWin, endWin): фразы
|
|
||||||
// разграничены паузами из ≥ minSilenceWindows подряд тихих окон.
|
|
||||||
class Phrase(var start: Int, var end: Int)
|
|
||||||
|
|
||||||
val phrases = mutableListOf<Phrase>()
|
|
||||||
var pos = 0
|
|
||||||
while (pos < winCount) {
|
|
||||||
var sil = 0
|
|
||||||
while (pos + sil < winCount && !isSound[pos + sil]) sil++
|
|
||||||
if (pos + sil >= winCount) break
|
|
||||||
val start = pos + sil
|
|
||||||
var end = start
|
|
||||||
var continuePhrase = true
|
|
||||||
while (continuePhrase) {
|
|
||||||
var run = 0
|
|
||||||
while (end + run < winCount && isSound[end + run]) run++
|
|
||||||
end += run
|
|
||||||
if (end >= winCount) { continuePhrase = false; break }
|
|
||||||
var sil2 = 0
|
|
||||||
while (end + sil2 < winCount && !isSound[end + sil2]) sil2++
|
|
||||||
if (sil2 >= minSilenceWindows) {
|
|
||||||
continuePhrase = false
|
|
||||||
} else {
|
|
||||||
end += sil2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
phrases.add(Phrase(start, end))
|
|
||||||
pos = end
|
|
||||||
}
|
|
||||||
|
|
||||||
// Короткая (< 1.5 с) фраза объединяется с соседней (ед. фраза — без изменений).
|
|
||||||
while (phrases.size > 1) {
|
|
||||||
val idx = phrases.indexOfFirst { it.end - it.start < minPhraseWindows }
|
|
||||||
if (idx < 0) break
|
|
||||||
val other = if (idx == 0) idx + 1 else idx - 1
|
|
||||||
val lo = minOf(idx, other)
|
|
||||||
val hi = maxOf(idx, other)
|
|
||||||
val merged = Phrase(
|
|
||||||
minOf(phrases[lo].start, phrases[hi].start),
|
|
||||||
maxOf(phrases[lo].end, phrases[hi].end),
|
|
||||||
)
|
|
||||||
phrases.removeAt(hi)
|
|
||||||
phrases.removeAt(lo)
|
|
||||||
phrases.add(lo, merged)
|
|
||||||
}
|
|
||||||
|
|
||||||
val pad = padWindows * windowSize
|
|
||||||
val out = mutableListOf<FloatArray>()
|
|
||||||
for (p in phrases) {
|
|
||||||
val s = maxOf(0, p.start * windowSize - pad)
|
|
||||||
val e = minOf(samples.size, p.end * windowSize + pad)
|
|
||||||
if (e - s <= maxPhraseSamples) {
|
|
||||||
out += samples.copyOfRange(s, e)
|
|
||||||
} else {
|
|
||||||
val chunk = (maxPhraseSamples / windowSize) * windowSize
|
|
||||||
var cs = s
|
|
||||||
while (cs < e) {
|
|
||||||
val ce = minOf(e, cs + chunk)
|
|
||||||
out += samples.copyOfRange(cs, ce)
|
|
||||||
cs = ce
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз —
|
* Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз —
|
||||||
* распознаётся только ПОЛНЫЙ буфер сессии в [finish] (по клику) одним вызовом
|
* распознаётся только ПОЛНЫЙ буфер сессии в [finish] (по клику) одним вызовом
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package pw.binom.viewmate.phone.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import pw.binom.viewmate.core.media.MirrorJob
|
||||||
|
import pw.binom.viewmate.core.protocol.DownloadPhase as GlassesDownloadPhase
|
||||||
|
import pw.binom.viewmate.core.protocol.DownloadProgress as GlassesDownloadProgress
|
||||||
|
import pw.binom.viewmate.phone.PhoneApp
|
||||||
|
import pw.binom.viewmate.phone.DownloadPhase as PhoneDownloadPhase
|
||||||
|
import pw.binom.viewmate.phone.DownloadProgress as PhoneDownloadProgress
|
||||||
|
|
||||||
|
/** Статусы зеркала, которые считаются «конвертируется прямо сейчас». */
|
||||||
|
private val CONVERTING_STATUSES = setOf("new", "queued", "processing")
|
||||||
|
|
||||||
|
/** Как часто опрашиваем mirror.list, пока открыт экран (конвертация не шлёт события сама). */
|
||||||
|
private const val REFRESH_MS = 15_000L
|
||||||
|
|
||||||
|
/** Строка активности для единого списка в LazyColumn. */
|
||||||
|
private sealed interface ActivityRow {
|
||||||
|
val key: String
|
||||||
|
|
||||||
|
data class Header(val text: String, override val key: String) : ActivityRow
|
||||||
|
data class Convert(val itemId: String, val title: String, val statusLabel: String, override val key: String) : ActivityRow
|
||||||
|
data class Download(val itemId: String, val title: String, val kind: String, val percent: Int, override val key: String) : ActivityRow
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экран «Активность»: всё, что сейчас конвертируется (зеркала) и скачивается
|
||||||
|
* (звук → телефон, видео → очки). Уровень 1: без серверной доработки — по
|
||||||
|
* конвертации спиннер (progress на сервере не живой), по скачиванию живой %.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ActivityScreen() {
|
||||||
|
val app = PhoneApp.instance
|
||||||
|
val userId by app.userId.collectAsState()
|
||||||
|
|
||||||
|
// Конвертирующиеся зеркала: опрос mirror.list раз в REFRESH_MS (нет push от сервера).
|
||||||
|
var converting by remember { mutableStateOf<List<MirrorJob>>(emptyList()) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (isActive) {
|
||||||
|
converting = runCatching {
|
||||||
|
app.mirror.list(limit = 500).filter { it.status in CONVERTING_STATUSES }
|
||||||
|
}.getOrDefault(converting)
|
||||||
|
delay(REFRESH_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Живой прогресс скачивания: звук (телефон) — DownloadManager, видео (очки) — хаб.
|
||||||
|
val phoneDownloads by app.downloadManager.downloads.collectAsState()
|
||||||
|
val activePhone by app.downloadManager.activeIds.collectAsState()
|
||||||
|
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
|
||||||
|
|
||||||
|
// Настройки/названия предметов по активным itemId (подтягиваем только недостающие).
|
||||||
|
val activeIds = remember(converting, activePhone, glassesDownloads) {
|
||||||
|
(converting.mapNotNull { it.itemId } + activePhone + glassesDownloads.keys.filter { it.isNotBlank() })
|
||||||
|
.distinct()
|
||||||
|
}
|
||||||
|
var titles by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||||
|
LaunchedEffect(activeIds, userId) {
|
||||||
|
if (userId.isBlank()) return@LaunchedEffect
|
||||||
|
activeIds.forEach { id ->
|
||||||
|
if (titles.containsKey(id)) return@forEach
|
||||||
|
val name = runCatching { app.jellyfin.item(userId, id)?.Name }.getOrNull()
|
||||||
|
titles = titles + (id to (name?.takeIf { it.isNotBlank() } ?: id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val rows = buildActivityRows(
|
||||||
|
converting = converting,
|
||||||
|
phone = phoneDownloads,
|
||||||
|
activePhone = activePhone,
|
||||||
|
glasses = glassesDownloads,
|
||||||
|
titles = titles,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
ScreenPlaceholder("Ничего не конвертируется и не скачивается")
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(horizontal = 14.dp),
|
||||||
|
) {
|
||||||
|
items(rows) { row ->
|
||||||
|
ActivityRowView(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildActivityRows(
|
||||||
|
converting: List<MirrorJob>,
|
||||||
|
phone: Map<String, PhoneDownloadProgress>,
|
||||||
|
activePhone: Set<String>,
|
||||||
|
glasses: Map<String, Map<String, GlassesDownloadProgress>>,
|
||||||
|
titles: Map<String, String>,
|
||||||
|
): List<ActivityRow> {
|
||||||
|
val out = ArrayList<ActivityRow>()
|
||||||
|
val name = { id: String -> titles[id] ?: id }
|
||||||
|
|
||||||
|
val liveConverting = converting.filter { !it.itemId.isNullOrBlank() }
|
||||||
|
if (liveConverting.isNotEmpty()) {
|
||||||
|
out += ActivityRow.Header("Конвертируется", "h-conv")
|
||||||
|
liveConverting.forEach { job ->
|
||||||
|
val id = job.itemId.orEmpty()
|
||||||
|
val label = when (job.status) {
|
||||||
|
"processing" -> "обрабатывается"
|
||||||
|
"queued" -> "в очереди"
|
||||||
|
"new" -> "взято в работу"
|
||||||
|
else -> job.status ?: "…"
|
||||||
|
}
|
||||||
|
out += ActivityRow.Convert(id, name(id), label, "conv-${job.id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val phoneActive = phone.filterKeys { activePhone.contains(it) }
|
||||||
|
val glassesActive = glasses.filter { (itemId, files) ->
|
||||||
|
itemId.isNotBlank() && files.values.any { it.phase == GlassesDownloadPhase.DOWNLOADING }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phoneActive.isNotEmpty() || glassesActive.isNotEmpty()) {
|
||||||
|
out += ActivityRow.Header("Скачивается", "h-dl")
|
||||||
|
phoneActive.forEach { (id, p) ->
|
||||||
|
out += ActivityRow.Download(id, name(id), "звук", p.percent, "dl-$id-phone")
|
||||||
|
}
|
||||||
|
glassesActive.forEach { (id, files) ->
|
||||||
|
val video = files["video.mkv"] ?: files.values.firstOrNull()
|
||||||
|
out += ActivityRow.Download(id, name(id), "видео", video?.percent ?: 0, "dl-$id-glasses")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActivityRowView(row: ActivityRow) {
|
||||||
|
when (row) {
|
||||||
|
is ActivityRow.Header -> SectionLabel(
|
||||||
|
text = row.text,
|
||||||
|
modifier = Modifier.padding(top = 10.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
is ActivityRow.Convert -> StatusBlock {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
color = PhoneAccent,
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = row.title,
|
||||||
|
color = PhoneTextDim,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "зеркало: ${row.statusLabel}",
|
||||||
|
color = PhoneMuted,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ActivityRow.Download -> StatusBlock {
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = row.title,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
color = PhoneTextDim,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${row.kind} ${row.percent}%",
|
||||||
|
color = PhoneMuted,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
ProgressBar(percent = row.percent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package pw.binom.viewmate.phone.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pw.binom.viewmate.core.media.JellyfinItem
|
||||||
|
import pw.binom.viewmate.core.media.MirrorJob
|
||||||
|
import pw.binom.viewmate.phone.PhoneApp
|
||||||
|
import pw.binom.viewmate.phone.PhoneConfig
|
||||||
|
import pw.binom.viewmate.phone.S3Presigner
|
||||||
|
import pw.binom.viewmate.phone.log
|
||||||
|
|
||||||
|
/** Статусы зеркала, при которых конвертацию заказывать не надо. */
|
||||||
|
private val MIRROR_BUSY_OR_DONE = setOf("done", "queued", "processing")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Массовые действия страницы сезона/сериала: все эпизоды отправить на конвертацию,
|
||||||
|
* скачать всё (звук → телефон, видео → очки) и удалить локальные файлы (телефон + очки).
|
||||||
|
*
|
||||||
|
* [episodes] — целевой набор эпизодов, [mirrors] — свежие статусы зеркал,
|
||||||
|
* [label] — «сезон»/«сериал» для подписи, [contextName] — название для диалога удаления.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BulkActionsRow(
|
||||||
|
episodes: List<JellyfinItem>,
|
||||||
|
mirrors: Map<String, MirrorJob>,
|
||||||
|
label: String,
|
||||||
|
contextName: String,
|
||||||
|
onMessage: (String) -> Unit,
|
||||||
|
onStateRefresh: () -> Unit,
|
||||||
|
) {
|
||||||
|
val app = PhoneApp.instance
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val dm = app.downloadManager
|
||||||
|
val phoneDownloaded by dm.downloadedIds.collectAsState()
|
||||||
|
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
|
||||||
|
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val statusOf: (JellyfinItem) -> String? = { mirrors[it.Id]?.status }
|
||||||
|
val needConversion = episodes.filter { statusOf(it) !in MIRROR_BUSY_OR_DONE }
|
||||||
|
val readyToDownload = episodes.filter { statusOf(it) == "done" }
|
||||||
|
/** Если всё уже конвертировано — кнопку «конвертировать» не рисуем. */
|
||||||
|
val convertable = needConversion.isNotEmpty()
|
||||||
|
/** Готовые зеркала, которые ещё не скачаны (телефон/очки); если все скачано — кнопку не рисуем. */
|
||||||
|
val toDownload = readyToDownload.filterNot { it.Id in phoneDownloaded || it.Id in glassesDownloadedIds }
|
||||||
|
val withLocal = episodes.count { it.Id in phoneDownloaded || it.Id in glassesDownloadedIds }
|
||||||
|
|
||||||
|
/** Массовая конвертация: заказ по каждому эпизоду без активного/готового зеркала. */
|
||||||
|
fun onConvert() {
|
||||||
|
if (needConversion.isEmpty() || busy) return
|
||||||
|
busy = true
|
||||||
|
scope.launch {
|
||||||
|
var ok = 0
|
||||||
|
var fail = 0
|
||||||
|
for (ep in needConversion) {
|
||||||
|
runCatching { app.mirror.createMirror(ep.Id) }
|
||||||
|
.onSuccess { ok++ }
|
||||||
|
.onFailure {
|
||||||
|
fail++
|
||||||
|
log("bulk", "mirror ${ep.Id}: ${it.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
busy = false
|
||||||
|
onStateRefresh()
|
||||||
|
onMessage(
|
||||||
|
buildString {
|
||||||
|
append("Конвертация: заказано $ok")
|
||||||
|
if (fail > 0) append(", ошибок $fail")
|
||||||
|
append(" ($label)")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Массовое скачивание готовых зеркал: звук на телефон (DownloadManager),
|
||||||
|
* видео на очки (DownloadFiles через actions). Очки не подключены — только звук на телефон.
|
||||||
|
*/
|
||||||
|
fun onDownload() {
|
||||||
|
if (toDownload.isEmpty() || busy) return
|
||||||
|
busy = true
|
||||||
|
scope.launch {
|
||||||
|
val actions = app.actions
|
||||||
|
val glassesOk = app.server.hub.connected.value > 0 && actions != null
|
||||||
|
var videoSent = 0
|
||||||
|
for (ep in toDownload) {
|
||||||
|
dm.startDownload(ep.Id)
|
||||||
|
if (glassesOk) {
|
||||||
|
val result = runCatching {
|
||||||
|
actions.downloadToGlasses(ep.Id, sizes = dm.knownFileSizes(ep.Id)) {
|
||||||
|
S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.isFailure) log("bulk", "скачивание на очки ${ep.Id}: ${result.exceptionOrNull()?.message}")
|
||||||
|
else videoSent++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
busy = false
|
||||||
|
onStateRefresh()
|
||||||
|
onMessage(
|
||||||
|
buildString {
|
||||||
|
append("Скачивание: звук → телефон, видео → очки ($videoSent) ($label)")
|
||||||
|
if (!glassesOk) append(" [очки не подключены — только телефон]")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Удаление локальных файлов всех эпизодов: диск телефона + каталог контента очков. */
|
||||||
|
fun onDelete() {
|
||||||
|
if (withLocal == 0 || busy) return
|
||||||
|
busy = true
|
||||||
|
scope.launch {
|
||||||
|
var deleted = 0
|
||||||
|
for (ep in episodes) {
|
||||||
|
if (ep.Id !in phoneDownloaded && ep.Id !in glassesDownloadedIds) continue
|
||||||
|
dm.delete(ep.Id)
|
||||||
|
app.actions?.deleteDownloaded(ep.Id)
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
busy = false
|
||||||
|
onStateRefresh()
|
||||||
|
onMessage("Удалено локальное: $deleted эпизод(ов) ($label)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (convertable || toDownload.isNotEmpty() || withLocal > 0) {
|
||||||
|
Text(
|
||||||
|
text = "все эпизоды $label",
|
||||||
|
color = PhoneMuted,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
modifier = Modifier.padding(start = 14.dp, top = 4.dp, end = 14.dp),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 14.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
if (convertable) {
|
||||||
|
OutlinedButton(onClick = ::onConvert, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "конвертировать",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toDownload.isNotEmpty()) {
|
||||||
|
OutlinedButton(onClick = ::onDownload, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "скачать",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (withLocal > 0) {
|
||||||
|
OutlinedButton(onClick = { confirmDelete = true }, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "удалить локальное ($withLocal)",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmDelete) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmDelete = false },
|
||||||
|
title = { Text("Удалить локальные файлы?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
"«$contextName»: с телефонов и очков удалятся уже скачанные " +
|
||||||
|
"видео/звук по ${withLocal} эпизод(ам). Это действие необратимо.",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { confirmDelete = false; onDelete() }) { Text("Удалить") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { confirmDelete = false }) { Text("Отмена") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package pw.binom.viewmate.phone.ui
|
package pw.binom.viewmate.phone.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -17,7 +18,10 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -27,6 +31,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -47,6 +52,7 @@ import pw.binom.viewmate.core.media.MirrorJob
|
|||||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||||
import pw.binom.viewmate.phone.PhoneApp
|
import pw.binom.viewmate.phone.PhoneApp
|
||||||
import pw.binom.viewmate.phone.WatchProgress
|
import pw.binom.viewmate.phone.WatchProgress
|
||||||
|
import pw.binom.viewmate.phone.log
|
||||||
|
|
||||||
/** Вкладки каталога: Фильмы / Сериалы / Папки. */
|
/** Вкладки каталога: Фильмы / Сериалы / Папки. */
|
||||||
enum class CatalogTab { MOVIES, SERIES, FOLDERS }
|
enum class CatalogTab { MOVIES, SERIES, FOLDERS }
|
||||||
@@ -86,6 +92,8 @@ class CatalogState(
|
|||||||
private set
|
private set
|
||||||
var scrollOffset by mutableStateOf(0)
|
var scrollOffset by mutableStateOf(0)
|
||||||
private set
|
private set
|
||||||
|
// фильтр «только скачанные (и частично)» — общий для списка фильмов и списка эпизодов сезона
|
||||||
|
var onlyDownloaded by mutableStateOf(false)
|
||||||
|
|
||||||
/** Сохранённые позиции скролла по пути (ключ — путь, из которого ушли). */
|
/** Сохранённые позиции скролла по пути (ключ — путь, из которого ушли). */
|
||||||
private val savedScroll = mutableMapOf<CatalogState.Path, Pair<Int, Int>>()
|
private val savedScroll = mutableMapOf<CatalogState.Path, Pair<Int, Int>>()
|
||||||
@@ -198,7 +206,6 @@ class CatalogState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
items = loaded
|
items = loaded
|
||||||
mirrors = app.mirror.list(limit = 500).associateBy { it.itemId.orEmpty() }
|
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -207,6 +214,15 @@ class CatalogState(
|
|||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
// Зеркала — отдельный «мягкий» запрос: сбой/таймаут mirror.list не чистит
|
||||||
|
// уже загруженный каталог, просто временно без бейджей.
|
||||||
|
try {
|
||||||
|
mirrors = app.mirror.list(limit = 500).associateBy { it.itemId.orEmpty() }
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("catalog", "зеркала: не удалось (${e.message}) — каталог без бейджей")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +249,36 @@ fun CatalogScreen(
|
|||||||
) {
|
) {
|
||||||
val app = PhoneApp.instance
|
val app = PhoneApp.instance
|
||||||
val userId by app.userId.collectAsState()
|
val userId by app.userId.collectAsState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
// Системная кнопка «назад»: выход из вложенного пути (сериал→сезон, папки) на уровень выше
|
||||||
|
BackHandler(enabled = state.canGoBack()) {
|
||||||
|
state.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Все эпизоды сериала (для bulk-операций): по каждому сезону — список эпизодов.
|
||||||
|
var seriesEpisodes by remember { mutableStateOf<List<JellyfinItem>?>(null) }
|
||||||
|
val seasonIds = state.items.map { it.Id }
|
||||||
|
LaunchedEffect(state.path, seasonIds, userId) {
|
||||||
|
if (state.path !is CatalogState.Path.Series || userId.isBlank() || state.items.isEmpty()) {
|
||||||
|
seriesEpisodes = emptyList()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
seriesEpisodes = state.items.flatMap { season ->
|
||||||
|
runCatching { app.jellyfin.items(userId, season.Id, listOf("Episode")) }.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val bulkEpisodes = when (state.path) {
|
||||||
|
is CatalogState.Path.Season -> state.items
|
||||||
|
is CatalogState.Path.Series -> seriesEpisodes ?: emptyList()
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
val bulkLabel = when (state.path) {
|
||||||
|
is CatalogState.Path.Season -> "сезона"
|
||||||
|
is CatalogState.Path.Series -> "сериала"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
// Позиция скролла каталога: сохраняется/восстанавливается между навигациями.
|
// Позиция скролла каталога: сохраняется/восстанавливается между навигациями.
|
||||||
val gridState = rememberLazyGridState()
|
val gridState = rememberLazyGridState()
|
||||||
@@ -264,12 +310,40 @@ fun CatalogScreen(
|
|||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
CatalogTopBar(state = state)
|
CatalogTopBar(state = state)
|
||||||
|
if (bulkEpisodes.isNotEmpty()) {
|
||||||
|
BulkActionsRow(
|
||||||
|
episodes = bulkEpisodes,
|
||||||
|
mirrors = state.mirrors,
|
||||||
|
label = bulkLabel,
|
||||||
|
contextName = state.subtitle(),
|
||||||
|
onMessage = { message -> scope.launch { snackbar.showSnackbar(message) } },
|
||||||
|
onStateRefresh = state::refresh,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// фильтр «только скачанные (и частично)» — остаётся только то, что скачано на каком-то устройстве
|
||||||
|
val visibleItems: List<JellyfinItem> = if (state.onlyDownloaded) {
|
||||||
|
state.items.filter { item ->
|
||||||
|
val id = item.Id
|
||||||
|
downloaded.containsKey(id) ||
|
||||||
|
localDownloaded.contains(id) ||
|
||||||
|
glassesDownloadedIds.contains(id) ||
|
||||||
|
glassesDownloads.containsKey(id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.items
|
||||||
|
}
|
||||||
if (state.loading && state.items.isEmpty()) {
|
if (state.loading && state.items.isEmpty()) {
|
||||||
ScreenPlaceholder("загружаю…")
|
ScreenPlaceholder("загружаю…")
|
||||||
} else if (state.error != null && state.items.isEmpty()) {
|
} else if (state.error != null && state.items.isEmpty()) {
|
||||||
ScreenPlaceholder("ошибка: ${state.error}\n\nнажмите на вкладку, чтобы обновить")
|
ScreenPlaceholder("ошибка: ${state.error}\n\nнажмите на вкладку, чтобы обновить")
|
||||||
} else if (state.items.isEmpty()) {
|
} else if (visibleItems.isEmpty()) {
|
||||||
ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто")
|
ScreenPlaceholder(
|
||||||
|
when {
|
||||||
|
state.onlyDownloaded && state.items.isNotEmpty() -> "здесь нет скачанных — снимите галочку «только скачанные»"
|
||||||
|
state.query.isNotBlank() -> "ничего не нашлось"
|
||||||
|
else -> "пусто"
|
||||||
|
},
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
state = gridState,
|
state = gridState,
|
||||||
@@ -279,7 +353,7 @@ fun CatalogScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
items(state.items, key = { it.Id }) { item ->
|
items(visibleItems, key = { it.Id }) { item ->
|
||||||
val videoProgress = glassesDownloads[item.Id]?.get("video.mkv")
|
val videoProgress = glassesDownloads[item.Id]?.get("video.mkv")
|
||||||
ItemCard(
|
ItemCard(
|
||||||
item = item,
|
item = item,
|
||||||
@@ -317,6 +391,7 @@ fun CatalogScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SnackbarHost(hostState = snackbar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +448,21 @@ private fun CatalogTopBar(state: CatalogState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = state.onlyDownloaded,
|
||||||
|
onCheckedChange = { state.onlyDownloaded = it },
|
||||||
|
modifier = Modifier.padding(end = 6.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "только скачанные (и частично)",
|
||||||
|
color = PhoneMuted,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import java.util.Locale
|
|||||||
enum class Screen {
|
enum class Screen {
|
||||||
CATALOG,
|
CATALOG,
|
||||||
DETAILS,
|
DETAILS,
|
||||||
|
ACTIVITY,
|
||||||
GLASSES,
|
GLASSES,
|
||||||
GLASSES_INFO,
|
GLASSES_INFO,
|
||||||
CHAT,
|
CHAT,
|
||||||
@@ -46,6 +47,7 @@ private data class BottomTab(val screen: Screen, val icon: String, val label: St
|
|||||||
|
|
||||||
private val bottomTabs = listOf(
|
private val bottomTabs = listOf(
|
||||||
BottomTab(Screen.CATALOG, "🎬", "Каталог"),
|
BottomTab(Screen.CATALOG, "🎬", "Каталог"),
|
||||||
|
BottomTab(Screen.ACTIVITY, "📶", "Активность"),
|
||||||
BottomTab(Screen.GLASSES, "🕶️", "Очки"),
|
BottomTab(Screen.GLASSES, "🕶️", "Очки"),
|
||||||
BottomTab(Screen.CHAT, "💬", "Чат"),
|
BottomTab(Screen.CHAT, "💬", "Чат"),
|
||||||
)
|
)
|
||||||
@@ -307,7 +309,7 @@ fun ProgressBar(percent: Int, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SectionLabel(text: String) {
|
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
color = PhoneMuted,
|
color = PhoneMuted,
|
||||||
@@ -315,6 +317,6 @@ fun SectionLabel(text: String) {
|
|||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
letterSpacing = 0.5.sp,
|
letterSpacing = 0.5.sp,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
modifier = Modifier.padding(bottom = 6.dp),
|
modifier = modifier.padding(bottom = 6.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,16 +78,16 @@ fun DetailsScreen(
|
|||||||
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
|
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
|
||||||
val glassesMediaDir by app.server.hub.mediaDir.collectAsState()
|
val glassesMediaDir by app.server.hub.mediaDir.collectAsState()
|
||||||
|
|
||||||
// статус зеркала + дорожки; пока конвертируется — опрашиваем
|
// статус зеркала + дорожки; пока конвертируется — опрашиваем только дешёвый byItem.
|
||||||
|
// Тяжёлый list(limit=500) НЕ дёргаем каждые 3 с: progress зеркала на сервере не живой
|
||||||
|
// (0 до конца), а тяжёлый запрос с большим лимитом — главный источник таймаутов.
|
||||||
LaunchedEffect(item.Id, refreshKey) {
|
LaunchedEffect(item.Id, refreshKey) {
|
||||||
loading = true
|
loading = true
|
||||||
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
|
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
|
||||||
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
|
|
||||||
loading = false
|
loading = false
|
||||||
while (mirrorStatus?.status == "processing" || mirrorStatus?.status == "queued") {
|
while (mirrorStatus?.status == "processing" || mirrorStatus?.status == "queued") {
|
||||||
delay(3_000)
|
delay(3_000)
|
||||||
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
|
mirrorStatus = runCatching { app.mirror.mirrorByItem(item.Id) }.getOrNull()
|
||||||
mirrorJob = runCatching { app.mirror.list(limit = 500).firstOrNull { it.itemId == item.Id } }.getOrNull()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -112,6 +113,66 @@ fun MercuryPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Row(modifier = Modifier.padding(top = 8.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = { MercuryBridge.startPairing() },
|
||||||
|
enabled = !state.connected,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = PhoneAccent,
|
||||||
|
contentColor = Color(0xFF10251A),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text("🔗 паринг (QR)", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
if (state.pairCode != null) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { MercuryBridge.cancelPairing() },
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = PhoneSurface,
|
||||||
|
contentColor = PhoneMuted,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text("отмена", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(modifier = Modifier.padding(top = 8.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = { scope.launch { MercuryBridge.pairClassicAuto() } },
|
||||||
|
enabled = !state.connected,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFF3A3F47),
|
||||||
|
contentColor = Color(0xFFD5DBDF),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text("🔗 паринг (вручную)", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val pairCode = state.pairCode
|
||||||
|
if (pairCode != null) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Паринг: отсканируйте QR картинку камерой очков (она должна смотреть на экран телефона)",
|
||||||
|
color = PhoneMuted,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
)
|
||||||
|
QrCode(pairCode, side = 280, modifier = Modifier.padding(top = 8.dp))
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
"Код: $pairCode",
|
||||||
|
color = PhoneText,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Row(modifier = Modifier.padding(top = 8.dp)) {
|
Row(modifier = Modifier.padding(top = 8.dp)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { scope.launch { MercuryBridge.screenOff() } },
|
onClick = { scope.launch { MercuryBridge.screenOff() } },
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package pw.binom.viewmate.phone.ui
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.qrcode.QRCodeWriter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рендер QR-кода (pairCode) для паринга очков: очки сканируют его своей камерой.
|
||||||
|
* В стоковом приложении RayNeo QR кодирует ровно pairCode (8 символов).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun QrCode(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
side: Int = 200,
|
||||||
|
foreground: Color = Color.Black,
|
||||||
|
background: Color = Color.White,
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current.density
|
||||||
|
val sizePx = (side * density).toInt().coerceAtLeast(256)
|
||||||
|
val bitmap = remember(text, sizePx, foreground, background) {
|
||||||
|
renderQr(
|
||||||
|
text = text,
|
||||||
|
sizePx = sizePx,
|
||||||
|
foreground = foreground.toArgb(),
|
||||||
|
background = background.toArgb(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Image(
|
||||||
|
bitmap = bitmap.asImageBitmap(),
|
||||||
|
contentDescription = "QR-код паринга",
|
||||||
|
modifier = modifier.size(side.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderQr(text: String, sizePx: Int, foreground: Int, background: Int): Bitmap {
|
||||||
|
val bits = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, sizePx, sizePx)
|
||||||
|
val w = bits.width
|
||||||
|
val h = bits.height
|
||||||
|
val pixels = IntArray(w * h)
|
||||||
|
for (y in 0 until h) {
|
||||||
|
val rowOffset = y * w
|
||||||
|
for (x in 0 until w) {
|
||||||
|
pixels[rowOffset + x] = if (bits.get(x, y)) foreground else background
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Bitmap.createBitmap(pixels, w, h, Bitmap.Config.ARGB_8888)
|
||||||
|
}
|
||||||
@@ -51,6 +51,8 @@ fun ViewMateApp() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Screen.ACTIVITY -> ActivityScreen()
|
||||||
|
|
||||||
Screen.DETAILS -> {
|
Screen.DETAILS -> {
|
||||||
val item = selected
|
val item = selected
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package pw.binom.viewmate.phone
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.io.path.createTempDirectory
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class WatchProgressStoreTest {
|
||||||
|
|
||||||
|
private fun tempDir(name: String): File = createTempDirectory(name).toFile()
|
||||||
|
|
||||||
|
private fun newStore(): Pair<WatchProgressStore, File> {
|
||||||
|
val dir = tempDir("watch-progress")
|
||||||
|
return WatchProgressStore(dir) to dir
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `upsert and resume position`() {
|
||||||
|
val (store, _) = newStore()
|
||||||
|
store.upsert("item-1", positionMs = 3_600_000L, durationMs = 7_200_000L)
|
||||||
|
|
||||||
|
assertEquals(3_600_000L, store.resumePosition("item-1"))
|
||||||
|
assertEquals(50, store.watchedPercent("item-1"))
|
||||||
|
assertNull(store.resumePosition("other"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resume returns null for short or finished viewing`() {
|
||||||
|
val (store, _) = newStore()
|
||||||
|
store.upsert("short", positionMs = 10_000L, durationMs = 7_200_000L)
|
||||||
|
assertNull(store.resumePosition("short"))
|
||||||
|
|
||||||
|
val finishedMs = (7_200_000L * WatchProgressStore.FINISHED_RATIO).toLong() + 1000L
|
||||||
|
store.upsert("finished", positionMs = finishedMs, durationMs = 7_200_000L)
|
||||||
|
assertNull(store.resumePosition("finished"))
|
||||||
|
|
||||||
|
val almost = 7_200_000L * 0.90
|
||||||
|
store.upsert("almost", positionMs = almost.toLong(), durationMs = 7_200_000L)
|
||||||
|
assertNotNull(store.resumePosition("almost"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `backward and duplicate positions are not written`() {
|
||||||
|
val (store, dir) = newStore()
|
||||||
|
store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L)
|
||||||
|
val writtenAfterAdvance = File(dir, "watchprogress.json").readText()
|
||||||
|
|
||||||
|
store.upsert("item-1", positionMs = 55_000L, durationMs = 7_200_000L)
|
||||||
|
store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L)
|
||||||
|
assertEquals(writtenAfterAdvance, File(dir, "watchprogress.json").readText())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `position beyond duration is rejected`() {
|
||||||
|
val (store, dir) = newStore()
|
||||||
|
store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L)
|
||||||
|
val before = File(dir, "watchprogress.json").readText()
|
||||||
|
|
||||||
|
store.upsert("item-1", positionMs = 99_000_000L, durationMs = 7_200_000L)
|
||||||
|
assertEquals(before, File(dir, "watchprogress.json").readText())
|
||||||
|
assertEquals(60_000L, store.resumePosition("item-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `duration discovered later is persisted`() {
|
||||||
|
val (store, dir) = newStore()
|
||||||
|
store.upsert("item-1", positionMs = 60_000L)
|
||||||
|
assertNull(store.watchedPercent("item-1"))
|
||||||
|
|
||||||
|
store.upsert("item-1", positionMs = 60_000L, durationMs = 120_000L)
|
||||||
|
assertEquals(50, store.watchedPercent("item-1"))
|
||||||
|
assertEquals("watchprogress.json", File(dir, "watchprogress.json").name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `progress survives reload`() {
|
||||||
|
val dir = tempDir("watch-progress-reload")
|
||||||
|
WatchProgressStore(dir).upsert("item-1", positionMs = 3_600_000L, durationMs = 7_200_000L)
|
||||||
|
|
||||||
|
val reloaded = WatchProgressStore(dir)
|
||||||
|
assertEquals(3_600_000L, reloaded.resumePosition("item-1"))
|
||||||
|
assertEquals(50, reloaded.watchedPercent("item-1"))
|
||||||
|
assertEquals(mapOf("item-1" to WatchProgress(3_600_000L, 7_200_000L)), reloaded.flow.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown duration gives percent null but resume works`() {
|
||||||
|
val (store, _) = newStore()
|
||||||
|
store.upsert("item-1", positionMs = 3_600_000L)
|
||||||
|
assertNull(store.watchedPercent("item-1"))
|
||||||
|
assertEquals(3_600_000L, store.resumePosition("item-1"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package pw.binom.viewmate.phone.stt
|
|||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
import pw.binom.viewmate.core.stt.splitPhrases
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тесты чистой функции [splitPhrases] (TASK-phrase-split-stt): резать буфер на
|
* Тесты чистой функции [splitPhrases] (TASK-phrase-split-stt): резать буфер на
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package pw.binom.viewmate.phone.stt
|
|||||||
|
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import pw.binom.viewmate.core.stt.s16leToFloat
|
||||||
|
|
||||||
class S16ToFloatTest {
|
class S16ToFloatTest {
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package pw.binom.viewmate.phone.stt
|
|||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertContentEquals
|
import kotlin.test.assertContentEquals
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import pw.binom.viewmate.core.stt.trimEdges
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Тесты чистой функции [trimEdges] (TZ-STT): краевая тишина убирается,
|
* Тесты чистой функции [trimEdges] (TZ-STT): краевая тишина убирается,
|
||||||
|
|||||||
@@ -14,3 +14,13 @@ allprojects {
|
|||||||
}
|
}
|
||||||
group = "pw.binom.viewmate"
|
group = "pw.binom.viewmate"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Собери оба приложения и подпиши ключом Binom (v2+v3) одним шагом.
|
||||||
|
// Кестор/креды берутся из скилла android-sign (см. signingConfigs в build.gradle.kts каждого app-модуля);
|
||||||
|
// без кестора release соберётся без подписи.
|
||||||
|
tasks.register("assembleSigned") {
|
||||||
|
group = "build"
|
||||||
|
description = "Собирает app-phone и app-glasses release-APK, подписанные ключом Binom (v2+v3)."
|
||||||
|
dependsOn(":app-phone:assembleRelease", ":app-glasses:assembleRelease")
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,35 @@ fun defaultHttpClient(): HttpClient = HttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-клиент для media-mirror: тяжёлые опросы (mirror.list с большим лимитом)
|
||||||
|
* на сервере считаются заметно дольше, чем обычные запросы, поэтому таймауты
|
||||||
|
* шире [defaultHttpClient] — иначе живой, но медленный ответ упирается в таймаут.
|
||||||
|
*/
|
||||||
|
fun defaultMirrorHttpClient(): HttpClient = HttpClient {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
explicitNulls = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
install(HttpTimeout) {
|
||||||
|
connectTimeoutMillis = 15_000
|
||||||
|
requestTimeoutMillis = 90_000
|
||||||
|
}
|
||||||
|
install(Logging) {
|
||||||
|
logger = object : Logger {
|
||||||
|
override fun log(message: String) {
|
||||||
|
if (HttpClientDebugLogging.enabled) println("[view-mate] $message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
level = LogLevel.INFO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Флаг debug-логирования HTTP (по умолчанию включён). */
|
/** Флаг debug-логирования HTTP (по умолчанию включён). */
|
||||||
object HttpClientDebugLogging {
|
object HttpClientDebugLogging {
|
||||||
var enabled: Boolean = true
|
var enabled: Boolean = true
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package pw.binom.viewmate.core.media
|
|||||||
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import io.ktor.client.request.delete
|
import io.ktor.client.request.delete
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.request.header
|
import io.ktor.client.request.header
|
||||||
@@ -19,7 +21,7 @@ import io.ktor.http.contentType
|
|||||||
class MirrorClient(
|
class MirrorClient(
|
||||||
private val baseUrl: String, // https://mirror.binom.pw
|
private val baseUrl: String, // https://mirror.binom.pw
|
||||||
private val apiKey: String, // X-API-Key
|
private val apiKey: String, // X-API-Key
|
||||||
private val http: HttpClient = defaultHttpClient(),
|
private val http: HttpClient = defaultMirrorHttpClient(),
|
||||||
) {
|
) {
|
||||||
/** Заказ конвертации элемента. 201 — создан, 200 — уже есть (дубль). */
|
/** Заказ конвертации элемента. 201 — создан, 200 — уже есть (дубль). */
|
||||||
suspend fun createMirror(itemId: String): MirrorJob {
|
suspend fun createMirror(itemId: String): MirrorJob {
|
||||||
@@ -42,15 +44,26 @@ class MirrorClient(
|
|||||||
return response.body()
|
return response.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Список зеркал: статус (null — все) + лимит. */
|
/** Тяжёлый запрос (большой limit, медленный сервер): повторяем при сбое. Статус (null — все) + лимит. */
|
||||||
suspend fun list(status: String? = null, limit: Int = 50): List<MirrorJob> {
|
suspend fun list(status: String? = null, limit: Int = 50): List<MirrorJob> {
|
||||||
val response = http.get("$baseUrl/api/mirror") {
|
var last: Exception? = null
|
||||||
header("X-API-Key", apiKey)
|
repeat(LIST_ATTEMPTS) { attempt ->
|
||||||
parameter("limit", limit)
|
try {
|
||||||
if (status != null) parameter("status", status)
|
val response = http.get("$baseUrl/api/mirror") {
|
||||||
|
header("X-API-Key", apiKey)
|
||||||
|
parameter("limit", limit)
|
||||||
|
if (status != null) parameter("status", status)
|
||||||
|
}
|
||||||
|
response.ensureSuccess("mirror.list")
|
||||||
|
return response.body()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
last = e
|
||||||
|
if (attempt < LIST_ATTEMPTS - 1) delay(LIST_RETRY_DELAY_MS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
response.ensureSuccess("mirror.list")
|
throw ApiClientException("mirror.list не удался: ${last?.message}", cause = last)
|
||||||
return response.body()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Удаление зеркала (204). */
|
/** Удаление зеркала (204). */
|
||||||
@@ -69,4 +82,11 @@ class MirrorClient(
|
|||||||
response.ensureSuccess("mirror.scan")
|
response.ensureSuccess("mirror.scan")
|
||||||
return response.body()
|
return response.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Сколько раз повторяем [list] при сбое (один повтор на подёргивание сервера). */
|
||||||
|
private const val LIST_ATTEMPTS = 2
|
||||||
|
|
||||||
|
private const val LIST_RETRY_DELAY_MS = 1_500L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,16 @@ data class SttAudio(val data: ByteArray) : GlassesToHost {
|
|||||||
@SerialName("stop_stt")
|
@SerialName("stop_stt")
|
||||||
data class StopStt(val cancel: Boolean) : GlassesToHost
|
data class StopStt(val cancel: Boolean) : GlassesToHost
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Готовый распознанный текст (очки → телефон): очки сами распознали речь локально
|
||||||
|
* (Qwen3-ASR) и шлют фактический текст — телефон маршрутизирует его в LLM.
|
||||||
|
* Замена пути SttAudio/StopStt (там распознавал телефон); тот остаётся как фолбэк
|
||||||
|
* для старых очей. Пустой текст очки не шлют.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("stt_text")
|
||||||
|
data class SttText(val text: String) : GlassesToHost
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Лог-пачка очков (очки → телефон). Телефон прибавляет эти записи к своему
|
* Лог-пачка очков (очки → телефон). Телефон прибавляет эти записи к своему
|
||||||
* коллектору и пересылает на сервер вместе со своими лог-записями;
|
* коллектору и пересылает на сервер вместе со своими лог-записями;
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package pw.binom.viewmate.core.stt
|
||||||
|
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чистые (без Android) преобразования PCM перед офлайн-распознаванием — общие
|
||||||
|
* для телефона и очков. Телефон накапливает и распознаёт на себе (фолбэк); очки
|
||||||
|
* распозновывают локально и шлют готовый текст (новый путь). Тело — дословно из
|
||||||
|
* SttStreamer (app-phone): числа выверены на дампах микрофона очков (×3, 16 кГц),
|
||||||
|
* НЕ менять без перепроверки.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
|
||||||
|
fun s16leToFloat(pcm: ByteArray): FloatArray {
|
||||||
|
val n = pcm.size / 2
|
||||||
|
val out = FloatArray(n)
|
||||||
|
var i = 0
|
||||||
|
for (j in 0 until n) {
|
||||||
|
val lo = pcm[i].toInt() and 0xff
|
||||||
|
val hi = pcm[i + 1].toInt() and 0xff
|
||||||
|
i += 2
|
||||||
|
var s = lo or (hi shl 8)
|
||||||
|
if (s >= 0x8000) s -= 0x10000
|
||||||
|
out[j] = (s / 32768f).coerceIn(-1f, 1f)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обрезает краевую тишину в начале и конце [samples], сохраняя внутренние паузы.
|
||||||
|
* Окно [windowSize] сэмплов (320 = 20 мс @ 16 кГц) — звучным считается окно,
|
||||||
|
* чей RMS >= [threshold]. Возвращает срез от первого до последнего звучного окна
|
||||||
|
* с отступом [padWindows] окон (15 = 300 мс) в обе стороны (clamp к границам).
|
||||||
|
*
|
||||||
|
* Если звучных окон нет — пустой массив. Внутренние тишины НЕ трогаются.
|
||||||
|
* Чистая функция (без Android) — покрыта JVM-тестами.
|
||||||
|
*/
|
||||||
|
fun trimEdges(
|
||||||
|
samples: FloatArray,
|
||||||
|
threshold: Float = 0.01f,
|
||||||
|
windowSize: Int = 320,
|
||||||
|
padWindows: Int = 15,
|
||||||
|
): FloatArray {
|
||||||
|
if (samples.isEmpty()) return samples
|
||||||
|
var first = -1
|
||||||
|
var lastEnd = -1
|
||||||
|
var idx = 0
|
||||||
|
while (idx < samples.size) {
|
||||||
|
val end = minOf(idx + windowSize, samples.size)
|
||||||
|
var sum = 0.0
|
||||||
|
for (k in idx until end) sum += samples[k] * samples[k].toDouble()
|
||||||
|
val rms = sqrt(sum / (end - idx)).toFloat()
|
||||||
|
if (rms >= threshold) {
|
||||||
|
if (first == -1) first = idx
|
||||||
|
lastEnd = end
|
||||||
|
}
|
||||||
|
idx = end
|
||||||
|
}
|
||||||
|
if (first == -1) return FloatArray(0)
|
||||||
|
val pad = padWindows * windowSize
|
||||||
|
val start = maxOf(0, first - pad)
|
||||||
|
val stop = minOf(samples.size, lastEnd + pad)
|
||||||
|
return samples.copyOfRange(start, stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон).
|
||||||
|
* Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с).
|
||||||
|
* Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) —
|
||||||
|
* НЕ менять без перепроверки на тех же дампах.
|
||||||
|
*
|
||||||
|
* Окно [windowSize] сэмплов — ТИШИНА, если RMS < [threshold] (0.005, НЕ 0.01
|
||||||
|
* как в [trimEdges]: 0.01 режет тихие начала слов на обрубки). Пауза =
|
||||||
|
* подряд ≥ [minSilenceWindows] тихих окон (0.6 с) — она РАЗДЕЛЯЕТ фразы.
|
||||||
|
* Фраза = звуковые окна между паузами + отступ [padWindows] тихих окон с
|
||||||
|
* каждой стороны (clamp к границам массива). Фраза короче
|
||||||
|
* [minPhraseWindows] (1.5 с) объединяется с соседней (обрубок); единственная
|
||||||
|
* — остаётся. Фраза длиннее [maxPhraseSamples] режется на куски ≤ 30 с по
|
||||||
|
* границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы
|
||||||
|
* < 0.6 с не трогаются. Чистая функция — покрыта JVM-тестами.
|
||||||
|
*/
|
||||||
|
fun splitPhrases(
|
||||||
|
samples: FloatArray,
|
||||||
|
threshold: Float = 0.005f,
|
||||||
|
windowSize: Int = 320, // 20 мс @ 16 кГц
|
||||||
|
minSilenceWindows: Int = 30, // 30 окон × 20 мс = пауза 0.6 с
|
||||||
|
minPhraseWindows: Int = 75, // 1.5 с — короче объединять с соседней
|
||||||
|
maxPhraseSamples: Int = 30 * 16_000, // страховка whisper ≤ 30 с
|
||||||
|
padWindows: Int = 15, // 300 мс пре/пост-ролла тишины у фразы
|
||||||
|
): List<FloatArray> {
|
||||||
|
if (samples.isEmpty()) return emptyList()
|
||||||
|
val winCount = (samples.size + windowSize - 1) / windowSize
|
||||||
|
val isSound = BooleanArray(winCount)
|
||||||
|
var anySound = false
|
||||||
|
for (w in 0 until winCount) {
|
||||||
|
val s = w * windowSize
|
||||||
|
val e = minOf(s + windowSize, samples.size)
|
||||||
|
var sum = 0.0
|
||||||
|
for (k in s until e) sum += samples[k] * samples[k].toDouble()
|
||||||
|
if (sqrt(sum / (e - s)).toFloat() >= threshold) {
|
||||||
|
isSound[w] = true
|
||||||
|
anySound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anySound) return emptyList()
|
||||||
|
|
||||||
|
// Диапазоны звукового содержимого фраз как индексы окон [startWin, endWin): фразы
|
||||||
|
// разграничены паузами из ≥ minSilenceWindows подряд тихих окон.
|
||||||
|
class Phrase(var start: Int, var end: Int)
|
||||||
|
|
||||||
|
val phrases = mutableListOf<Phrase>()
|
||||||
|
var pos = 0
|
||||||
|
while (pos < winCount) {
|
||||||
|
var sil = 0
|
||||||
|
while (pos + sil < winCount && !isSound[pos + sil]) sil++
|
||||||
|
if (pos + sil >= winCount) break
|
||||||
|
val start = pos + sil
|
||||||
|
var end = start
|
||||||
|
var continuePhrase = true
|
||||||
|
while (continuePhrase) {
|
||||||
|
var run = 0
|
||||||
|
while (end + run < winCount && isSound[end + run]) run++
|
||||||
|
end += run
|
||||||
|
if (end >= winCount) { continuePhrase = false; break }
|
||||||
|
var sil2 = 0
|
||||||
|
while (end + sil2 < winCount && !isSound[end + sil2]) sil2++
|
||||||
|
if (sil2 >= minSilenceWindows) {
|
||||||
|
continuePhrase = false
|
||||||
|
} else {
|
||||||
|
end += sil2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
phrases.add(Phrase(start, end))
|
||||||
|
pos = end
|
||||||
|
}
|
||||||
|
|
||||||
|
// Короткая (< 1.5 с) фраза объединяется с соседней (ед. фраза — без изменений).
|
||||||
|
while (phrases.size > 1) {
|
||||||
|
val idx = phrases.indexOfFirst { it.end - it.start < minPhraseWindows }
|
||||||
|
if (idx < 0) break
|
||||||
|
val other = if (idx == 0) idx + 1 else idx - 1
|
||||||
|
val lo = minOf(idx, other)
|
||||||
|
val hi = maxOf(idx, other)
|
||||||
|
val merged = Phrase(
|
||||||
|
minOf(phrases[lo].start, phrases[hi].start),
|
||||||
|
maxOf(phrases[lo].end, phrases[hi].end),
|
||||||
|
)
|
||||||
|
phrases.removeAt(hi)
|
||||||
|
phrases.removeAt(lo)
|
||||||
|
phrases.add(lo, merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pad = padWindows * windowSize
|
||||||
|
val out = mutableListOf<FloatArray>()
|
||||||
|
for (p in phrases) {
|
||||||
|
val s = maxOf(0, p.start * windowSize - pad)
|
||||||
|
val e = minOf(samples.size, p.end * windowSize + pad)
|
||||||
|
if (e - s <= maxPhraseSamples) {
|
||||||
|
out += samples.copyOfRange(s, e)
|
||||||
|
} else {
|
||||||
|
val chunk = (maxPhraseSamples / windowSize) * windowSize
|
||||||
|
var cs = s
|
||||||
|
while (cs < e) {
|
||||||
|
val ce = minOf(e, cs + chunk)
|
||||||
|
out += samples.copyOfRange(cs, ce)
|
||||||
|
cs = ce
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -213,4 +213,14 @@ class GlassesToHostTest {
|
|||||||
assertIs<StopStt>(decoded)
|
assertIs<StopStt>(decoded)
|
||||||
assertTrue(decoded.cancel)
|
assertTrue(decoded.cancel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sttTextRoundTrip() {
|
||||||
|
val msg: GlassesToHost = SttText(text = "включи сериал брат 2")
|
||||||
|
val text = json.encodeToString(GlassesToHost.serializer(), msg)
|
||||||
|
assertEquals("""{"type":"stt_text","text":"включи сериал брат 2"}""", text)
|
||||||
|
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
|
||||||
|
assertIs<SttText>(decoded)
|
||||||
|
assertEquals("включи сериал брат 2", decoded.text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user