Compare commits

10 Commits

Author SHA1 Message Date
subochev adfe376dd4 CI: системный JDK 17 на раннере вместо setup-java (adoptium виснет)
build / Android APK to F-Droid (release) Successful in 1m2s
2026-08-21 19:45:43 +03:00
subochev 20654e916e CI: Java 17 (jvmToolchain в lib-core)
build / Android APK to F-Droid (release) Failing after 14m32s
2026-08-21 19:31:37 +03:00
subochev ee9700c558 CICD: сборка APK на release → F-Droid; versionCode из тега; фикс скролла каталога и масштаба
build / Android APK to F-Droid (release) Failing after 36s
2026-08-21 19:27:02 +03:00
subochev ee86b9dc50 Revert "feat: retry конвертации (2 попытки) при failed/error"
This reverts commit cbcd22f09c.
2026-08-21 12:08:57 +03:00
Hermes Agent cbcd22f09c feat: retry конвертации (2 попытки) при failed/error
- polling-цикл: при status failed/error — retry (до 2 раз)
- логирование каждого retry
- после исчерпания retry — текущее поведение (кнопка 'Перезаказать')
2026-08-21 11:54:48 +03:00
Hermes Agent a434d5bb12 feat: потоковый локальный ассистент — фраза → LLM → ответ на очки
- agent/Assistant.kt: оркестратор диалога (SQLite-история + Koog), таймаут 30с,
  сессия одна живая, история — последние 20 сообщений
- ChatDao.latestSessionId(); SYSTEM_PROMPT нейтральный (TODO: персона)
- PhoneApp: очередь фраз (Channel UNLIMITED), assistantLoop (THINKING/ShowText/IDLE),
  ensureAssistant ленивый; ключ/модель из BuildConfig (local.properties)
- Очки: assistantText StateFlow, ShowText → оверлей (белый текст речи + серый ответ),
  оверлей остаётся после Click до новой сессии
- AssistantTest (androidTest, 3 теста: сессия/история/таймаут) — OK (6 tests всего)
- README: строка про ассистента
2026-08-20 12:35:49 +03:00
Hermes Agent d9d34e9277 feat: история общения (SQLite) + Koog LLM-клиент в app-phone
SQLite (голый SQLiteOpenHelper, без ORM — как решили):
- sessions (id, title, created_at, updated_at) + messages (role, content,
  tool_call_id, tool_calls JSON, attachments JSON, created_at) по утверждённой
  схеме; FK-каскад; CHECK на роли.
- ChatDb + ChatDao (createSession/listSessions/rename/touch/delete, addMessage,
  messagesForSession), доступ через PhoneApp.chatDao.
- Unit: ModelsTest (роли, JSON tool_calls/attachments). androidTest:
  ChatDaoTest — CRUD-круг + каскад — OK (3 tests) на эмуляторе.

Koog (JetBrains) — базовый LLM-клиент, без агентного слоя:
- ai.koog:prompt-executor-openai-client:1.1.1 + http-client-okhttp
- LlmClient: OpenAILLMClient(apiKey, baseUrl=llm.binom.pw) → chat(история)
- BuildConfig LLM_API_KEY/LLM_MODEL из local.properties (вне git); llm_debug
  smoke (--ez llm_debug true) — проверен на эмуляторе: реальный ответ от
  llm.binom.pw (deepseek-v4-flash).
- ЗАГЛУШКА Coroutines_jvmKt: дефект публикации Koog 1.1.1 — utils-android не
  включает jvm-класс, который зовёт OkHttpKoogHttpClient (без неё
  NoClassDefFoundError на первом вызове). Сигнатура сверена с utils-jvm.
  Убрать после фикса JetBrains.
2026-08-20 11:52:50 +03:00
Hermes Agent 3ed63012f4 build: Kotlin 2.2.0 → 2.4.10 (AGP 8.9.2, Gradle 8.14 — в официальном диапазоне)
- libs.versions.toml: kotlin = 2.4.10
- jvmTarget '17' (kotlinOptions, String) — в 2.4.10 ошибка → compilerOptions DSL
  (JvmTarget.JVM_17) в app-phone и app-glasses
- Compose-плагин (org.jetbrains.kotlin.plugin.compose) версионируется с Kotlin — 2.4.10
- Проверено: lib-core allTests + phone/glasses тесты + оба APK — BUILD SUCCESSFUL
- Открывает дорогу Koog (требует Kotlin 2.3.10+)
2026-08-20 10:51:07 +03:00
Hermes Agent 1304c6d921 feat: голосовой ввод на очках — TripleClick + PCM → телефон → текст на очки
Протокол: SttAudio (PCM s16 16k mono кусками), SttPhrase/SttDone/SttCancel
(host→очки), StopStt(cancel) (очки→host).

Очки: TripleClick — безусловный вызов (кино: пауза + затемнение + слушаем);
во время распознавания Click — завершить (весь текст в консоль, затемнение
уходит, видео на паузе), DoubleClick — отмена; VAD-тишина 30с — автоотмена.
Микрофон (AudioRecord 16k) → SttAudio; на эмуляторе — PCM из filesDir/samples
(куски 3200 + пауза 200мс). Оверлей текста в Binocular (полупрозрачный фон,
текст по центру, в оба глаза). RECORD_AUDIO в манифесте. DEBUG-ресивер
(am broadcast DEBUG_GESTURE type=triple_click|click|double_click).

Телефон: SttStreamer — стриминговый VAD (Silero fp32, окна 512) → фразы →
Whisper-small int8 (executor, не блокирует приём) → SttPhrase на очки;
StopStt → SttDone (полный текст) / SttCancel; watchdog 30с тишины.

Фиксы (найдены на эмуляторе): IllegalThreadStateException — двойной старт
watchdog-потока (thread{}.apply{start()}); decode входящих — try/catch с
логом (не роняет сессию).

Проверено на эмуляторах (5554 очки + 5556 телефон, forward 8080):
triple_click → фразы «Раз, два» и «3 4 5» на очках (оверлей) → click →
«ВЕСЬ ТЕКСТ: Раз, два 3 4 5»; 30с-тишина → «отменено». Тесты: lib-core,
phone, glasses — зелёные.
2026-08-20 04:12:28 +03:00
Hermes Agent 324c427c74 feat(app-phone): офлайн-распознавание речи — sherpa-onnx + Whisper-small
STT-модуль для отладки (без UI): WhisperStt (offline whisper int8, CPU),
VadSegmenter (Silero VAD, фразы), WavReader (16k mono s16), SttDebug
(распознавание wav-файла по фразам с печатью в лог). Вызов: am start
--ez stt_debug true (первый wav из filesDir/samples). Модели — в
filesDir/models (вне git): small-encoder/decoder.int8.onnx,
small-tokens.txt, silero_vad.onnx (fp32!).

Грабли, найденные на эмуляторе (Android 15):
- Silero VAD int8 (643КБ) молчит в Android-рантайме — нужна fp32 (2.2МБ)
- WavReader: littleShort возвращал беззнаковое 0..65535 — сэмплы с
  DC-offset, whisper давал мусор; теперь знаковый -32768..32767
- VAD требует подачу окнами ровно window_size (целиком — переполнение)
- Модели кладутся через adb push /data/local/tmp + run-as cp
  (внешний каталог приложения на Android 15 не видит adb-файлы)

