Compare commits

12 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
Hermes Agent 97aaabeb65 fix(app-glasses): не застревать на fallback 10.0.2.2 — перепоиск телефона
Если при старте очков телефон недоступен, discoverPhoneUrl возвращал
fallback (эмуляторный 10.0.2.2), он запоминался как lastUrl — очки вечно
реконнектили туда. Теперь на устройстве fallback не используется: не
нашли — повторный поиск через 3с (connectLoop рекурсивно). На эмуляторе
fallback остался. Железо: очки сами нашли телефон после его включения.
2026-08-20 01:41:43 +03:00
Hermes Agent 2844d6a3ac feat: разделение контента — на телефон только аудио, на очки только видео
Телефон качает audio-N.ogg (audioKeys вместо fileKeys, стадия «звук»),
очки получают DownloadFiles только с video.mkv (buildGlassesFileSpecs).
Watch проверяет готовность обоих: телефон — audioReady(), очки — как
было; отказ с подсказкой «Скачать на телефон/очки». Тесты: lib-core 19,
phone 36.
2026-08-20 01:41:43 +03:00
50 changed files with 2333 additions and 66 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`)
### Вне распознавания (обычный режим)
| Жест (SDK) | Режим КИНО | Режим ЧАТ |
|------------|-----------|-----------|
| `Click` | пауза ↔ плей | старт / стоп диктовки |
| `DoubleClick` | игнор | отмена диктовки; вне диктовки — вызов локального ассистента |
| `DoubleClick` | игнор | отмена диктовки |
| `TripleClick` | **безусловный вызов ассистента** (пауза видео + затемнение + слушаем) | **безусловный вызов ассистента** (слушаем) |
| `SlideBackward` / `SlideForward` | перемотка | в `SESSION_SELECT` — листание сессий; в `TALKING` — скролл текста ответа |
| `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"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
versionName = (project.findProperty("versionName") as? String) ?: "0.1.0"
}
buildTypes {
@@ -28,8 +28,10 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
buildFeatures {
+1
View File
@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<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.NEARBY_WIFI_DEVICES"
@@ -36,6 +36,18 @@ class GlassesApp : Application() {
/** Масштаб экрана очков (0.5..1.0). Загружается в onCreate (prefs — после привязки контекста). */
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) {
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.SetMode
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.SttCancel
import pw.binom.viewmate.core.protocol.SttDone
import pw.binom.viewmate.core.protocol.SttPhrase
import pw.binom.viewmate.core.protocol.Welcome
/**
@@ -94,13 +98,21 @@ class HostConnection(
/**
* Первое подключение: mDNS-дискавери телефона (до 15с на устройстве, 3с на
* эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б),
* иначе fallback (эмуляторный 10.0.2.2). Внутри [GlassesWsClient.connect]
* живёт бесконечный реконнект с тем же адресом (переиспользование последнего
* найденного); при его отсутствии — повторный mDNS-цикл.
* эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
* Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при
* пустом адресе — повторный поиск через 3с (lastUrl не запоминается).
* Внутри [GlassesWsClient.connect] живёт бесконечный реконнект с тем же
* адресом (переиспользование последнего найденного); при его отсутствии —
* повторный mDNS-цикл.
*/
private suspend fun connectLoop() {
val url = lastUrl ?: discoverPhoneUrl()
if (url.isEmpty()) {
log("app", "телефон не найден — повтор поиска через 3с")
delay(3_000)
connectLoop()
return
}
lastUrl = url
log("app", "подключаюсь к $url")
@@ -130,9 +142,10 @@ class HostConnection(
}
/**
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б) → fallback.
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет, а fallback
* на 10.0.2.2 должен наступать быстро).
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б).
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу
* наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoop]).
*/
private suspend fun discoverPhoneUrl(): String {
val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator()))
@@ -150,8 +163,12 @@ class HostConnection(
log("app", "нет локального IPv4 — scan /24 пропущен")
}
}
log("app", "mDNS и scan не нашли телефон — fallback на $fallbackUrl")
return fallbackUrl
if (isEmulator()) {
log("app", "эмулятор — fallback на $fallbackUrl")
return fallbackUrl
}
log("app", "mDNS и scan не нашли телефон — повтор поиска через 3с")
return ""
}
/** Отправка сообщения на телефон (используется менеджером скачивания). */
@@ -251,9 +268,35 @@ class HostConnection(
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 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.IntentFilter
import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -17,9 +18,11 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@@ -33,6 +36,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -40,19 +44,49 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.rayneo.arsdk.android.touch.TempleAction
import com.rayneo.arsdk.android.ui.activity.BaseEventActivity
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.templeActionToCommand
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.BinocularScreenSide
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
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.scaleOffsetX
class MainActivity : BaseEventActivity() {
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() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
@@ -81,6 +115,7 @@ class MainActivity : BaseEventActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GlassesApp.instance.mainActivity = this
collectTempleActions()
disableTrackpadSounds()
WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -111,6 +146,22 @@ class MainActivity : BaseEventActivity() {
}
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
dm.registerDisplayListener(object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) {}
@@ -137,25 +188,80 @@ class MainActivity : BaseEventActivity() {
}, null)
}
/** Жесты трекпада → команды просмотра (Click=пауза/плей, свайпы=±30с). */
/**
* Жесты трекпада → команды. Click/Slide — управление кино; TripleClick —
* голосовой ввод (безусловный вызов), Click во время прослушивания —
* завершить распознавание, DoubleClick — отмена.
*/
private fun collectTempleActions() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
templeActionViewModel.state.collect { action ->
val cmd = templeActionToCommand(action)
if (cmd != null) {
log("gesture", "жест: ${actionName(action)} → ${cmd.command}")
GlassesApp.instance.movieController.applyMediaCommand(cmd) {
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
when (action) {
is TempleAction.TripleClick -> handleTripleClick()
is TempleAction.Click -> handleClick()
is TempleAction.DoubleClick -> handleDoubleClick()
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 {
log("gesture", "жест (резерв): ${actionName(action)}")
else -> 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:
* mercury_system_single_tap/fling/double_click/long_press) — это и есть
@@ -209,8 +315,10 @@ class MainActivity : BaseEventActivity() {
}
override fun onDestroy() {
GlassesApp.instance.mainActivity = null
super.onDestroy()
unregisterReceiver(screenReceiver)
unregisterReceiver(debugReceiver)
}
/**
@@ -251,12 +359,27 @@ fun ConnectionScreen() {
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()
// Бинокулярно: контент дублируется в оба глаза, панель — по центру каждой
// половины (явная ширина половины, иначе fillMaxSize растянет на 1280).
Binocular(scale = false) {
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(
modifier = Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 56.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -274,7 +397,43 @@ fun ConnectionScreen() {
StatusPanelCompose(
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})")
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 {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -5,6 +7,28 @@ plugins {
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 {
namespace = "pw.binom.viewmate.phone"
compileSdk = 35
@@ -13,8 +37,14 @@ android {
applicationId = "pw.binom.viewmate.phone"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
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 {
@@ -28,12 +58,22 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
buildFeatures {
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(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)
implementation("io.ktor:ktor-server-core: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-serialization-kotlinx-json:3.3.0")
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
}
@@ -125,6 +125,10 @@ internal fun fileKeyFor(partName: String): String {
internal fun fileKeys(files: MirrorFiles): List<String> =
listOf("video") + files.audios.map { "audio-${it.index}" }
/** Ключи аудио-дорожек зеркала (audio-N) — телефон качает только звук. */
internal fun audioKeys(files: MirrorFiles): List<String> =
files.audios.map { "audio-${it.index}" }
/** Полностью ли скачано на диск: все финальные файлы [keys] есть, >0 байт, без .part. */
internal fun isFullyOnDisk(dir: File, keys: List<String>): Boolean {
if (keys.isEmpty()) return false
@@ -205,7 +209,7 @@ internal fun scanDownloads(
val biggest = parts.maxByOrNull { it.length() } ?: return@forEach
if (total > 0) ((biggest.length() * 100) / total).toInt().coerceIn(0, 99) else 0
}
val stage = savedStatus?.stage?.takeIf { it.isNotBlank() } ?: "видео"
val stage = savedStatus?.stage?.takeIf { it.isNotBlank() } ?: "звук"
progress[itemId] = DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, stage)
}
}
@@ -221,8 +225,8 @@ internal fun requestFor(url: String, existingBytes: Long): Request {
}
/**
* Реальное скачивание зеркала (видео + все аудио-дорожки) с общим прогрессом.
* Файлы: filesDir/viewmate/{itemId}/video.mkv + audio-{index}.ogg.
* Реальное скачивание зеркала (только аудио-дорожки — звук на телефоне) с общим прогрессом.
* Файлы: filesDir/viewmate/{itemId}/audio-{index}.ogg.
* Скачивание пишется в .part и переживает рестарт: статус хранится в status.json,
* при старте диск сканируется, докачка — только по явному тапу пользователя.
*/
@@ -251,7 +255,7 @@ class DownloadManager(
val downloads: StateFlow<Map<String, DownloadProgress>> = _downloads.asStateFlow()
private val _downloadedIds = MutableStateFlow<Set<String>>(emptySet())
/** itemId, у которых видео уже скачано на телефон (переживает рестарт приложения). */
/** itemId, у которых звук уже скачан на телефон (переживает рестарт приложения). */
val downloadedIds: StateFlow<Set<String>> = _downloadedIds.asStateFlow()
private val _activeIds = MutableStateFlow<Set<String>>(emptySet())
@@ -321,15 +325,25 @@ class DownloadManager(
fun isDownloaded(itemId: String): Boolean = videoFile(itemId).exists()
/**
* Полностью ли скачано зеркало: video.mkv + все аудио из [files] (без .part, >0 байт).
* Полностью ли скачан звук зеркала на телефон: все аудио из [files] (без .part, >0 байт).
* Список дорожек берётся из зеркала (files.audios), а не из того, что есть на диске.
*/
fun isFullyDownloaded(itemId: String, files: MirrorFiles?): Boolean {
val keys = files?.let { fileKeys(it) } ?: return false
val keys = files?.let { audioKeys(it) } ?: return false
if (keys.isEmpty()) return false
return isFullyOnDisk(dirFor(itemId), keys)
}
/**
* Все аудио-дорожки зеркала на диске (можно смотреть: звук будет).
* Список дорожек — из статуса качки (expectedFiles), а не с диска.
*/
fun audioReady(itemId: String): Boolean {
val status = statuses[itemId] ?: return false
if (status.expectedFiles.isEmpty()) return false
return isFullyOnDisk(dirFor(itemId), status.expectedFiles)
}
/** Общий процент скачанного на диск по всем дорожкам (по .part и полным файлам). */
fun overallPercent(itemId: String): Int {
val st = statuses[itemId] ?: return 0
@@ -352,7 +366,7 @@ class DownloadManager(
percentForFile(itemId, part.name.removeSuffix(".part")) ?: 0
/**
* Начать (или продолжить) загрузку зеркала [itemId]: видео + ВСЕ аудио-дорожки.
* Начать (или продолжить) загрузку зеркала [itemId]: ТОЛЬКО аудио-дорожки (звук на телефоне).
* Если уже качается — повторный вызов игнорируется; если всё полностью скачано — тоже.
* При наличии .part докачивает с места обрыва (Range: bytes={размер}-) для каждого файла.
*/
@@ -376,7 +390,7 @@ class DownloadManager(
if (!stale()) update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "зеркало не готово"))
return@launch
}
val keys = fileKeys(files)
val keys = audioKeys(files)
val dir = dirFor(itemId)
if (stale()) return@launch
dir.mkdirs()
@@ -393,9 +407,8 @@ class DownloadManager(
val done = HashMap<String, Long>()
keys.forEach { key ->
if (stale()) return@launch
val url = if (key == "video") files.video.url
else files.audios.firstOrNull { "audio-${it.index}" == key }?.url ?: return@forEach
val stage = if (key == "video") "видео" else "звук"
val url = files.audios.firstOrNull { "audio-${it.index}" == key }?.url ?: return@forEach
val stage = "звук"
downloadFile(
url = S3Presigner.presignGet(url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
part = File(dir, partFileName(key)),
@@ -28,8 +28,13 @@ import pw.binom.viewmate.core.protocol.Gesture
import pw.binom.viewmate.core.protocol.Hello
import pw.binom.viewmate.core.protocol.HostToGlasses
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.protocolJson
import pw.binom.viewmate.phone.stt.SttStreamer
import java.util.concurrent.ConcurrentHashMap
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
/**
* Стриминговое распознавание речи с очков. Лениво создаётся через
* [sttFactory] (модели во filesDir/models); колбэки onPhrase/onSilence30s
* рассылаются через [broadcast].
*/
@Volatile
var stt: SttStreamer? = null
/** Фабрика SttStreamer на первый SttAudio (тяжёлая — создаётся один раз). */
@Volatile
var sttFactory: (() -> SttStreamer?)? = null
fun add(session: DefaultWebSocketServerSession) {
sessions[session] = Unit
_connected.value = sessions.size
@@ -119,7 +136,12 @@ class GlassesHub(
/** Обработать входящее текстовое сообщение от очков. */
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) {
is Hello -> {
log("glasses", "hello (app=${msg.appVersion}) → welcome")
@@ -168,6 +190,24 @@ class GlassesHub(
this.state.playing = false
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) {
log("server", "WS-ошибка: ${e.message}")
e.printStackTrace()
} finally {
hub.remove(this)
}
@@ -1,9 +1,16 @@
package pw.binom.viewmate.phone
import android.graphics.Color
import android.os.Bundle
import android.view.Gravity
import android.widget.FrameLayout
import android.widget.TextView
import androidx.activity.ComponentActivity
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 java.io.File
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -11,5 +18,58 @@ class MainActivity : ComponentActivity() {
setContent {
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
import android.app.Application
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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.phone.PhoneActions
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,
@@ -46,6 +59,9 @@ class PhoneApp : Application() {
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
/** История общения с ассистентом (SQLite, "assistant.db"). */
val chatDao: ChatDao by lazy { ChatDao(ChatDb(this)) }
/** Автоматика паузы/продолжения звука при обрыве связи. */
private val connectionAutoResume = ConnectionAutoResume()
@@ -57,6 +73,92 @@ class PhoneApp : Application() {
/** itemId, у которого автоподхват звука уже не удался — не дёргать повторно каждые 5с. */
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 не активен (телефон «не в курсе»).
@@ -90,6 +192,8 @@ class PhoneApp : Application() {
server.start()
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
nsd.publish()
server.hub.sttFactory = { ensureStt() }
scope.launch { assistantLoop() }
val lastPositionAt = AtomicLong(0)
val lastWasPlaying = AtomicBoolean(false)
@@ -161,6 +265,7 @@ class PhoneApp : Application() {
override fun onTerminate() {
nsd.unpublish()
sttStreamer?.close()
server.stop()
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.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -26,6 +27,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -77,6 +79,13 @@ class CatalogState(
private set
var path by mutableStateOf<Path>(Path.Root)
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 loadJob: Job? = null
@@ -102,16 +111,19 @@ class CatalogState(
}
fun openSeries(item: JellyfinItem) {
savedScroll[path] = scrollIndex to scrollOffset
path = Path.Series(item)
refresh()
}
fun openSeason(season: JellyfinItem, series: JellyfinItem) {
savedScroll[path] = scrollIndex to scrollOffset
path = Path.Season(season, series)
refresh()
}
fun openFolder(item: JellyfinItem) {
savedScroll[path] = scrollIndex to scrollOffset
path = Path.Folder(item)
refresh()
}
@@ -123,9 +135,27 @@ class CatalogState(
is Path.Season -> Path.Series(p.series)
is Path.Folder -> Path.Root
}
val (idx, off) = savedScroll[path] ?: Pair(0, 0)
scrollIndex = idx
scrollOffset = off
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) {
Path.Root -> when (tab) {
@@ -200,6 +230,21 @@ fun CatalogScreen(
) {
val app = PhoneApp.instance
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 localDownloaded by app.downloadManager.downloadedIds.collectAsState()
@@ -218,6 +263,7 @@ fun CatalogScreen(
ScreenPlaceholder(if (state.query.isNotBlank()) "ничего не нашлось" else "пусто")
} else {
LazyVerticalGrid(
state = gridState,
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -246,7 +292,11 @@ fun CatalogScreen(
state.openSeason(item, series)
}
item.Type in CatalogState.CONTAINER_TYPES -> state.openFolder(item)
else -> onItemClick(item)
else -> {
// уходим на детали — сохранить позицию, чтобы назад вернул скролл
state.saveScrollNow()
onItemClick(item)
}
}
},
)
@@ -150,6 +150,12 @@ fun DetailsScreen(
scope.launch { snackbar.showSnackbar("сначала нажми «Скачать на очки»") }
return
}
val audioOk = dm.audioReady(item.Id)
if (!audioOk) {
log("watch", "пропущено: звук не скачан на телефон (audioReady=false)")
scope.launch { snackbar.showSnackbar("сначала нажми «Скачать на телефон»") }
return
}
if (glassesMediaDir.isNullOrBlank()) {
log("watch", "пропущено: очки ещё не сообщили каталог (mediaDir пуст)")
scope.launch { snackbar.showSnackbar("очки ещё не сообщили каталог — подожди") }
@@ -179,7 +185,7 @@ fun DetailsScreen(
log("watch", "видео уже играет на очках — PlayVideo не отправляю, запускаю только звук")
} else {
result = try {
actions!!.watch(item.Id, effectiveAudioIndex, localVideoPath = localPath)
actions!!.watch(item.Id, effectiveAudioIndex, localVideoPath = localPath, phoneAudioReady = audioOk)
} catch (e: Exception) {
log("watch", "watch упал: ${e.message}")
snackbar.showSnackbar("watch упал: ${e.message}")
@@ -213,6 +213,15 @@ fun GlassesScreen(
Spacer(modifier = Modifier.height(12.dp))
RemoteButton(
text = "⏻ Выключить очки",
big = true,
onClick = {
scope.launch { app.actions?.shutdownGlasses() }
},
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
)
RemoteButton(
text = "Скачанное на очках →",
big = true,
@@ -55,7 +55,7 @@ class DownloadManagerTest {
assertTrue(result.downloaded.isEmpty())
assertEquals(
DownloadProgress("i2", DownloadPhase.DOWNLOADING, 50, "видео"),
DownloadProgress("i2", DownloadPhase.DOWNLOADING, 50, "звук"),
result.progress["i2"],
)
}
@@ -68,7 +68,7 @@ class DownloadManagerTest {
val result = scanDownloads(base, emptyMap())
assertEquals(DownloadProgress("i3", DownloadPhase.DOWNLOADING, 0, "видео"), result.progress["i3"])
assertEquals(DownloadProgress("i3", DownloadPhase.DOWNLOADING, 0, "звук"), result.progress["i3"])
}
@Test
@@ -131,6 +131,27 @@ class DownloadManagerTest {
assertEquals(listOf("video", "audio-0", "audio-1"), fileKeys(files))
}
@Test
fun audioKeysFromMirrorCoversOnlyAudios() {
val files = MirrorFiles(
video = MirrorVideo(key = "v", url = "http://v"),
audios = listOf(
MirrorAudio(index = 0, key = "a0", url = "http://a0"),
MirrorAudio(index = 1, key = "a1", url = "http://a1"),
),
)
assertEquals(listOf("audio-0", "audio-1"), audioKeys(files))
}
@Test
fun audioKeysEmptyWithoutAudios() {
val files = MirrorFiles(
video = MirrorVideo(key = "v", url = "http://v"),
audios = emptyList(),
)
assertTrue(audioKeys(files).isEmpty())
}
@Test
fun isFullyOnDiskRequiresAllFilesNoParts() {
val base = newBaseDir()
@@ -38,11 +38,9 @@ class GlassesFilesTest {
assertEquals("movie-1", msg.itemId)
assertEquals("Interstellar", msg.title)
assertEquals(3, msg.files.size)
assertEquals(1, msg.files.size)
assertEquals("video.mkv", msg.files[0].name)
assertEquals("https://s3/mirror/video.mkv?sig", msg.files[0].url)
assertEquals("audio-0.ogg", msg.files[1].name)
assertEquals("audio-1.ogg", msg.files[2].name)
}
@Test
@@ -56,8 +54,6 @@ class GlassesFilesTest {
)
assertEquals(1_863_946_240L, msg.files[0].sizeBytes)
assertEquals(68_157_440L, msg.files[1].sizeBytes)
assertEquals(0L, msg.files[2].sizeBytes)
}
@Test
@@ -67,8 +63,9 @@ class GlassesFilesTest {
sizes = emptyMap(),
presign = { "signed-$it" },
)
assertEquals(3, specs.size)
assertEquals(1, specs.size)
assertTrue(specs.all { it.url.startsWith("signed-") })
assertEquals("video.mkv", specs.single().name)
}
@Test
@@ -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]
kotlin = "2.2.0"
kotlin = "2.4.10"
kotlinx-serialization = "1.8.1"
kotlinx-coroutines = "1.10.2"
ktor = "3.3.0"
@@ -9,6 +9,7 @@ androidx-compose-bom = "2025.05.00"
androidx-activity-compose = "1.10.1"
exoplayer = "2.17.1"
media3 = "1.6.1"
koog = "1.1.1"
[libraries]
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" }
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-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
@@ -11,7 +11,8 @@ fun trackFileName(key: String): String = if (key == "video") "video.mkv" else "$
fun trackKeys(files: MirrorFiles): List<String> =
listOf("video") + files.audios.map { "audio-${it.index}" }
/** Собрать список файлов для DownloadFiles на очки: presigned URL, имя файла, размер (если известен). */
/** Собрать список файлов для DownloadFiles на очки: presigned URL, имя файла, размер (если известен).
* На очки — только video.mkv; звук — на телефоне. */
fun buildGlassesFileSpecs(
files: MirrorFiles,
sizes: Map<String, Long>,
@@ -22,15 +23,7 @@ fun buildGlassesFileSpecs(
name = trackFileName("video"),
sizeBytes = sizes["video"] ?: 0L,
)
val audios = files.audios.map { a ->
val key = "audio-${a.index}"
FileSpec(
url = presign(a.url),
name = trackFileName(key),
sizeBytes = sizes[key] ?: 0L,
)
}
return listOf(video) + audios
return listOf(video)
}
/** Полное сообщение DownloadFiles для очков. */
@@ -18,6 +18,7 @@ import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.SetMode
import pw.binom.viewmate.core.protocol.SetScale
import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.Shutdown
/**
* Действия «телефона»: каталог Jellyfin, зеркала media-mirror, очки.
@@ -89,8 +90,9 @@ class PhoneActions(
/**
* Включить на очках: зеркало должно быть done → PlayVideo с ЛОКАЛЬНЫМ путём на очках.
* Стриминг запрещён: [localVideoPath] — file://... с диска очков (или пустой — не играем).
* Звук — на телефоне: [phoneAudioReady] — скачаны ли аудио-дорожки на телефон.
*/
suspend fun watch(itemId: String, audioIndex: Int = 0, localVideoPath: String = ""): String {
suspend fun watch(itemId: String, audioIndex: Int = 0, localVideoPath: String = "", phoneAudioReady: Boolean = true): String {
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
val status = mirror.mirrorByItem(itemId)
val files = status?.files
@@ -100,6 +102,9 @@ class PhoneActions(
if (!localVideoPath.startsWith("file://")) {
return "«${item.Name ?: itemId}» не скачан на очки — сначала «Скачать на очки»"
}
if (!phoneAudioReady) {
return "«${item.Name ?: itemId}» не скачан на телефон (звук) — сначала «Скачать на телефон»"
}
val title = item.Name ?: itemId
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex))
@@ -263,5 +268,11 @@ class PhoneActions(
return "масштаб → $clamped"
}
/** Закрыть приложение на очках (телефон остаётся открытым). */
suspend fun shutdownGlasses(): String {
sender.send(Shutdown())
return "выключение очков"
}
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
@SerialName("set_scale")
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
@SerialName("hello")
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
@@ -187,6 +187,17 @@ class PhoneActionsTest {
assertTrue(sender.sent.isEmpty())
}
@Test
fun watchWhenPhoneAudioNotReadyIsRejected() = runTest {
val sender = SenderStub()
val out = actions(simpleJellyfin(), doneMirror(), sender)
.watch("m1", localVideoPath = "file:///x/m1/video.mkv", phoneAudioReady = false)
assertTrue(out.contains("не скачан на телефон"))
assertTrue(out.contains("Скачать на телефон"))
assertTrue(sender.sent.isEmpty())
}
@Test
fun watchWhenMirrorNotReadyReturnsHint() = runTest {
val sender = SenderStub()
@@ -295,7 +306,7 @@ class PhoneActionsTest {
assertIs<DownloadFiles>(msg)
assertEquals("m1", msg.itemId)
assertEquals("Интерстеллар", msg.title)
assertEquals(listOf("video.mkv", "audio-1.ogg", "audio-2.ogg"), msg.files.map { it.name })
assertEquals(listOf("video.mkv"), msg.files.map { it.name })
assertEquals("signed-https://s3/media/m1/video.mp4", msg.files[0].url)
assertEquals(100L, msg.files[0].sizeBytes)
assertTrue(msg.files[0].name.endsWith(".mkv"))
@@ -181,4 +181,33 @@ class GlassesToHostTest {
val obj = json.parseToJsonElement(text) as JsonObject
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
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.