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