Проверено на эмуляторе (CPU): «раз два три четыре пять» → «Раз, два 3 4 5»,
грека → почти идеально, masha/anton → 21 фраза, диалог распознан.
README: таблица жестов обновлена (TripleClick — безусловный вызов).
2026-08-20 03:29:57 +03:00
44 changed files with 2234 additions and 25 deletions
+50
View File
@@ -0,0 +1,50 @@
name: build
on:
release:
types: [published]
jobs:
android:
name: Android APK to F-Droid
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Setup Java 17
shell: bash
run: |
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
java -version 2>&1 | head -1
- name: Setup Android SDK
shell: bash
run: |
set -euo pipefail
export ANDROID_HOME=$HOME/android-sdk
mkdir -p "$ANDROID_HOME/cmdline-tools"
if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
curl -sSL -o /tmp/cmdtools.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q -o /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
fi
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null || true
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-35" "build-tools;36.0.0" > /dev/null
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
- name: Build APKs
shell: bash
run: |
chmod +x gradlew
./gradlew :app-phone:assembleRelease :app-glasses:assembleRelease \
-PversionCode=${{ gitea.ref_name }} \
--no-daemon --stacktrace
- name: Publish phone to F-Droid
uses: https://git.binom.pw/subochev/devops/fdroid-publish@main
with:
apk_path: "app-phone/build/outputs/apk/release/app-phone-release-unsigned.apk"
- name: Publish glasses to F-Droid
uses: https://git.binom.pw/subochev/devops/fdroid-publish@main
with:
apk_path: "app-glasses/build/outputs/apk/release/app-glasses-release-unsigned.apk"
+23 -3
View File
@@ -59,15 +59,35 @@ sketches/ эскизы UI: раскладка экранов + сценар
## Жесты (распознаёт RayNeo SDK, `TempleAction`) ## Жесты (распознаёт RayNeo SDK, `TempleAction`)
### Вне распознавания (обычный режим)
| Жест (SDK) | Режим КИНО | Режим ЧАТ | | Жест (SDK) | Режим КИНО | Режим ЧАТ |
|------------|-----------|-----------| |------------|-----------|-----------|
| `Click` | пауза ↔ плей | старт / стоп диктовки | | `Click` | пауза ↔ плей | старт / стоп диктовки |
| `DoubleClick` | игнор | отмена диктовки; вне диктовки — вызов локального ассистента | | `DoubleClick` | игнор | отмена диктовки |
| `TripleClick` | **безусловный вызов ассистента** (пауза видео + затемнение + слушаем) | **безусловный вызов ассистента** (слушаем) |
| `SlideBackward` / `SlideForward` | перемотка | в `SESSION_SELECT` — листание сессий; в `TALKING` — скролл текста ответа | | `SlideBackward` / `SlideForward` | перемотка | в `SESSION_SELECT` — листание сессий; в `TALKING` — скролл текста ответа |
| `LongClick` | (резерв) | показать / спрятать текст последнего ответа | | `LongClick` | (резерв) | показать / спрятать текст последнего ответа |
| `TripleClick` | (резерв) | (резерв) |
> Жесты распознаёт сам SDK (`TempleAction.Click/DoubleClick/TripleClick/SlideBackward/SlideForward/LongClick`) — своя детекция не нужна (проверено в pair-view, `MainActivity.kt`). ### Во время распознавания речи (LISTENING)
| Жест | Действие |
|------|----------|
| `Click` | **завершить распознавание**: вывести весь текст (все фразы) в консоль; затемнение уходит, видео остаётся на паузе |
| `DoubleClick` | **отмена распознавания** (текст отбрасывается) |
| `TripleClick` | игнор |
| VAD молчит 30 с | автоматическая отмена распознавания |
> **TripleClick — безусловный вызов локального ассистента из любого места**
> (поверх всего). Распознавание идёт по фразам: VAD отсёк фразу — распознал —
> отобразил на очках — слушаем дальше; ввод считается открытым до Click.
>
> Жесты распознаёт сам SDK (`TempleAction.Click/DoubleClick/SlideBackward/
> SlideForward/LongClick`) — своя детекция не нужна (проверено в pair-view,
> `MainActivity.kt`). `TripleClick`: если SDK его не отдаёт — детект по
> таймстампам кликов в `onTouchEvent`.
Ассистент (потоковый): TripleClick → говорите → каждая фраза уходит в LLM (llm.binom.pw), ответ текстом на очки; история диалога — в SQLite на телефоне. Модель/ключ: local.properties llm.model/llm.apiKey.
## Состояния ассистента (единые для обоих ассистентов) ## Состояния ассистента (единые для обоих ассистентов)
+106
View File
@@ -0,0 +1,106 @@
# Задача: масштаб на всех экранах очков + сохранение скролла каталога
## Баг 1: ConnectionScreen игнорирует масштаб
**Симптом (пользователь):** «в режиме НЕ просмотра видео (пока ничего не включено)
очки игнорируют выставленный масштаб». Плюс «масштаб будто бы не сохраняется —
сразу после старта масштаб 1».
**Корень:** масштаб (`GlassesApp.instance.scale`, 0.5..1) применяется ТОЛЬКО в
`GlassesMovieScreen.kt` (контейнер `width((screenWidthPx * scale).toInt().px)` +
offset к центру). А `ConnectionScreen()` в
`app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt` (строка ~354)
рисует контент на ПОЛНУЮ половину глаза (`Modifier.width(screenWidthPx.px)`) —
scale не читает вообще. Поэтому и «не сохраняется»: сохранённый scale в prefs есть,
но на не-видео экране он не виден.
**Фикс:** в `ConnectionScreen()` применять scale так же, как в GlassesMovieScreen:
```kotlin
fun ConnectionScreen() {
val connection = GlassesApp.instance.hostConnection
val connected by connection.connected.collectAsState()
val status by connection.status.collectAsState()
val messages by connection.messages.collectAsState()
val scale by GlassesApp.instance.scale.collectAsState() // ← НОВОЕ
Binocular(scale = false) {
val screenWidthPx = BinocularScreenSize.current.width
val screenHeightPx = BinocularScreenSize.current.height
// масштаб через layout — как в GlassesMovieScreen
val containerW = (screenWidthPx * scale).toInt()
val containerH = (screenHeightPx * scale).toInt()
val offsetX = (screenWidthPx * (1f - scale)).toInt() // сдвиг к носу (левый глаз)
Box(
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(x = offsetX.px)
.background(Color.Black),
) {
... существующий контент ...
}
}
}
```
Внутри контейнера всё остаётся: Column с текстами, StatusPanelCompose,
оверлей распознавания — но всё это теперь в уменьшенном контейнере.
Импорт `androidx.compose.foundation.layout.offset` и `width/height` уже есть.
⚠️ ВАЖНО: `Modifier.offset(x=...)` — смещение внутри родителя. Внутри
`Binocular(scale=false)` контент рисуется на половину 640. При scale<1 контейнер
уже (например 448 при 0.7) и сдвинут к центру (к носу): offsetX = 640*(1-0.7)=192.
Для левого глаза сдвиг вправо (к центру 640), правый глаз — как в GlassesMovieScreen
(offset 0, контейнер прижат к центру слева). Смотри `scaleOffsetX` в
GlassesMovieScreen.kt:143 — скопировать ту же логику (side из BinocularContent,
там есть `Side.LEFT/RIGHT` через CompositionLocal).
## Баг 2: скролл каталога теряется при навигации
**Симптом (пользователь):** «кручу вниз до нужной папки, захожу, тыкаю назад
(кнопку или аппаратную) и я оказываюсь в предыдущем окне в самом начале (скролл
не сохранился). Надо сохранять скролл, даже если зашёл в папку, потом ещё в
папку, потом ещё — и т.д.»
**Корень:** `app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt`:
`LazyVerticalGrid` (строка 220) БЕЗ `LazyGridState` — позиция не хранится.
`CatalogState` живёт на корне (переживает переходы), но позицию скролла не помнит.
**Фикс:** добавить в `CatalogState`:
- `private var scrollIndex by mutableStateOf(0)` / `scrollOffset by mutableStateOf(0)` — текущая позиция
- `private val savedScroll = mutableMapOf<Path, Pair<Int, Int>>()` — сохранённые позиции по пути
- в `openSeries`/`openSeason`/`openFolder`: **сначала** `savedScroll[path] = scrollIndex to scrollOffset`, **потом** менять path
- в `back()`: после смены path — восстановить `(scrollIndex, scrollOffset) = savedScroll[path] ?: (0,0)`
- методы `setScroll(index, offset)` (для snapshotFlow из UI) и `getSavedScroll(): Pair<Int,Int>` (текущий path)
В `CatalogScreen` composable:
```kotlin
val gridState = rememberLazyGridState()
// восстанавливаем позицию при смене пути/загрузке
LaunchedEffect(state.path, state.items) {
val (idx, off) = state.savedScrollForCurrentPath()
gridState.scrollToItem(idx, off)
}
// сохраняем позицию при скролле
LaunchedEffect(gridState) {
snapshotFlow {
gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset
}.collect { (idx, off) -> state.setScroll(idx, off) }
}
```
и `LazyVerticalGrid(state = gridState, ...)`.
Ключ сохранения — РОДИТЕЛЬСКИЙ path (тот, откуда ушли). При back() path меняется
на родителя — достаём сохранённое. Вложенные папки работают автоматически:
каждый уровень сохраняет свою позицию.
Импорты: `androidx.compose.foundation.lazy.grid.rememberLazyGridState`,
`androidx.compose.runtime.snapshotFlow`.
## Критерии приёмки
1. Сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug` — успех.
2. Юнит-тесты: `./gradlew :lib-core:testDebugUnitTest :app-phone:testDebugUnitTest :app-glasses:testDebugUnitTest` — зелёные.
3. Ручная проверка на очках: выставить масштаб 0.7 → ConnectionScreen (без видео) тоже уменьшен (чёрные поля по краям, картинка/текст сдвинуты к носу).
4. Ручная проверка: скролл каталога вниз → зайти в папку → назад → позиция на месте; то же для вложенных папок.
+6 -4
View File
@@ -13,8 +13,8 @@ android {
applicationId = "pw.binom.viewmate.glasses" applicationId = "pw.binom.viewmate.glasses"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
versionName = "0.1.0" versionName = (project.findProperty("versionName") as? String) ?: "0.1.0"
} }
buildTypes { buildTypes {
@@ -28,8 +28,10 @@ android {
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlin {
jvmTarget = "17" compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
} }
buildFeatures { buildFeatures {
+1
View File
@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission <uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES" android:name="android.permission.NEARBY_WIFI_DEVICES"
@@ -36,6 +36,18 @@ class GlassesApp : Application() {
/** Масштаб экрана очков (0.5..1.0). Загружается в onCreate (prefs — после привязки контекста). */ /** Масштаб экрана очков (0.5..1.0). Загружается в onCreate (prefs — после привязки контекста). */
val scale = MutableStateFlow(1f) val scale = MutableStateFlow(1f)
/** Идёт распознавание речи (TripleClick стартовал, Click/DoubleClick/тишина завершили). */
val listening = MutableStateFlow(false)
/** Накопленный текст распознавания (для оверлея); обновляется на SttPhrase. */
val sttText = MutableStateFlow("")
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
val assistantText = MutableStateFlow("")
/** Текущая Activity (для закрытия приложения по команде с телефона). */
var mainActivity: MainActivity? = null
/** Применить и сохранить масштаб. */ /** Применить и сохранить масштаб. */
fun applyScale(value: Float) { fun applyScale(value: Float) {
val v = value.coerceIn(0.5f, 1f) val v = value.coerceIn(0.5f, 1f)
@@ -31,7 +31,11 @@ import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.PlaybackPosition import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SetMode import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.SetScale import pw.binom.viewmate.core.protocol.SetScale
import pw.binom.viewmate.core.protocol.Shutdown
import pw.binom.viewmate.core.protocol.ShowText import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttDone
import pw.binom.viewmate.core.protocol.SttPhrase
import pw.binom.viewmate.core.protocol.Welcome import pw.binom.viewmate.core.protocol.Welcome
/** /**
@@ -264,9 +268,35 @@ class HostConnection(
is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)") is AssistantStateMsg -> addMessage("AssistantState: ${msg.state} (${msg.recordingSeconds}с)")
is ShowText -> addMessage("ShowText: ${msg.text}") is ShowText -> {
addMessage("ShowText: ${msg.text}")
GlassesApp.instance.assistantText.value = msg.text
}
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}") is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
is SttPhrase -> {
GlassesApp.instance.sttText.value = msg.full
log("stt", "фраза: ${msg.phrase}")
}
is SttDone -> {
log("stt", "ВЕСЬ ТЕКСТ: ${msg.full}")
GlassesApp.instance.listening.value = false
}
is SttCancel -> {
log("stt", "отменено")
GlassesApp.instance.listening.value = false
}
is Shutdown -> {
addMessage("Shutdown: закрываю приложение")
// как в pair-view: finish() Activity очков
GlassesApp.instance.mainActivity?.finishAffinity()
// страховка: процесс завершится сам, но kill убирает зависшие потоки (RayNeo)
android.os.Process.killProcess(android.os.Process.myPid())
}
} }
} }
@@ -5,6 +5,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.hardware.display.DisplayManager import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@@ -17,9 +18,11 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -33,6 +36,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
@@ -40,19 +44,49 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import com.rayneo.arsdk.android.touch.TempleAction
import com.rayneo.arsdk.android.ui.activity.BaseEventActivity 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.protocol.MediaCommandMsg
import pw.binom.viewmate.core.protocol.SttAudio
import pw.binom.viewmate.core.protocol.StopStt
import pw.binom.viewmate.glasses.gestures.actionName import pw.binom.viewmate.glasses.gestures.actionName
import pw.binom.viewmate.glasses.gestures.templeActionToCommand import pw.binom.viewmate.glasses.gestures.templeActionToCommand
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.Binocular import pw.binom.viewmate.glasses.ui.Binocular
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
import pw.binom.viewmate.glasses.ui.BinocularScreenSize import pw.binom.viewmate.glasses.ui.BinocularScreenSize
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
import pw.binom.viewmate.glasses.ui.Side
import pw.binom.viewmate.glasses.ui.px import pw.binom.viewmate.glasses.ui.px
import pw.binom.viewmate.glasses.ui.scaleOffsetX
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-имитация жестов (эмулятор без трекпада):
* `adb shell am broadcast -a pw.binom.viewmate.glasses.DEBUG_GESTURE --es type triple_click`
* Типы: triple_click / click / double_click. Оставлен для отладки.
*/
private val debugReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.getStringExtra("type")) {
"triple_click" -> handleTripleClick()
"click" -> handleClick()
"double_click" -> handleDoubleClick()
else -> log("gesture", "DEBUG_GESTURE: неизвестный тип ${intent?.getStringExtra("type")}")
}
}
}
private val screenReceiver = object : BroadcastReceiver() { private val screenReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) { when (intent?.action) {
@@ -81,6 +115,7 @@ class MainActivity : BaseEventActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
GlassesApp.instance.mainActivity = this
collectTempleActions() collectTempleActions()
disableTrackpadSounds() disableTrackpadSounds()
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -111,6 +146,22 @@ class MainActivity : BaseEventActivity() {
} }
registerReceiver(screenReceiver, filter) registerReceiver(screenReceiver, filter)
// DEBUG-имитация жестов на эмуляторе (см. debugReceiver). RECEIVER_EXPORTED
// на 33+ — shell `am broadcast` ходит под uid shell и не достучится до
// NOT_EXPORTED-ресивера. Ресивер ДЕБАГОВЫЙ (симулирует жесты) — см. TASK.md.
if (Build.VERSION.SDK_INT >= 33) {
registerReceiver(
debugReceiver,
IntentFilter("pw.binom.viewmate.glasses.DEBUG_GESTURE"),
Context.RECEIVER_EXPORTED,
)
} else {
registerReceiver(
debugReceiver,
IntentFilter("pw.binom.viewmate.glasses.DEBUG_GESTURE"),
)
}
val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
dm.registerDisplayListener(object : DisplayManager.DisplayListener { dm.registerDisplayListener(object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) {} override fun onDisplayAdded(displayId: Int) {}
@@ -137,25 +188,80 @@ class MainActivity : BaseEventActivity() {
}, null) }, null)
} }
/** Жесты трекпада → команды просмотра (Click=пауза/плей, свайпы=±30с). */ /**
* Жесты трекпада → команды. Click/Slide — управление кино; TripleClick —
* голосовой ввод (безусловный вызов), Click во время прослушивания —
* завершить распознавание, DoubleClick — отмена.
*/
private fun collectTempleActions() { private fun collectTempleActions() {
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) { repeatOnLifecycle(Lifecycle.State.RESUMED) {
templeActionViewModel.state.collect { action -> templeActionViewModel.state.collect { action ->
val cmd = templeActionToCommand(action) when (action) {
if (cmd != null) { is TempleAction.TripleClick -> handleTripleClick()
log("gesture", "жест: ${actionName(action)} → ${cmd.command}") is TempleAction.Click -> handleClick()
GlassesApp.instance.movieController.applyMediaCommand(cmd) { is TempleAction.DoubleClick -> handleDoubleClick()
GlassesApp.instance.hostConnection.sendPlaybackPositionNow() is TempleAction.SlideForward, is TempleAction.SlideBackward -> {
val cmd = templeActionToCommand(action)
log("gesture", "жест: ${actionName(action)} → ${cmd?.command}")
if (cmd != null) {
GlassesApp.instance.movieController.applyMediaCommand(cmd) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
} }
} else { else -> log("gesture", "жест (резерв): ${actionName(action)}")
log("gesture", "жест (резерв): ${actionName(action)}")
} }
} }
} }
} }
} }
/** TripleClick: если не слушаем — пауза кино + старт распознавания. */
private fun handleTripleClick() {
if (GlassesApp.instance.listening.value) {
log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
return
}
log("stt", "ассистент: слушаю")
if (GlassesApp.instance.movieController.pauseIfPlaying()) {
log("stt", "кино на паузе (TripleClick)")
}
GlassesApp.instance.listening.value = true
GlassesApp.instance.sttText.value = ""
GlassesApp.instance.assistantText.value = ""
sttMic.start(filesDir) { GlassesApp.instance.listening.value }
}
/** Click: во время прослушивания — завершить распознавание; иначе пауза/плей. */
private fun handleClick() {
if (GlassesApp.instance.listening.value) {
log("gesture", "Click → завершить распознавание")
sttMic.stop()
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
}
return
}
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
}
}
/** DoubleClick: во время прослушивания — отмена; иначе — резерв. */
private fun handleDoubleClick() {
if (GlassesApp.instance.listening.value) {
log("gesture", "DoubleClick → отмена распознавания")
sttMic.stop()
lifecycleScope.launch {
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
}
return
}
log("gesture", "жест (резерв): DoubleClick")
}
/** /**
* Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool: * Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool:
* mercury_system_single_tap/fling/double_click/long_press) — это и есть * mercury_system_single_tap/fling/double_click/long_press) — это и есть
@@ -209,8 +315,10 @@ class MainActivity : BaseEventActivity() {
} }
override fun onDestroy() { override fun onDestroy() {
GlassesApp.instance.mainActivity = null
super.onDestroy() super.onDestroy()
unregisterReceiver(screenReceiver) unregisterReceiver(screenReceiver)
unregisterReceiver(debugReceiver)
} }
/** /**
@@ -251,12 +359,27 @@ fun ConnectionScreen() {
val connected by connection.connected.collectAsState() val connected by connection.connected.collectAsState()
val status by connection.status.collectAsState() val status by connection.status.collectAsState()
val messages by connection.messages.collectAsState() val messages by connection.messages.collectAsState()
val scale by GlassesApp.instance.scale.collectAsState()
// Бинокулярно: контент дублируется в оба глаза, панель — по центру каждой // Бинокулярно: контент дублируется в оба глаза, панель — по центру каждой
// половины (явная ширина половины, иначе fillMaxSize растянет на 1280). // половины (явная ширина половины, иначе fillMaxSize растянет на 1280).
Binocular(scale = false) { Binocular(scale = false) {
val screenWidthPx = BinocularScreenSize.current.width val screenWidthPx = BinocularScreenSize.current.width
Box(modifier = Modifier.width(screenWidthPx.px).fillMaxHeight().background(Color.Black)) { val screenHeightPx = BinocularScreenSize.current.height
val side = BinocularScreenSide.current
// Масштаб через layout: контейнер каждого глаза — scale от половины экрана,
// сдвинут к центру (к носу) на (1 - scale) своей ширины.
val containerW = (screenWidthPx * scale).toInt()
val containerH = (screenHeightPx * scale).toInt()
Box(
modifier = Modifier
.width(containerW.px)
.height(containerH.px)
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
.background(Color.Black),
) {
Column( Column(
modifier = Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 56.dp), modifier = Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 56.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -274,7 +397,43 @@ fun ConnectionScreen() {
StatusPanelCompose( StatusPanelCompose(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp), modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
scale = scale,
) )
// Оверлей распознавания: затемнение + накопленный текст (поверх всего).
// Остаётся и после завершения распознавания, пока не стёрт ответ ассистента
// (assistantText) — ответ читается до новой сессии.
val listening by GlassesApp.instance.listening.collectAsState()
val sttText by GlassesApp.instance.sttText.collectAsState()
val assistantText by GlassesApp.instance.assistantText.collectAsState()
if (listening || assistantText.isNotBlank()) {
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.72f)),
) {
Column(
modifier = Modifier.align(Alignment.Center).padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
if (sttText.isBlank()) "слушаю..." else sttText,
color = Color.White,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium,
)
if (assistantText.isNotBlank()) {
Spacer(Modifier.height(12.dp))
Text(
assistantText,
color = Color(0xFFB0BEC5),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleSmall,
)
}
}
}
}
} }
} }
} }
@@ -0,0 +1,119 @@
package pw.binom.viewmate.glasses.stt
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import java.io.File
import java.io.FileInputStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import pw.binom.viewmate.glasses.log
/**
* Поток распознаваемого аудио очков: куски s16le (16 кГц, mono) → колбэк.
* На эмуляторе микрофона нет — вместо него отправляется первый wav из
* filesDir/samples (отладка, см. TASK.md этап 2).
*
* Список кусков/стоп — по [shouldRun] (наружу смотрит стейт listening):
* как только очки перестали слушать, поток обрывается.
*/
class SttMicStream(
private val scope: CoroutineScope,
private val onChunk: suspend (ByteArray) -> Unit,
) {
private var job: Job? = null
fun start(filesDir: File, shouldRun: () -> Boolean) {
job?.cancel()
val recorder = runCatching {
AudioRecord(
MediaRecorder.AudioSource.MIC,
16_000,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
6_400,
)
}.getOrNull()
if (recorder == null || recorder.state != AudioRecord.STATE_INITIALIZED) {
recorder?.release()
log("stt", "микрофон недоступен — отладка: PCM из файла")
job = scope.launch { sendWavFileAsStream(filesDir, shouldRun) }
return
}
job = scope.launch(Dispatchers.IO) {
try {
recorder.startRecording()
log("stt", "микрофон запущен (16 кГц, s16, mono)")
val buf = ByteArray(3_200)
while (isActive && shouldRun()) {
val n = recorder.read(buf, 0, buf.size)
if (n > 0) {
val chunk = if (n == buf.size) buf else buf.copyOf(n)
onChunk(chunk)
}
}
} catch (t: Throwable) {
log("stt", "микрофон: ${t.message}")
} finally {
runCatching { recorder.stop() }
recorder.release()
log("stt", "микрофон остановлен")
}
}
}
fun stop() {
job?.cancel()
job = null
}
/**
* Эмуляция живого потока: первый wav из filesDir/samples, куски по 3200 байт
* (100 мс) с паузой 200 мс, в конце — пауза 1 с (хвост тишины).
*/
private suspend fun sendWavFileAsStream(filesDir: File, shouldRun: () -> Boolean) {
val samplesDir = File(filesDir, "samples")
val wav = samplesDir.listFiles()
?.filter { it.isFile && it.extension.equals("wav", true) }
?.firstOrNull()
if (wav == null) {
log("stt", "нет samples/*.wav в ${samplesDir.absolutePath} — нечего отправлять")
return
}
log("stt", "шлю файл ${wav.name} кусками по 3200 байт")
val wavData = runCatching { WavReader.read(FileInputStream(wav)) }.getOrNull()
if (wavData == null) {
log("stt", "не удалось прочитать ${wav.name}")
return
}
val pcm = floatSamplesToPcm16(wavData.samples)
var off = 0
while (off < pcm.size) {
if (!shouldRun()) break
val end = minOf(off + 3_200, pcm.size)
onChunk(pcm.copyOfRange(off, end))
off = end
delay(200)
}
log("stt", "wav-файл отправлен целиком")
delay(1_000)
}
}
/** FloatArray [-1, 1] → s16le ByteArray (16 кГц, mono). */
internal fun floatSamplesToPcm16(samples: FloatArray): ByteArray {
val out = ByteArray(samples.size * 2)
var i = 0
for (s in samples) {
val v = (s.coerceIn(-1f, 1f) * 32767f).toInt()
out[i++] = (v and 0xff).toByte()
out[i++] = ((v shr 8) and 0xff).toByte()
}
return out
}
@@ -0,0 +1,88 @@
package pw.binom.viewmate.glasses.stt
import java.io.InputStream
/**
* Читает WAV (16 кГц, mono, s16) в FloatArray [-1, 1].
* Копия ридера app-phone (этап 1) — очкам не нужна зависимость от телефона.
*/
object WavReader {
/** Прочитать WAV из потока. */
fun read(stream: InputStream): Wav {
val riff = stream.readBytes(4)
require(riff.contentEquals("RIFF".toByteArray())) { "не WAV-файл (нет RIFF)" }
stream.skip(4) // chunk size
val wave = stream.readBytes(4)
require(wave.contentEquals("WAVE".toByteArray())) { "не WAV-файл (нет WAVE)" }
var sampleRate = 0
var channels = 0
var dataBytes: ByteArray = ByteArray(0)
while (true) {
val id = stream.readBytes(4)
if (id.isEmpty()) break
val size = stream.readLittleInt()
when (String(id, Charsets.US_ASCII)) {
"fmt " -> {
val fmt = stream.readBytes(size)
val audioFormat = littleShort(fmt, 0)
require(audioFormat == 1) { "поддерживается только PCM s16 (audioFormat=$audioFormat)" }
channels = littleShort(fmt, 2)
sampleRate = littleInt(fmt, 4)
if (size > 16) stream.skip((size - 16).toLong())
}
"data" -> {
dataBytes = stream.readBytes(size)
break
}
else -> stream.skip(size.toLong())
}
}
require(sampleRate == 16000) { "ожидается 16 кГц, а тут $sampleRate" }
require(channels == 1) { "ожидается mono, а тут $channels" }
val count = dataBytes.size / 2
val samples = FloatArray(count)
for (i in 0 until count) {
val s = littleShort(dataBytes, i * 2)
samples[i] = (s / 32768f).coerceIn(-1f, 1f)
}
return Wav(sampleRate, channels, samples)
}
private fun InputStream.readBytes(n: Int): ByteArray {
val buf = ByteArray(n)
var off = 0
while (off < n) {
val r = read(buf, off, n - off)
if (r < 0) break
off += r
}
return if (off == n) buf else buf.copyOf(off)
}
private fun InputStream.readLittleInt(): Int {
val b = readBytes(4)
return littleInt(b, 0)
}
private fun littleShort(b: ByteArray, off: Int): Int {
val u = (b[off].toInt() and 0xff) or ((b[off + 1].toInt() and 0xff) shl 8)
return if (u >= 0x8000) u - 0x10000 else u
}
private fun littleInt(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xff) or
((b[off + 1].toInt() and 0xff) shl 8) or
((b[off + 2].toInt() and 0xff) shl 16) or
((b[off + 3].toInt() and 0xff) shl 24)
}
data class Wav(
val sampleRate: Int,
val channels: Int,
val samples: FloatArray,
)
@@ -0,0 +1,27 @@
package pw.binom.viewmate.glasses.stt
import kotlin.test.Test
import kotlin.test.assertEquals
class PcmConversionTest {
@Test
fun floatToPcm16RoundTrip() {
val samples = floatArrayOf(0f, 0.5f, -1f, 1f, -0.25f)
val pcm = floatSamplesToPcm16(samples)
assertEquals(samples.size * 2, pcm.size)
// 0 → 0x0000
assertEquals(0x00, pcm[0].toInt() and 0xff)
assertEquals(0x00, pcm[1].toInt() and 0xff)
// 1 → 0x7fff
assertEquals(0xff, pcm[6].toInt() and 0xff)
assertEquals(0x7f, pcm[7].toInt() and 0xff)
}
@Test
fun emptyInput() {
assertEquals(0, floatSamplesToPcm16(FloatArray(0)).size)
}
}
@@ -113,6 +113,15 @@ open class GlassesHub(
log("glasses", "очки выключились → пауза (${msg.reason})") log("glasses", "очки выключились → пауза (${msg.reason})")
state.playing = false state.playing = false
} }
is pw.binom.viewmate.core.protocol.SttAudio -> {
// Хост-отладка STT не ведёт — аудио с очков игнорируется.
log("glasses", "stt_audio: ${msg.data.size} байт (игнор)")
}
is pw.binom.viewmate.core.protocol.StopStt -> {
log("glasses", "stop_stt: cancel=${msg.cancel} (игнор — STT нет на хосте)")
}
} }
} }
+54 -4
View File
@@ -1,3 +1,5 @@
import java.util.Properties
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,28 @@ plugins {
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
} }
// LLM-ключ из local.properties (llm.apiKey) — файл вне git; пустой = ещё не задан.
// providers.gradleProperty не читает local.properties, поэтому грузим файл вручную.
val llmApiKey: String = run {
val props = Properties()
val local = rootProject.file("local.properties")
if (local.exists()) local.inputStream().use(props::load)
props.getProperty("llm.apiKey", "")
.replace("\\", "\\\\")
.replace("\"", "\\\"")
}
// Модель для ассистента — из local.properties (llm.model), вне git.
// Дефолт — быстрая дешёвая (пользователь укажет боевую).
val llmModel: String = run {
val props = Properties()
val local = rootProject.file("local.properties")
if (local.exists()) local.inputStream().use(props::load)
props.getProperty("llm.model", "Routerai/deepseek/deepseek-v4-flash-0731")
.replace("\\", "\\\\")
.replace("\"", "\\\"")
}
android { android {
namespace = "pw.binom.viewmate.phone" namespace = "pw.binom.viewmate.phone"
compileSdk = 35 compileSdk = 35
@@ -13,8 +37,14 @@ android {
applicationId = "pw.binom.viewmate.phone" applicationId = "pw.binom.viewmate.phone"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
versionName = "0.1.0" versionName = (project.findProperty("versionName") as? String) ?: "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// LLM-ключ из local.properties (llm.apiKey) — вне git; пустой = ещё не задан
buildConfigField("String", "LLM_API_KEY", "\"$llmApiKey\"")
// Модель ассистента — из local.properties (llm.model), вне git
buildConfigField("String", "LLM_MODEL", "\"$llmModel\"")
} }
buildTypes { buildTypes {
@@ -28,12 +58,22 @@ android {
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlin {
jvmTarget = "17" compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
}
packaging {
resources {
// okhttp-sse 5.x и jspecify оба кладут этот OSGI-manifest
excludes += "META-INF/versions/9/OSGI-INF/MANIFEST.MF"
}
} }
} }
@@ -54,6 +94,13 @@ dependencies {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(project(":lib-core")) implementation(project(":lib-core"))
// Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
implementation(libs.koog.openai.client)
implementation(libs.koog.http.client.okhttp)
// Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack
implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6")
// WS-сервер телефона (CIO — работает на Android) // WS-сервер телефона (CIO — работает на Android)
implementation("io.ktor:ktor-server-core:3.3.0") implementation("io.ktor:ktor-server-core:3.3.0")
implementation("io.ktor:ktor-server-cio:3.3.0") implementation("io.ktor:ktor-server-cio:3.3.0")
@@ -66,4 +113,7 @@ dependencies {
testImplementation("io.ktor:ktor-client-content-negotiation:3.3.0") testImplementation("io.ktor:ktor-client-content-negotiation:3.3.0")
testImplementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0") testImplementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0")
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test:runner:1.6.2")
} }
@@ -0,0 +1,100 @@
package pw.binom.viewmate.phone.agent
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AssistantTest {
private lateinit var dao: ChatDao
private val context: Context = ApplicationProvider.getApplicationContext()
@Before
fun setUp() {
context.deleteDatabase("assistant.db")
dao = ChatDao(ChatDb(context))
}
@After
fun tearDown() {
context.deleteDatabase("assistant.db")
}
@Test
fun firstPhraseCreatesSessionAndTwoMessages() = runBlocking {
val histories = mutableListOf<List<ChatMessage>>()
val fake: suspend (List<ChatMessage>, String?) -> String = { history, _ ->
histories += history
"Привет! Чем помочь?"
}
val assistant = Assistant(chatDao = dao, chat = fake, timeoutMs = 1_000)
val answer = assistant.process("привет")
// ответ = то, что вернул фейк
assertEquals("Привет! Чем помочь?", answer)
// создана сессия, в ней 2 сообщения (user + assistant)
val sessionId = dao.latestSessionId()
assertTrue("сессия должна создаться", sessionId != null)
val messages = dao.messagesForSession(sessionId!!)
assertEquals(2, messages.size)
assertEquals(ChatRole.USER, messages[0].role)
assertEquals("привет", messages[0].content)
assertEquals(ChatRole.ASSISTANT, messages[1].role)
assertEquals("Привет! Чем помочь?", messages[1].content)
}
@Test
fun secondPhraseReusesSessionAndFakeGetsHistory() = runBlocking {
val histories = mutableListOf<List<ChatMessage>>()
val fake: suspend (List<ChatMessage>, String?) -> String = { history, _ ->
histories += history
"ответ #${history.size}"
}
val assistant = Assistant(chatDao = dao, chat = fake, timeoutMs = 1_000)
assistant.process("первый")
val session1 = dao.latestSessionId()
assistant.process("второй")
val session2 = dao.latestSessionId()
// сессия та же
assertEquals(session1, session2)
// фейк получил историю из 2+ сообщений (user1 + assistant1 + user2)
assertTrue("фейк получил историю из 2+ сообщений", histories.last().size >= 2)
// в сессии 4 сообщения
assertEquals(4, dao.messagesForSession(session1!!).size)
}
@Test
fun timeoutReturnsMessageAndOnlyUserMessagePersisted() = runBlocking {
val fake: suspend (List<ChatMessage>, String?) -> String = { _, _ ->
delay(150)
"опоздал"
}
val assistant = Assistant(chatDao = dao, chat = fake, timeoutMs = 100)
val answer = assistant.process("вопрос")
assertEquals("Ассистент не ответил (таймаут)", answer)
// в БД ТОЛЬКО user-сообщение — assistant не пишется
val sessionId = dao.latestSessionId()
val messages = dao.messagesForSession(sessionId!!)
assertEquals(1, messages.size)
assertEquals(ChatRole.USER, messages[0].role)
assertEquals("вопрос", messages[0].content)
}
}
@@ -0,0 +1,88 @@
package pw.binom.viewmate.phone.agent
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ChatDaoTest {
private lateinit var dao: ChatDao
private val context: Context = ApplicationProvider.getApplicationContext()
@Before
fun setUp() {
// чистый старт: БД пересоздаётся на каждый тест
context.deleteDatabase("assistant.db")
dao = ChatDao(ChatDb(context))
}
@After
fun tearDown() {
context.deleteDatabase("assistant.db")
}
@Test
fun sessionLifecycleWithMessages() {
val sessionId = dao.createSession()
assertTrue(sessionId > 0)
val userMsgId = dao.addMessage(sessionId, ChatRole.USER, "включи Матрицу")
val assistantMsgId = dao.addMessage(
sessionId,
ChatRole.ASSISTANT,
"",
toolCalls = listOf(ToolCall("c1", "play_movie", "{}")),
)
val toolMsgId = dao.addMessage(sessionId, ChatRole.TOOL, "", toolCallId = "c1")
// порядок по id и совпадение ролей/полей
val messages = dao.messagesForSession(sessionId)
assertEquals(listOf(userMsgId, assistantMsgId, toolMsgId), messages.map { it.id })
assertEquals(3, messages.size)
assertEquals(ChatRole.USER, messages[0].role)
assertEquals("включи Матрицу", messages[0].content)
assertTrue(messages[0].toolCalls.isEmpty())
assertEquals(ChatRole.ASSISTANT, messages[1].role)
assertEquals(listOf(ToolCall("c1", "play_movie", "{}")), messages[1].toolCalls)
assertEquals(ChatRole.TOOL, messages[2].role)
assertEquals("c1", messages[2].toolCallId)
// список сессий: созданная есть
assertEquals(sessionId, dao.listSessions().single().id)
// каскадное удаление сообщений вместе с сессией
dao.deleteSession(sessionId)
assertTrue(dao.messagesForSession(sessionId).isEmpty())
assertTrue(dao.listSessions().none { it.id == sessionId })
}
@Test
fun addMessageTouchesSessionUpdatedAt() {
val sessionId = dao.createSession()
val before = dao.listSessions().single().updatedAt
dao.addMessage(sessionId, ChatRole.USER, "привет")
val after = dao.listSessions().single().updatedAt
assertTrue("updated_at должен обновиться после addMessage", after >= before)
}
@Test
fun renameSessionChangesTitle() {
val sessionId = dao.createSession("первая")
dao.renameSession(sessionId, "Матрица")
val session = dao.listSessions().single()
assertEquals("Матрица", session.title)
}
}
@@ -0,0 +1,17 @@
package ai.koog.utils.io
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
/**
* ВРЕМЕННАЯ заглушка дефекта публикации Koog 1.1.1: android-артефакт
* utils-android НЕ включает jvm-класс Coroutines_jvmKt, хотя его зовёт
* OkHttpKoogHttpClient (ai.koog:http-client-okhttp — jvm-артефакт).
* Сигнатура сверена с utils-jvm-1.1.1.jar (javap): getSuitableForIO(Dispatchers).
* Убрать, когда JetBrains починит публикацию (следить за Koog > 1.1.1).
*/
object Coroutines_jvmKt {
@JvmStatic
fun getSuitableForIO(@Suppress("UNUSED_PARAMETER") dispatchers: Dispatchers): CoroutineDispatcher =
Dispatchers.IO
}
@@ -28,8 +28,13 @@ import pw.binom.viewmate.core.protocol.Gesture
import pw.binom.viewmate.core.protocol.Hello import pw.binom.viewmate.core.protocol.Hello
import pw.binom.viewmate.core.protocol.HostToGlasses import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.PlaybackPosition import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SttAudio
import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttDone
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
import pw.binom.viewmate.phone.stt.SttStreamer
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
const val GLASSES_WS_PATH = "/ws/glasses" const val GLASSES_WS_PATH = "/ws/glasses"
@@ -93,6 +98,18 @@ class GlassesHub(
*/ */
var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int) -> Unit)? = null var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int) -> Unit)? = null
/**
* Стриминговое распознавание речи с очков. Лениво создаётся через
* [sttFactory] (модели во filesDir/models); колбэки onPhrase/onSilence30s
* рассылаются через [broadcast].
*/
@Volatile
var stt: SttStreamer? = null
/** Фабрика SttStreamer на первый SttAudio (тяжёлая — создаётся один раз). */
@Volatile
var sttFactory: (() -> SttStreamer?)? = null
fun add(session: DefaultWebSocketServerSession) { fun add(session: DefaultWebSocketServerSession) {
sessions[session] = Unit sessions[session] = Unit
_connected.value = sessions.size _connected.value = sessions.size
@@ -119,7 +136,12 @@ class GlassesHub(
/** Обработать входящее текстовое сообщение от очков. */ /** Обработать входящее текстовое сообщение от очков. */
suspend fun handle(session: DefaultWebSocketServerSession, text: String) { suspend fun handle(session: DefaultWebSocketServerSession, text: String) {
val msg = protocolJson.decodeFromString(GlassesToHost.serializer(), text) val msg = try {
protocolJson.decodeFromString(GlassesToHost.serializer(), text)
} catch (e: Exception) {
log("hub", "ОШИБКА декодирования входящего: ${e::class.simpleName}: ${e.message} (текст: ${text.take(120)})")
return
}
when (msg) { when (msg) {
is Hello -> { is Hello -> {
log("glasses", "hello (app=${msg.appVersion}) → welcome") log("glasses", "hello (app=${msg.appVersion}) → welcome")
@@ -168,6 +190,24 @@ class GlassesHub(
this.state.playing = false this.state.playing = false
onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0) onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0)
} }
is SttAudio -> {
val s = stt ?: sttFactory?.invoke()?.also { stt = it }
s?.accept(msg.data)
}
is StopStt -> {
if (msg.cancel) {
log("stt", "распознавание отменено (очки)")
send(session, SttCancel())
stt?.reset()
} else {
val full = stt?.finish() ?: ""
log("stt", "ВЕСЬ ТЕКСТ: $full")
send(session, SttDone(full))
stt?.reset()
}
}
} }
} }
@@ -197,6 +237,7 @@ fun Application.glassesServerModule(hub: GlassesHub) {
} }
} catch (e: Exception) { } catch (e: Exception) {
log("server", "WS-ошибка: ${e.message}") log("server", "WS-ошибка: ${e.message}")
e.printStackTrace()
} finally { } finally {
hub.remove(this) hub.remove(this)
} }
@@ -1,9 +1,16 @@
package pw.binom.viewmate.phone package pw.binom.viewmate.phone
import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.view.Gravity
import android.widget.FrameLayout
import android.widget.TextView
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import pw.binom.viewmate.phone.stt.SttDebug
import pw.binom.viewmate.phone.agent.LlmDebug
import pw.binom.viewmate.phone.ui.ViewMateApp import pw.binom.viewmate.phone.ui.ViewMateApp
import java.io.File
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -11,5 +18,58 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
ViewMateApp() ViewMateApp()
} }
if (intent?.getBooleanExtra("stt_debug", false) == true) {
runSttDebug()
}
if (intent?.getBooleanExtra("llm_debug", false) == true) {
runLlmDebug()
}
}
/** Smoke Koog-клиента: один вызов llm.binom.pw, ответ в лог [llm]. */
private fun runLlmDebug() {
showOverlay("[llm] вызываю llm.binom.pw...")
Thread {
LlmDebug.run(BuildConfig.LLM_API_KEY, BuildConfig.LLM_MODEL)
runOnUiThread { showOverlay("[llm] готово — смотри лог") }
}.start()
}
/** Отладочный прогон распознавания: берёт первый wav из filesDir/samples. */
private fun runSttDebug() {
val samplesDir = File(filesDir, "samples")
val wav = samplesDir.listFiles()
?.filter { it.isFile && it.extension.equals("wav", true) }
?.firstOrNull()
if (wav == null) {
val msg = "[stt] нет wav-файлов в ${samplesDir.absolutePath}"
log("stt", msg)
showOverlay(msg)
return
}
showOverlay("[stt] распознаю ${wav.name}...")
Thread {
val text = SttDebug.run(this, wav.absolutePath)
runOnUiThread { showOverlay(text ?: "[stt] не удалось распознать") }
}.start()
}
/** TextView поверх экрана — чтобы видеть результат на эмуляторе. */
private fun showOverlay(text: String) {
val tv = TextView(this).apply {
this.text = text
setTextColor(Color.WHITE)
setBackgroundColor(Color.argb(200, 0, 0, 0))
textSize = 16f
setPadding(24, 24, 24, 24)
}
addContentView(
tv,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.TOP,
),
)
} }
} }
@@ -1,11 +1,13 @@
package pw.binom.viewmate.phone package pw.binom.viewmate.phone
import android.app.Application import android.app.Application
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -17,6 +19,17 @@ import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.MirrorClient import pw.binom.viewmate.core.media.MirrorClient
import pw.binom.viewmate.core.phone.PhoneActions import pw.binom.viewmate.core.phone.PhoneActions
import pw.binom.viewmate.core.phone.PhoneState import pw.binom.viewmate.core.phone.PhoneState
import pw.binom.viewmate.core.protocol.AssistantState
import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttPhrase
import pw.binom.viewmate.phone.agent.Assistant
import pw.binom.viewmate.phone.agent.ChatDao
import pw.binom.viewmate.phone.agent.ChatDb
import pw.binom.viewmate.phone.agent.LlmClient
import pw.binom.viewmate.phone.stt.SttStreamer
import pw.binom.viewmate.phone.stt.WhisperStt
/** /**
* Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState, * Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState,
@@ -46,6 +59,9 @@ class PhoneApp : Application() {
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */ /** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) } val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
/** История общения с ассистентом (SQLite, "assistant.db"). */
val chatDao: ChatDao by lazy { ChatDao(ChatDb(this)) }
/** Автоматика паузы/продолжения звука при обрыве связи. */ /** Автоматика паузы/продолжения звука при обрыве связи. */
private val connectionAutoResume = ConnectionAutoResume() private val connectionAutoResume = ConnectionAutoResume()
@@ -57,6 +73,92 @@ class PhoneApp : Application() {
/** itemId, у которого автоподхват звука уже не удался — не дёргать повторно каждые 5с. */ /** itemId, у которого автоподхват звука уже не удался — не дёргать повторно каждые 5с. */
private var lastFailedItemId: String? = null private var lastFailedItemId: String? = null
private var sttLock = Any()
private var sttStreamer: SttStreamer? = null
/**
* Очередь распознанных фраз для ассистента. UNLIMITED — `send` неблокирующий,
* STT-поток не блокируется. Единственный consumer — [assistantLoop] (сериализация
* LLM-вызовов: фразы обрабатываются по одной).
*/
private val assistantChannel = Channel<String>(Channel.UNLIMITED)
private var assistantLock = Any()
private var assistant: Assistant? = null
/** Ленивый ассистент (паттерн ensureStt): один экземпляр на процесс, не на каждую фразу. */
private fun ensureAssistant(): Assistant? {
synchronized(assistantLock) {
assistant?.let { return it }
if (BuildConfig.LLM_API_KEY.isBlank()) return null
val llm = LlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
return Assistant(chatDao = chatDao, chat = { history, system -> llm.chat(history, system) })
.also { assistant = it }
}
}
/**
* Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки.
* Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем.
*/
private suspend fun assistantLoop() {
for (phrase in assistantChannel) {
val assistant = ensureAssistant()
if (assistant == null) {
log("assistant", "LLM-ключ не задан — фраза пропущена")
continue
}
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
val answer = runCatching { assistant.process(phrase) }
.onFailure { log("assistant", "ошибка: ${it.message}") }
.getOrElse { "Ассистент недоступен: ${it.message}" }
server.hub.broadcast(ShowText(answer))
server.hub.broadcast(AssistantStateMsg(AssistantState.IDLE))
}
}
/**
* Лениво создаёт стриминговое распознавание (модели во filesDir/models —
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
* на первый SttAudio; тяжёлое создание Whisper — один раз на процесс.
*/
private fun ensureStt(): SttStreamer? {
synchronized(sttLock) {
sttStreamer?.let { return it }
val modelsDir = File(filesDir, "models")
val vadModel = File(modelsDir, "silero_vad.onnx")
val encoder = File(modelsDir, "small-encoder.int8.onnx")
val decoder = File(modelsDir, "small-decoder.int8.onnx")
val tokens = File(modelsDir, "small-tokens.txt")
if (!vadModel.isFile || !encoder.isFile || !decoder.isFile || !tokens.isFile) {
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
return null
}
val whisper = WhisperStt(
encoderPath = encoder.absolutePath,
decoderPath = decoder.absolutePath,
tokensPath = tokens.absolutePath,
numThreads = 4,
)
val s = SttStreamer(
stt = whisper,
vadModelPath = vadModel.absolutePath,
onPhrase = { phrase, full ->
log("stt", "фраза: $phrase")
scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) }
scope.launch { assistantChannel.send(phrase) }
},
onSilence30s = {
log("stt", "тишина 30 с — автo-отмена")
scope.launch { server.hub.broadcast(SttCancel()) }
},
)
sttStreamer = s
log("stt", "SttStreamer готов (Whisper-small int8, VAD fp32)")
return s
}
}
/** /**
* Автоподхват звука после рестарта телефона: очки продолжают играть и шлют * Автоподхват звука после рестарта телефона: очки продолжают играть и шлют
* PlaybackPosition с itemId+audioIndex, а audioSync не активен (телефон «не в курсе»). * PlaybackPosition с itemId+audioIndex, а audioSync не активен (телефон «не в курсе»).
@@ -90,6 +192,8 @@ class PhoneApp : Application() {
server.start() server.start()
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}") log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
nsd.publish() nsd.publish()
server.hub.sttFactory = { ensureStt() }
scope.launch { assistantLoop() }
val lastPositionAt = AtomicLong(0) val lastPositionAt = AtomicLong(0)
val lastWasPlaying = AtomicBoolean(false) val lastWasPlaying = AtomicBoolean(false)
@@ -161,6 +265,7 @@ class PhoneApp : Application() {
override fun onTerminate() { override fun onTerminate() {
nsd.unpublish() nsd.unpublish()
sttStreamer?.close()
server.stop() server.stop()
super.onTerminate() super.onTerminate()
} }
@@ -0,0 +1,37 @@
package pw.binom.viewmate.phone.agent
import kotlinx.coroutines.withTimeoutOrNull
/**
* Оркестратор диалога с локальным ассистентом: фраза → история из SQLite →
* LLM → ответ. Потоковый режим: каждая распознанная фраза уходит в LLM,
* история накапливается в сессии. Синхронный вызов из одной корутины
* (сериализация фраз — LLM-вызовы не пересекаются).
*/
class Assistant(
private val chatDao: ChatDao,
private val chat: suspend (history: List<ChatMessage>, system: String?) -> String,
private val timeoutMs: Long = 30_000,
) {
/**
* Обработать фразу: сохранить user-сообщение, вызвать LLM с историей
* сессии (последние 20 сообщений), сохранить ответ. При таймауте LLM —
* ответ с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
*/
suspend fun process(text: String): String {
val sessionId = chatDao.latestSessionId() ?: chatDao.createSession("ассистент")
chatDao.addMessage(sessionId, role = ChatRole.USER, content = text)
val history = chatDao.messagesForSession(sessionId).takeLast(20)
val answer = withTimeoutOrNull(timeoutMs) { chat(history, SYSTEM_PROMPT) }
?: return "Ассистент не ответил (таймаут)"
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
chatDao.touchSession(sessionId)
return answer
}
companion object {
// TODO(персона): промпт выберет пользователь
const val SYSTEM_PROMPT =
"Ты — локальный голосовой ассистент в AR-очках. Отвечай кратко (1-3 предложения), по-русски, без markdown и эмодзи. Рядом с тобой может идти фильм — вопросы могут быть про него."
}
}
@@ -0,0 +1,151 @@
package pw.binom.viewmate.phone.agent
import android.content.ContentValues
import android.database.Cursor
import kotlinx.serialization.encodeToString
/**
* DAO истории общения. Все методы синхронные — вызывать из Dispatchers.IO
* на стороне клиента. Голый SQLiteOpenHelper + ContentValues/rawQuery, без ORM.
*/
class ChatDao(private val db: ChatDb) {
fun createSession(title: String = ""): Long {
val now = System.currentTimeMillis()
return db.writableDatabase.insert(
ChatDb.TABLE_SESSIONS,
null,
ContentValues().apply {
put("title", title)
put("created_at", now)
put("updated_at", now)
},
)
}
/** Последняя активная сессия (по обновлению), или null, если сессий ещё нет. */
fun latestSessionId(): Long? {
val cursor = db.readableDatabase.rawQuery(
"SELECT id FROM sessions ORDER BY updated_at DESC, id DESC LIMIT 1",
null,
)
return cursor.use { c -> if (c.moveToFirst()) c.getLong(0) else null }
}
fun renameSession(id: Long, title: String) {
db.writableDatabase.update(
ChatDb.TABLE_SESSIONS,
ContentValues().apply { put("title", title) },
"id = ?",
arrayOf(id.toString()),
)
}
fun touchSession(id: Long) {
db.writableDatabase.update(
ChatDb.TABLE_SESSIONS,
ContentValues().apply { put("updated_at", System.currentTimeMillis()) },
"id = ?",
arrayOf(id.toString()),
)
}
fun listSessions(): List<Session> {
val cursor = db.readableDatabase.rawQuery(
"SELECT id, title, created_at, updated_at FROM sessions ORDER BY updated_at DESC",
null,
)
return cursor.use { c ->
val sessions = ArrayList<Session>(c.count)
while (c.moveToNext()) sessions += c.toSession()
sessions
}
}
/** Каскад в messages.session_id удалит и все сообщения. */
fun deleteSession(id: Long) {
db.writableDatabase.delete(
ChatDb.TABLE_SESSIONS,
"id = ?",
arrayOf(id.toString()),
)
}
fun addMessage(
sessionId: Long,
role: ChatRole,
content: String = "",
toolCallId: String? = null,
toolCalls: List<ToolCall> = emptyList(),
attachments: List<Attachment> = emptyList(),
): Long {
val id = db.writableDatabase.insert(
ChatDb.TABLE_MESSAGES,
null,
ContentValues().apply {
put("session_id", sessionId)
put("role", role.wire)
put("content", content)
put("tool_call_id", toolCallId)
put("tool_calls", encodeToolCalls(toolCalls))
put("attachments", encodeAttachments(attachments))
put("created_at", System.currentTimeMillis())
},
)
touchSession(sessionId)
return id
}
fun messagesForSession(sessionId: Long): List<ChatMessage> {
val cursor = db.readableDatabase.rawQuery(
"SELECT * FROM messages WHERE session_id = ? ORDER BY id",
arrayOf(sessionId.toString()),
)
return cursor.use { c ->
val messages = ArrayList<ChatMessage>(c.count)
while (c.moveToNext()) messages += c.toChatMessage()
messages
}
}
// ---------- маппинг строк ----------
private fun Cursor.toSession(): Session = Session(
id = getLong(getColumnIndexOrThrow("id")),
title = getString(getColumnIndexOrThrow("title")),
createdAt = getLong(getColumnIndexOrThrow("created_at")),
updatedAt = getLong(getColumnIndexOrThrow("updated_at")),
)
private fun Cursor.toChatMessage(): ChatMessage = ChatMessage(
id = getLong(getColumnIndexOrThrow("id")),
sessionId = getLong(getColumnIndexOrThrow("session_id")),
role = ChatRole.fromWire(getString(getColumnIndexOrThrow("role"))),
content = getString(getColumnIndexOrThrow("content")),
toolCallId = getNullableString(getColumnIndexOrThrow("tool_call_id")),
toolCalls = decodeToolCalls(getNullableString(getColumnIndexOrThrow("tool_calls"))),
attachments = decodeAttachments(getNullableString(getColumnIndexOrThrow("attachments"))),
createdAt = getLong(getColumnIndexOrThrow("created_at")),
)
private fun Cursor.getNullableString(columnIndex: Int): String? {
if (isNull(columnIndex)) return null
return getString(columnIndex)
}
// ---------- сериализация toolCalls/attachments ----------
private fun encodeToolCalls(toolCalls: List<ToolCall>): String? =
if (toolCalls.isEmpty()) null else agentJson.encodeToString(toolCalls)
private fun encodeAttachments(attachments: List<Attachment>): String? =
if (attachments.isEmpty()) null else agentJson.encodeToString(attachments)
private fun decodeToolCalls(raw: String?): List<ToolCall> =
raw?.let { runCatching { agentJson.decodeFromString<List<ToolCall>>(it) }.getOrNull() }
?: emptyList()
private fun decodeAttachments(raw: String?): List<Attachment> =
raw?.let { runCatching { agentJson.decodeFromString<List<Attachment>>(it) }.getOrNull() }
?: emptyList()
}
@@ -0,0 +1,65 @@
package pw.binom.viewmate.phone.agent
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
/**
* Хранилище истории общения с ассистентом (v1).
* Голый SQLiteOpenHelper, без ORM. Миграции появятся позже; onUpgrade — drop + create.
*/
class ChatDb(context: Context) : SQLiteOpenHelper(context, "assistant.db", null, VERSION) {
override fun onConfigure(db: SQLiteDatabase) {
super.onConfigure(db)
// нужно для ON DELETE CASCADE в messages.session_id
db.setForeignKeyConstraintsEnabled(true)
}
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(CREATE_SESSIONS)
db.execSQL(CREATE_MESSAGES)
db.execSQL(CREATE_INDEX)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
// v1: миграций пока нет — просто пересоздаём схему
db.execSQL("DROP TABLE IF EXISTS messages")
db.execSQL("DROP TABLE IF EXISTS sessions")
onCreate(db)
}
companion object {
const val VERSION = 1
const val TABLE_SESSIONS = "sessions"
const val TABLE_MESSAGES = "messages"
private val CREATE_SESSIONS =
"""
CREATE TABLE sessions (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
""".trimIndent()
private val CREATE_MESSAGES =
"""
CREATE TABLE messages (
id INTEGER PRIMARY KEY,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user','assistant','system','tool')),
content TEXT NOT NULL DEFAULT '',
tool_call_id TEXT,
tool_calls TEXT,
attachments TEXT,
created_at INTEGER NOT NULL
)
""".trimIndent()
private val CREATE_INDEX =
"CREATE INDEX idx_messages_session ON messages(session_id, id)"
}
}
@@ -0,0 +1,64 @@
package pw.binom.viewmate.phone.agent
import ai.koog.http.client.okhttp.OkHttpKoogHttpClient
import ai.koog.prompt.dsl.prompt
import ai.koog.prompt.executor.clients.openai.OpenAIClientSettings
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient
import ai.koog.prompt.llm.LLMCapability
import ai.koog.prompt.llm.LLModel
import ai.koog.prompt.llm.LLMProvider
/**
* Базовый LLM-клиент на Koog (JetBrains), OpenAI-совместимый эндпоинт
* https://llm.binom.pw (chatCompletionsPath по умолчанию "v1/chat/completions").
*
* Модель и виртуальный ключ llm.binom.pw даст пользователь (llm.apiKey в local.properties).
* Пустой ключ — клиент создаётся, но вызовы вернут ошибку авторизации (это ожидаемо).
*
* Класс НЕ singleton и не создаётся в Application: ассистент создаст его при старте сессии.
*/
class LlmClient(
private val apiKey: String,
private val baseUrl: String = "https://llm.binom.pw",
private val model: String, // модель укажет пользователь
) {
private val client: OpenAILLMClient = OpenAILLMClient(
apiKey = apiKey,
settings = OpenAIClientSettings(baseUrl = baseUrl),
httpClientFactory = OkHttpKoogHttpClient.Factory(),
)
private val llmModel: LLModel = LLModel(
provider = LLMProvider.OpenAI,
id = model,
capabilities = listOf(
LLMCapability.Completion,
LLMCapability.OpenAIEndpoint.Completions,
),
)
/**
* Отправить историю сообщений и вернуть текстовый ответ (без стриминга).
* System-сообщение, если задано, добавляется первым.
*/
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String {
val prompt = prompt("assistant-chat") {
if (system != null) {
this.system(system)
}
for (msg in messages) {
when (msg.role) {
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) this.system(msg.content)
ChatRole.USER -> if (msg.content.isNotBlank()) user(msg.content)
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) assistant(msg.content)
// TODO(assistant): в Koog нет отдельной tool-роли — результаты инструментов
// моделируются как MessagePart.Tool.Result внутри user-сообщения, но для этого
// нужен tool-name, которого в ChatMessage нет. Пропускаем (базовый клиент).
ChatRole.TOOL -> Unit
}
}
}
return client.execute(prompt, llmModel).textContent()
}
}
@@ -0,0 +1,41 @@
package pw.binom.viewmate.phone.agent
import pw.binom.viewmate.phone.log
/**
* Smoke-проверка Koog-клиента: --ez llm_debug true → один вызов llm.binom.pw,
* ответ в лог [llm]. Модель: llm.model из local.properties (вне git), дефолт —
* быстрая дешёвая для проверки (пользователь укажет боевую).
*/
object LlmDebug {
fun run(apiKey: String, model: String) {
log("llm", "smoke: модель=$model, ключ=${if (apiKey.isBlank()) "ПУСТОЙ" else "задан"}")
if (apiKey.isBlank()) {
log("llm", "llm.apiKey не задан в local.properties — пропускаю")
return
}
val client = LlmClient(apiKey = apiKey, model = model)
try {
val reply = runBlockingSafe {
client.chat(
messages = listOf(
ChatMessage(
id = 0, sessionId = 0, role = ChatRole.USER,
content = "Привет! Ты локальный ассистент для AR-очков. Ответь одним предложением.",
toolCallId = null, toolCalls = emptyList(), attachments = emptyList(),
createdAt = 0,
),
),
system = "Ты — лаконичный ассистент.",
)
}
log("llm", "ОТВЕТ: $reply")
} catch (e: Exception) {
log("llm", "ОШИБКА: ${e::class.simpleName}: ${e.message}")
}
}
private inline fun <T> runBlockingSafe(crossinline block: suspend () -> T): T =
kotlinx.coroutines.runBlocking { block() }
}
@@ -0,0 +1,50 @@
package pw.binom.viewmate.phone.agent
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/** Роль сообщения в переписке с ассистентом. */
enum class ChatRole(val wire: String) {
USER("user"),
ASSISTANT("assistant"),
SYSTEM("system"),
TOOL("tool");
companion object {
/** Обратный маппинг из wire-строки; неизвестное значение бросает исключение. */
fun fromWire(wire: String): ChatRole =
entries.firstOrNull { it.wire == wire }
?: throw IllegalArgumentException("Unknown chat role: $wire")
}
}
/** Tool-call ассистента: arguments — JSON-строка аргументов как пришла из API. */
@Serializable
data class ToolCall(val id: String, val name: String, val arguments: String)
/** Вложение в сообщении пользователя (например, фото с камеры). */
@Serializable
data class Attachment(val type: String = "image", val uri: String, val mime: String? = null)
/** Сессия переписки. */
data class Session(
val id: Long,
val title: String,
val createdAt: Long,
val updatedAt: Long,
)
/** Сообщение в сессии. */
data class ChatMessage(
val id: Long,
val sessionId: Long,
val role: ChatRole,
val content: String,
val toolCallId: String?,
val toolCalls: List<ToolCall>,
val attachments: List<Attachment>,
val createdAt: Long,
)
/** Json для сериализации toolCalls/attachments в строковые колонки БД. */
internal val agentJson: Json = Json { ignoreUnknownKeys = true }
@@ -0,0 +1,59 @@
package pw.binom.viewmate.phone.stt
import android.content.Context
import android.util.Log
import java.io.File
import java.io.FileInputStream
/** Отладочный прогон: распознать wav-файл по фразам, печать в лог. */
object SttDebug {
private const val TAG = "stt"
/**
* Распознаёт [wavPath] по фразам (VAD + Whisper).
* Возвращает весь текст (для показа на экране), ошибки логирует и возвращает null.
*/
fun run(context: Context, wavPath: String): String? {
return try {
val wav = FileInputStream(File(wavPath)).use { WavReader.read(it) }
log("[stt] wav: $wavPath, ${wav.sampleRate} Гц, ${wav.channels} ch, ${wav.samples.size} сэмплов (${wav.samples.size / 16000.0}s)")
// Модели во внутреннем каталоге приложения. Для отладки кладём
// через: adb push /data/local/tmp/... + run-as pkg cp
val modelsDir = File(context.filesDir, "models")
val vadModel = File(modelsDir, "silero_vad.onnx").absolutePath
val encoder = File(modelsDir, "small-encoder.int8.onnx").absolutePath
val decoder = File(modelsDir, "small-decoder.int8.onnx").absolutePath
val tokens = File(modelsDir, "small-tokens.txt").absolutePath
log("[stt] создаём VAD...")
val segments = VadSegmenter.vadSegments(wav.samples, vadModel)
log("[stt] фраз (VAD): ${segments.size}")
log("[stt] создаём Whisper-small (int8, CPU)...")
val stt = WhisperStt(encoder, decoder, tokens, numThreads = 4)
val parts = ArrayList<String>()
for ((i, seg) in segments.withIndex()) {
val startMs = seg.first / 16
val endMs = seg.last / 16
val phrase = stt.recognize(wav.samples.copyOfRange(seg.first, seg.last))
parts += phrase
log("[stt] фраза ${i + 1} [$startMs-$endMs ms]: $phrase")
}
val all = parts.filter { it.isNotBlank() }.joinToString(" ")
log("[stt] ВЕСЬ ТЕКСТ: $all")
all
} catch (t: Throwable) {
Log.e(TAG, "[stt] ошибка: ${t.message}", t)
t.printStackTrace()
null
}
}
private fun log(msg: String) {
Log.i(TAG, msg)
println(msg)
}
}
@@ -0,0 +1,200 @@
package pw.binom.viewmate.phone.stt
import com.k2fsa.sherpa.onnx.SileroVadModelConfig
import com.k2fsa.sherpa.onnx.Vad
import com.k2fsa.sherpa.onnx.VadModelConfig
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
import kotlin.concurrent.withLock
/** 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
}
/**
* Стриминговое распознавание: куски PCM → VAD-фразы (Silero, окна по 512) →
* Whisper (в отдельном потоке) → колбэк [onPhrase].
*
* Потокобезопасен: [accept]/[finish]/[reset] синхронизированы; распознавание
* фраз идёт в однопоточном executor-е (не блокирует приём аудио).
*
* Тишина 30 с (с момента первого [accept] либо последней фразы) → [onSilence30s]
* (автo-отмена, вызывается один раз за сессию).
*/
class SttStreamer(
private val stt: WhisperStt,
private val vadModelPath: String,
private val onPhrase: (phrase: String, full: String) -> Unit,
private val onSilence30s: () -> Unit,
) {
companion object {
private const val SILENCE_TIMEOUT_MS = 30_000L
private const val WINDOW_SIZE = 512
}
private val lock = java.util.concurrent.locks.ReentrantLock()
private val vad: Vad = Vad(null, vadConfig())
private val executor = Executors.newSingleThreadExecutor { r ->
Thread(r, "stt-recognize").apply { isDaemon = true }
}
/** Накопленный текст сессии (читается из finish, пишется в executor-е). */
@Volatile
private var full = ""
/** Номер сессии: инкрементируется при reset/cancel — старые распознавания не всплывают. */
@Volatile
private var session = 0
/** Крайний срок «речи не было» (0 — сессия не активна). */
@Volatile
private var deadlineMs = 0L
/** Недозаполненные сэмплы (< 512) для следующего окна VAD. */
private var pending = FloatArray(0)
/** Последняя поставленная в executor задача — finish ждёт её завершения. */
private var lastFuture: Future<*>? = null
private val watchdog = thread(name = "stt-watchdog", isDaemon = true) {
while (true) {
val deadline = deadlineMs
if (deadline > 0 && System.currentTimeMillis() > deadline) {
lock.withLock {
if (deadlineMs == deadline) {
deadlineMs = 0
resetLocked()
onSilence30s()
}
}
}
runCatching { Thread.sleep(1_000) }.onFailure { return@thread }
}
}
/** Подать кусок PCM: s16le, 16 кГц, mono (~100-200 мс). */
fun accept(pcm: ByteArray) {
lock.withLock {
if (deadlineMs == 0L) deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
val floats = s16leToFloat(pcm)
var i = 0
if (pending.isNotEmpty()) {
val need = WINDOW_SIZE - pending.size
val take = minOf(need, floats.size)
val combined = pending + floats.copyOfRange(0, take)
i += take
if (combined.size == WINDOW_SIZE) {
feedWindow(combined)
pending = FloatArray(0)
} else {
pending = combined
return
}
}
while (i + WINDOW_SIZE <= floats.size) {
feedWindow(floats.copyOfRange(i, i + WINDOW_SIZE))
i += WINDOW_SIZE
}
if (i < floats.size) pending = floats.copyOfRange(i, floats.size)
}
}
/**
* Завершить сессию (клик): выбросить остаток буфера, дождаться всех
* распознаваний и вернуть полный текст. Буферы НЕ сбрасывает — после
* вызова нужно [reset] (состояние сессии снимает вызывающий).
*/
fun finish(): String {
lock.withLock {
vad.flush()
while (!vad.empty()) {
val seg = vad.front()
recognizeSegment(seg.samples)
vad.pop()
}
val last = lastFuture
if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) }
return full
}
}
/** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */
fun reset() {
lock.withLock { resetLocked() }
}
/** Освободить нативные ресурсы и сторожевой поток (при выключении приложения). */
fun close() {
lock.withLock {
deadlineMs = 0
runCatching { vad.release() }
}
executor.shutdownNow()
watchdog.interrupt()
}
private fun resetLocked() {
// Выбросить остаток VAD-буфера прошлой сессии, чтобы он не склеился
// с началом следующей (cancel отбрасывает текст, flush не признаётся).
runCatching { vad.flush() }
while (!vad.empty()) vad.pop()
session++
full = ""
pending = FloatArray(0)
deadlineMs = 0
}
/** Подать окно ровно по 512 сэмплов в VAD и забрать готовые сегменты. */
private fun feedWindow(window: FloatArray) {
vad.acceptWaveform(window)
while (!vad.empty()) {
val seg = vad.front()
// Копия: массив из нативного VAD может переиспользоваться после pop.
recognizeSegment(seg.samples.copyOf())
vad.pop()
}
}
/** Распознать сегмент в executor-е: обновить full и позвать onPhrase. */
private fun recognizeSegment(samples: FloatArray) {
val sessionId = session
lastFuture = executor.submit {
if (sessionId != session) return@submit
val phrase = runCatching { stt.recognize(samples) }.getOrDefault("")
if (phrase.isBlank()) return@submit
val newFull = if (full.isEmpty()) phrase else "$full $phrase"
full = newFull
deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
onPhrase(phrase, newFull)
}
}
private fun vadConfig(): VadModelConfig = VadModelConfig(
sileroVadModelConfig = SileroVadModelConfig(
model = vadModelPath,
threshold = 0.5f,
minSilenceDuration = 0.25f,
minSpeechDuration = 0.25f,
windowSize = WINDOW_SIZE,
maxSpeechDuration = 20f,
),
sampleRate = 16000,
numThreads = 1,
provider = "cpu",
)
}
@@ -0,0 +1,51 @@
package pw.binom.viewmate.phone.stt
import com.k2fsa.sherpa.onnx.SileroVadModelConfig
import com.k2fsa.sherpa.onnx.Vad
import com.k2fsa.sherpa.onnx.VadModelConfig
/**
* Режет 16 кГц PCM на фразы (Silero VAD).
* Возвращает сегменты [startSample, endSample).
*/
object VadSegmenter {
fun vadSegments(samples: FloatArray, vadModelPath: String): List<IntRange> {
val vadConfig = VadModelConfig(
sileroVadModelConfig = SileroVadModelConfig(
model = vadModelPath,
threshold = 0.5f,
minSilenceDuration = 0.25f,
minSpeechDuration = 0.25f,
windowSize = 512,
maxSpeechDuration = 20f,
),
sampleRate = 16000,
numThreads = 1,
provider = "cpu",
)
val vad = Vad(null, vadConfig)
try {
// VAD требует подачу окнами ровно по window_size (подача целиком
// ломает сегментацию — буфер переполняется).
val windowSize = vadConfig.sileroVadModelConfig.windowSize
var off = 0
while (off < samples.size) {
val end = minOf(off + windowSize, samples.size)
vad.acceptWaveform(samples.copyOfRange(off, end))
off = end
}
vad.flush()
val segments = ArrayList<IntRange>()
while (!vad.empty()) {
val seg = vad.front()
segments += seg.start until (seg.start + seg.samples.size)
vad.pop()
}
return segments
} finally {
vad.release()
}
}
}
@@ -0,0 +1,88 @@
package pw.binom.viewmate.phone.stt
import java.io.InputStream
/**
* Читает WAV (16 кГц, mono, s16) в FloatArray в диапазоне [-1, 1].
* Свой маленький ридер без зависимостей.
*/
object WavReader {
/** Прочитать WAV из потока. */
fun read(stream: InputStream): Wav {
val riff = stream.readBytes(4)
require(riff.contentEquals("RIFF".toByteArray())) { "не WAV-файл (нет RIFF)" }
stream.skip(4) // chunk size
val wave = stream.readBytes(4)
require(wave.contentEquals("WAVE".toByteArray())) { "не WAV-файл (нет WAVE)" }
var sampleRate = 0
var channels = 0
var dataBytes: ByteArray = ByteArray(0)
while (true) {
val id = stream.readBytes(4)
if (id.isEmpty()) break
val size = stream.readLittleInt()
when (String(id, Charsets.US_ASCII)) {
"fmt " -> {
val fmt = stream.readBytes(size)
val audioFormat = littleShort(fmt, 0)
require(audioFormat == 1) { "поддерживается только PCM s16 (audioFormat=$audioFormat)" }
channels = littleShort(fmt, 2)
sampleRate = littleInt(fmt, 4)
if (size > 16) stream.skip((size - 16).toLong())
}
"data" -> {
dataBytes = stream.readBytes(size)
break
}
else -> stream.skip(size.toLong())
}
}
require(sampleRate == 16000) { "ожидается 16 кГц, а тут $sampleRate" }
require(channels == 1) { "ожидается mono, а тут $channels" }
val count = dataBytes.size / 2
val samples = FloatArray(count)
for (i in 0 until count) {
val s = littleShort(dataBytes, i * 2)
samples[i] = (s / 32768f).coerceIn(-1f, 1f)
}
return Wav(sampleRate, channels, samples)
}
private fun InputStream.readBytes(n: Int): ByteArray {
val buf = ByteArray(n)
var off = 0
while (off < n) {
val r = read(buf, off, n - off)
if (r < 0) break
off += r
}
return if (off == n) buf else buf.copyOf(off)
}
private fun InputStream.readLittleInt(): Int {
val b = readBytes(4)
return littleInt(b, 0)
}
private fun littleShort(b: ByteArray, off: Int): Int {
val u = (b[off].toInt() and 0xff) or ((b[off + 1].toInt() and 0xff) shl 8)
return if (u >= 0x8000) u - 0x10000 else u // знаковый: -32768..32767
}
private fun littleInt(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xff) or
((b[off + 1].toInt() and 0xff) shl 8) or
((b[off + 2].toInt() and 0xff) shl 16) or
((b[off + 3].toInt() and 0xff) shl 24)
}
data class Wav(
val sampleRate: Int,
val channels: Int,
val samples: FloatArray,
)
@@ -0,0 +1,46 @@
package pw.binom.viewmate.phone.stt
import com.k2fsa.sherpa.onnx.OfflineModelConfig
import com.k2fsa.sherpa.onnx.OfflineRecognizer
import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig
import com.k2fsa.sherpa.onnx.OfflineWhisperModelConfig
/** Офлайн-распознавание: sherpa-onnx + Whisper-small (int8), CPU. */
class WhisperStt(
private val encoderPath: String,
private val decoderPath: String,
private val tokensPath: String,
private val numThreads: Int = 4,
private val language: String = "ru",
) {
private val recognizer: OfflineRecognizer = OfflineRecognizer(
null,
OfflineRecognizerConfig(
modelConfig = OfflineModelConfig(
whisper = OfflineWhisperModelConfig(
encoder = encoderPath,
decoder = decoderPath,
language = language,
task = "transcribe",
tailPaddings = -1,
),
tokens = tokensPath,
numThreads = numThreads,
provider = "cpu",
modelType = "whisper",
),
),
)
/** Распознать 16 кГц mono PCM. Возвращает текст. */
fun recognize(samples: FloatArray): String {
val stream = recognizer.createStream()
try {
stream.acceptWaveform(samples, 16000)
recognizer.decode(stream)
return recognizer.getResult(stream).text.trim()
} finally {
stream.release()
}
}
}
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 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.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -26,6 +27,7 @@ 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.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -77,6 +79,13 @@ class CatalogState(
private set private set
var path by mutableStateOf<Path>(Path.Root) var path by mutableStateOf<Path>(Path.Root)
private set private set
var scrollIndex by mutableStateOf(0)
private set
var scrollOffset by mutableStateOf(0)
private set
/** Сохранённые позиции скролла по пути (ключ — путь, из которого ушли). */
private val savedScroll = mutableMapOf<CatalogState.Path, Pair<Int, Int>>()
private var searchJob: Job? = null private var searchJob: Job? = null
private var loadJob: Job? = null private var loadJob: Job? = null
@@ -102,16 +111,19 @@ class CatalogState(
} }
fun openSeries(item: JellyfinItem) { fun openSeries(item: JellyfinItem) {
savedScroll[path] = scrollIndex to scrollOffset
path = Path.Series(item) path = Path.Series(item)
refresh() refresh()
} }
fun openSeason(season: JellyfinItem, series: JellyfinItem) { fun openSeason(season: JellyfinItem, series: JellyfinItem) {
savedScroll[path] = scrollIndex to scrollOffset
path = Path.Season(season, series) path = Path.Season(season, series)
refresh() refresh()
} }
fun openFolder(item: JellyfinItem) { fun openFolder(item: JellyfinItem) {
savedScroll[path] = scrollIndex to scrollOffset
path = Path.Folder(item) path = Path.Folder(item)
refresh() refresh()
} }
@@ -123,9 +135,27 @@ class CatalogState(
is Path.Season -> Path.Series(p.series) is Path.Season -> Path.Series(p.series)
is Path.Folder -> Path.Root is Path.Folder -> Path.Root
} }
val (idx, off) = savedScroll[path] ?: Pair(0, 0)
scrollIndex = idx
scrollOffset = off
refresh() refresh()
} }
/** Сохранить позицию для текущего пути (перед уходом на детали и т.п.). */
fun saveScrollNow() {
savedScroll[path] = scrollIndex to scrollOffset
}
/** Сохранить текущую позицию (вызывается при скролле из UI). */
fun setScroll(index: Int, offset: Int) {
scrollIndex = index
scrollOffset = offset
}
/** Позиция скролла для текущего пути (восстановление после навигации). */
fun savedScrollForCurrentPath(): Pair<Int, Int> =
savedScroll[path] ?: Pair(0, 0)
/** Заголовок под шапкой: текущая вкладка или хлебная крошка внутри сериала/папки. */ /** Заголовок под шапкой: текущая вкладка или хлебная крошка внутри сериала/папки. */
fun subtitle(): String = when (val p = path) { fun subtitle(): String = when (val p = path) {
Path.Root -> when (tab) { Path.Root -> when (tab) {
@@ -200,6 +230,21 @@ fun CatalogScreen(
) { ) {
val app = PhoneApp.instance val app = PhoneApp.instance
val userId by app.userId.collectAsState() val userId by app.userId.collectAsState()
// Позиция скролла каталога: сохраняется/восстанавливается между навигациями.
val gridState = rememberLazyGridState()
// Восстановить позицию при смене пути/загрузке.
LaunchedEffect(state.path, state.items) {
val (idx, off) = state.savedScrollForCurrentPath()
gridState.scrollToItem(idx, off)
}
// Сохранять позицию при скролле.
LaunchedEffect(gridState) {
snapshotFlow {
gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset
}.collect { (idx, off) -> state.setScroll(idx, off) }
}
val downloaded by app.state.downloadedFlow.collectAsState() val downloaded by app.state.downloadedFlow.collectAsState()
val localDownloaded by app.downloadManager.downloadedIds.collectAsState() val localDownloaded by app.downloadManager.downloadedIds.collectAsState()
@@ -218,6 +263,7 @@ fun CatalogScreen(
ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто") ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто")
} else { } else {
LazyVerticalGrid( LazyVerticalGrid(
state = gridState,
columns = GridCells.Fixed(2), columns = GridCells.Fixed(2),
contentPadding = PaddingValues(14.dp), contentPadding = PaddingValues(14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -246,7 +292,11 @@ fun CatalogScreen(
state.openSeason(item, series) state.openSeason(item, series)
} }
item.Type in CatalogState.CONTAINER_TYPES -> state.openFolder(item) item.Type in CatalogState.CONTAINER_TYPES -> state.openFolder(item)
else -> onItemClick(item) else -> {
// уходим на детали — сохранить позицию, чтобы назад вернул скролл
state.saveScrollNow()
onItemClick(item)
}
} }
}, },
) )
@@ -213,6 +213,15 @@ fun GlassesScreen(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
RemoteButton(
text = "⏻ Выключить очки",
big = true,
onClick = {
scope.launch { app.actions?.shutdownGlasses() }
},
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
)
RemoteButton( RemoteButton(
text = "Скачанное на очках →", text = "Скачанное на очках →",
big = true, big = true,
@@ -0,0 +1,54 @@
package pw.binom.viewmate.phone.agent
import kotlinx.serialization.encodeToString
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
class ModelsTest {
@Test
fun chatRoleWireRoundTrip() {
ChatRole.entries.forEach { role ->
assertEquals(role, ChatRole.fromWire(role.wire))
}
}
@Test
fun chatRoleFromWireUnknownThrows() {
assertFailsWith<IllegalArgumentException> { ChatRole.fromWire("nope") }
}
@Test
fun toolCallJsonRoundTrip() {
val call = ToolCall("c1", "play_movie", "{\"item\":\"m1\",\"audio\":0}")
val decoded = agentJson.decodeFromString<ToolCall>(agentJson.encodeToString(call))
assertEquals(call, decoded)
}
@Test
fun attachmentJsonRoundTrip() {
val attachment = Attachment(type = "image", uri = "file:///sdcard/a.jpg", mime = "image/jpeg")
val decoded = agentJson.decodeFromString<Attachment>(agentJson.encodeToString(attachment))
assertEquals(attachment, decoded)
}
@Test
fun attachmentDefaultsTypeToImage() {
val decoded = agentJson.decodeFromString<Attachment>("""{"uri":"file:///x.png"}""")
assertEquals("image", decoded.type)
assertEquals("file:///x.png", decoded.uri)
}
@Test
fun toolCallListToJsonStringAndBack() {
val calls = listOf(
ToolCall("c1", "play_movie", "{}"),
ToolCall("c2", "search", "{\"q\":\"matrix\"}"),
)
val json = agentJson.encodeToString(calls)
assertNotNull(json)
assertEquals(calls, agentJson.decodeFromString<List<ToolCall>>(json))
}
}
@@ -0,0 +1,31 @@
package pw.binom.viewmate.phone.stt
import kotlin.test.Test
import kotlin.test.assertEquals
class S16ToFloatTest {
@Test
fun convertsSignedSamples() {
val pcm = byteArrayOf(0x00, 0x00, 0xff.toByte(), 0x7f, 0x00, 0x80.toByte())
val out = s16leToFloat(pcm)
assertEquals(3, out.size)
assertEquals(0f, out[0], 1e-6f) // 0
assertEquals(32767f / 32768f, out[1], 1e-6f) // 0x7fff
assertEquals(-32768f / 32768f, out[2], 1e-6f) // 0x8000
}
@Test
fun minMaxSamples() {
val pcm = byteArrayOf(0xff.toByte(), 0x7f, 0x00, 0x80.toByte())
val out = s16leToFloat(pcm)
assertEquals(2, out.size)
assertEquals(32767f / 32768f, out[0], 1e-6f)
assertEquals(-1f, out[1], 1e-6f)
}
@Test
fun emptyInput() {
assertEquals(0, s16leToFloat(ByteArray(0)).size)
}
}
+8 -1
View File
@@ -1,5 +1,5 @@
[versions] [versions]
kotlin = "2.2.0" kotlin = "2.4.10"
kotlinx-serialization = "1.8.1" kotlinx-serialization = "1.8.1"
kotlinx-coroutines = "1.10.2" kotlinx-coroutines = "1.10.2"
ktor = "3.3.0" ktor = "3.3.0"
@@ -9,6 +9,7 @@ androidx-compose-bom = "2025.05.00"
androidx-activity-compose = "1.10.1" androidx-activity-compose = "1.10.1"
exoplayer = "2.17.1" exoplayer = "2.17.1"
media3 = "1.6.1" media3 = "1.6.1"
koog = "1.1.1"
[libraries] [libraries]
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
@@ -22,6 +23,12 @@ androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref =
exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" } exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" }
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
# Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
# Модели/типы (OpenAIModels, LLModel) входят в prompt-executor-openai-client;
# отдельный артефакт prompt-executor-openai-model существует только до 0.4.1.
koog-openai-client = { module = "ai.koog:prompt-executor-openai-client", version.ref = "koog" }
# KoogHttpClient.Factory провайдер на OkHttp (OkHttp уже есть в app-phone)
koog-http-client-okhttp = { module = "ai.koog:http-client-okhttp", version.ref = "koog" }
# Ktor client (HTTP) # Ktor client (HTTP)
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
@@ -18,6 +18,7 @@ import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.SetMode import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.SetScale import pw.binom.viewmate.core.protocol.SetScale
import pw.binom.viewmate.core.protocol.ShowText import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.Shutdown
/** /**
* Действия «телефона»: каталог Jellyfin, зеркала media-mirror, очки. * Действия «телефона»: каталог Jellyfin, зеркала media-mirror, очки.
@@ -267,5 +268,11 @@ class PhoneActions(
return "масштаб → $clamped" return "масштаб → $clamped"
} }
/** Закрыть приложение на очках (телефон остаётся открытым). */
suspend fun shutdownGlasses(): String {
sender.send(Shutdown())
return "выключение очков"
}
private fun ticksToMinutes(ticks: Long): Long = ticks / 10_000_000 / 60 private fun ticksToMinutes(ticks: Long): Long = ticks / 10_000_000 / 60
} }
@@ -80,3 +80,23 @@ data class ChatSessionMsg(val sessionId: String, val title: String) : HostToGlas
@Serializable @Serializable
@SerialName("set_scale") @SerialName("set_scale")
data class SetScale(val scale: Float) : HostToGlasses data class SetScale(val scale: Float) : HostToGlasses
/** Фраза распознана (streaming): phrase — фраза, full — весь накопленный текст сессии. */
@Serializable
@SerialName("stt_phrase")
data class SttPhrase(val phrase: String, val full: String) : HostToGlasses
/** Распознавание завершено (клик): полный текст сессии. */
@Serializable
@SerialName("stt_done")
data class SttDone(val full: String) : HostToGlasses
/** Распознавание отменено (двойной клик или 30 с тишины) — текст не выводится. */
@Serializable
@SerialName("stt_cancel")
class SttCancel : HostToGlasses
/** Полное выключение приложения на очках (по команде с телефона). */
@Serializable
@SerialName("shutdown")
class Shutdown : HostToGlasses
@@ -81,3 +81,24 @@ data class GlassesOff(val reason: String = "display_off") : GlassesToHost
@Serializable @Serializable
@SerialName("hello") @SerialName("hello")
data class Hello(val appVersion: String = "0.1.0") : GlassesToHost data class Hello(val appVersion: String = "0.1.0") : GlassesToHost
/**
* Кусок аудио для распознавания (очки → телефон): s16le, 16 кГц, mono,
* ~100-200 мс. Текстовый JSON-фрейм: ByteArray кодируется base64.
*/
@Serializable
@SerialName("stt_audio")
data class SttAudio(val data: ByteArray) : GlassesToHost {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SttAudio) return false
return data.contentEquals(other.data)
}
override fun hashCode(): Int = data.contentHashCode()
}
/** Запрос завершения распознавания: cancel=true — отмена (текст не выводить). */
@Serializable
@SerialName("stop_stt")
data class StopStt(val cancel: Boolean) : GlassesToHost
@@ -181,4 +181,33 @@ class GlassesToHostTest {
val obj = json.parseToJsonElement(text) as JsonObject val obj = json.parseToJsonElement(text) as JsonObject
assertEquals("gesture", obj["type"]!!.jsonPrimitive.content) assertEquals("gesture", obj["type"]!!.jsonPrimitive.content)
} }
@Test
fun sttAudioRoundTrip() {
val pcm = byteArrayOf(0x01, 0x00, -1, 0x7f, 0x34, 0x12)
val msg: GlassesToHost = SttAudio(pcm)
val text = json.encodeToString(GlassesToHost.serializer(), msg)
assertTrue(text.contains("\"type\":\"stt_audio\""))
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
assertIs<SttAudio>(decoded)
assertTrue(decoded.data.contentEquals(pcm))
}
@Test
fun stopSttRoundTrip() {
val msg: GlassesToHost = StopStt(cancel = false)
val text = json.encodeToString(GlassesToHost.serializer(), msg)
assertEquals("""{"type":"stop_stt","cancel":false}""", text)
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
assertIs<StopStt>(decoded)
assertEquals(false, decoded.cancel)
}
@Test
fun stopSttCancelRoundTrip() {
val msg: GlassesToHost = StopStt(cancel = true)
val decoded = json.decodeFromString(GlassesToHost.serializer(), json.encodeToString(GlassesToHost.serializer(), msg))
assertIs<StopStt>(decoded)
assertTrue(decoded.cancel)
}
} }
@@ -192,4 +192,34 @@ class HostToGlassesTest {
val obj = json.parseToJsonElement(text) as JsonObject val obj = json.parseToJsonElement(text) as JsonObject
assertEquals("set_mode", obj["type"]!!.jsonPrimitive.content) assertEquals("set_mode", obj["type"]!!.jsonPrimitive.content)
} }
@Test
fun sttPhraseRoundTrip() {
val msg: HostToGlasses = SttPhrase(phrase = "раз два", full = "раз два три")
val text = json.encodeToString(HostToGlasses.serializer(), msg)
assertEquals("""{"type":"stt_phrase","phrase":"раз два","full":"раз два три"}""", text)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<SttPhrase>(decoded)
assertEquals("раз два", decoded.phrase)
assertEquals("раз два три", decoded.full)
}
@Test
fun sttDoneRoundTrip() {
val msg: HostToGlasses = SttDone(full = "весь текст")
val text = json.encodeToString(HostToGlasses.serializer(), msg)
assertEquals("""{"type":"stt_done","full":"весь текст"}""", text)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<SttDone>(decoded)
assertEquals("весь текст", decoded.full)
}
@Test
fun sttCancelRoundTrip() {
val msg: HostToGlasses = SttCancel()
val text = json.encodeToString(HostToGlasses.serializer(), msg)
assertEquals("""{"type":"stt_cancel"}""", text)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<SttCancel>(decoded)
}
} }
+15
View File
@@ -0,0 +1,15 @@
# voice-samples — образцы голоса для тестов распознавания
Тестовые аудио для on-device STT (очки/телефон). Не удалять — образцы
пользователя, присылаются повторно только по необходимости.
| Файл | Что там | Длительность |
|---|---|---|
| `skorogovorka-greka.mp3` | «Ехал грека через реку…» (скороговорка) | ~6 с |
| `raz-dva-tri.mp3` | «раз два три четыре пять» | ~3 с |
| `masha-anton.wav` | Голоса Маши и Антона (с 142, 16 кГц mono) | 60 с |
Конвертация в 16 кГц mono (как для STT):
```
ffmpeg -y -i in.mp3 -ar 16000 -ac 1 out.wav
```
Binary file not shown.
Binary file not shown.
Binary file not shown.