Compare commits
10 Commits
51049d56a8
...
40edd88c1d
| Author | SHA1 | Date | |
|---|---|---|---|
| 40edd88c1d | |||
| fc84ba16b6 | |||
| c940a7e0eb | |||
| 22eb83fd17 | |||
| c1c169036a | |||
| 7557d7229f | |||
| 504e66f0c6 | |||
| e24caaa684 | |||
| eaca47c443 | |||
| 706cc10d1e |
@@ -0,0 +1,73 @@
|
|||||||
|
# TASK: debug-дампы аудио-пути очки→телефон (PCM-записи для диагностики STT-качества)
|
||||||
|
|
||||||
|
## Подозреваемые (по фактам разведки)
|
||||||
|
(a) убогий микрофон очков (AudioRecord MIC 16 кГц s16 mono, buf 6400, чтение 3200 Б = 100 мс);
|
||||||
|
(b) транспорт: PCM идёт как **base64 в JSON-строке** (`SttAudio.data` → kotlinx-serialization
|
||||||
|
default ByteArray-сериализатор = base64) по NEWLINE-каналу SPP/WiFi (readLines/readLine);
|
||||||
|
100-мс-чанк 3200 Б s16 → ~4300 Б base64+JSON. Потери/задержки/перепутанный порядок кусков
|
||||||
|
по шкатулке — верный кандидат «убожества»;
|
||||||
|
(c) VAD/Whisper на телефоне (настройки: VAD 0.5/0.25с/0.25с/20с, Whisper-small int8, CPU, 4
|
||||||
|
потока — на старом HONOR CPU-распознавание медленное и может «не успевать»).
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
Запись сырого PCM в двух точках (очки: ДО отправки; телефон: ПОСЛЕ приёма и декода) +
|
||||||
|
сравнение: объёмы/RMS/порядок. Дальше (утром, агент+user): снять с устройств, снять
|
||||||
|
офлайн-ASR-картину, решить (a)/(b)/(c).
|
||||||
|
|
||||||
|
## Оковы
|
||||||
|
- Пути только относительные от корня репо /root/WORK/view-mate. Никаких /tmp.
|
||||||
|
- НЕ ТРОГАТЬ: HostToGlasses.kt, GlassesServer.kt, HostConnection.kt, MainActivity.kt (оба модуля).
|
||||||
|
- Правим: SttMicStream.kt (очки), SttStreamer.kt (телефон), PhoneApp.kt (телефон, НЕ охраняется), новый WavWriter.kt (очки).
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
|
||||||
|
### 1. app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/WavWriter.kt (новый)
|
||||||
|
Минимальный WAV-райтер (PCM s16le, 16000 Hz, mono):
|
||||||
|
- `class WavWriter(path: File, sampleRate: Int = 16000, channels: Int = 1)`
|
||||||
|
- `fun write(pcm: ByteArray)` — appends (header пишется один раз при создании файла).
|
||||||
|
- `close()`. Стандартный RIFF/PCM header 44 байта.
|
||||||
|
|
||||||
|
### 2. app-glasses: SttMicStream.kt
|
||||||
|
- В `start(filesDir, shouldRun)`: перед запуском потока проверить маркер
|
||||||
|
`File(filesDir, "stt_dump.marker")`. Если есть:
|
||||||
|
- создать `File(filesDir, "stt_dump_" + java.time формат HHmmss + ".wav")` через WavWriter;
|
||||||
|
- ВСЕ чанки, уходящие в onChunk (только ветка ЖИВОГО микрофона, эмуляторную не трогаем),
|
||||||
|
дописывать в WavWriter;
|
||||||
|
- log("stt", "dump микрофона: <path>").
|
||||||
|
- При `stop()` — закрыть WavWriter (runCatching).
|
||||||
|
- Все операции с dump — runCatching, сбои — только log, поток звука не прерывать.
|
||||||
|
|
||||||
|
### 3. app-phone: SttStreamer.kt
|
||||||
|
- Конструктор: добавить опциональный параметр `dumpPcmTo: File? = null`
|
||||||
|
(хвост конструктора, чтобы не сломать существующие вызовы).
|
||||||
|
- В `accept(pcm)`: если `dumpPcmTo != null` — дописать `pcm` в файл
|
||||||
|
(DataOutputStream/FileOutputStream append, raw s16le, header НЕ нужен — файл помечен как
|
||||||
|
16кГц s16 mono в логе). Все runCatching.
|
||||||
|
- В `close()`: закрыть поток дампа.
|
||||||
|
- Лог: "stt" логгер — "PCM-дампа: <path>".
|
||||||
|
|
||||||
|
### 4. app-phone: PhoneApp.kt (ensureStt(), ~строка 368)
|
||||||
|
- При создании SttStreamer передать `dumpPcmTo`:
|
||||||
|
```kotlin
|
||||||
|
val dumpMarker = File(filesDir, "stt_dump.marker")
|
||||||
|
val dumpPcm = if (dumpMarker.exists()) File(filesDir, "stt_phone_dump.raw") else null
|
||||||
|
```
|
||||||
|
(посмотреть по факту, какой context/filesDir в области видимости).
|
||||||
|
- Лог: "stt" — "PCM-дампа (телефон): <path или off>".
|
||||||
|
|
||||||
|
### 5. Прогон
|
||||||
|
- `./gradlew :app-glasses:testDebugUnitTest :app-phone:testDebugUnitTest :lib-core:jvmTest` — зелёные.
|
||||||
|
- `./gradlew :app-glasses:assembleDebug :app-phone:assembleDebug` — BUILD SUCCESSFUL.
|
||||||
|
|
||||||
|
### 6. Коммит
|
||||||
|
- `git add -A && git commit -m "stt: debug-дампы аудио-пути (очки: сырой микрофон в WAV; телефон: принятые PCM) — старт по stt_dump.marker"`
|
||||||
|
- PUSH не делать.
|
||||||
|
|
||||||
|
## Для утренней диагностики (агент + user)
|
||||||
|
- На очках: `touch` маркера (adb shell run-as или в самом каталоге /sdcard/Android/data/.../files/stt_dump.marker — агент уточнит path).
|
||||||
|
- На телефоне: аналогично.
|
||||||
|
- Пользователь произносит фразы в очки → очки делают dump; после сессии — adb pull обоих файлов.
|
||||||
|
- Сравнение: (1) объём/продолжительность (должны совпадать с точностью на куски),
|
||||||
|
(2) RMS/клиппинг в очках-файле (диагноз (a)), (3) побайтовое/почанковое сравнение
|
||||||
|
очков↔телефон (диагноз (b)), (4) запустить обе записи через серверный ASR (88.125:8006) —
|
||||||
|
«убожество» воспроизводимо в оффлайне?
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# TASK-cancel-listen: отмена слушания двойным тапом
|
||||||
|
|
||||||
|
## Симптом (с железа, 24.08)
|
||||||
|
Пользователь в чате: сказал фразу → ассистент ответил. Далее **один тап** (чтобы снова начать
|
||||||
|
слушать) → очки начали слушать (микрофон открыт), пользователь молчит. Теперь хочет **отменить
|
||||||
|
слушание** — по замыслу это **двойной тап** — но отмена НЕ происходит: либо ничего не меняется,
|
||||||
|
либо микрофон снова открывается, кружок записи гаснет/возвращается непонятно. Состояние
|
||||||
|
«слушаю» залипает.
|
||||||
|
|
||||||
|
## Причина (расследование — opencode)
|
||||||
|
Ключевой дефект: на стороне очков ветка `SttCancel` реагирует на `reason` некорректно.
|
||||||
|
- «Пустой клик» (тап без голоса) и «двойной клик-отмена» на телефоне приходят на очки ОДИНАКОВО
|
||||||
|
(`SttCancel` с `reason="user"`), и очки на любой делают `resumeSttListening()` → микрофон снова
|
||||||
|
открыт, из режима не выходим.
|
||||||
|
- После закрытия микрофона (`sendPhrase()`/`cancelDictation()`, `MainActivity.kt` ~279-321)
|
||||||
|
флаг `listening` **остаётся `true`**, и в `AssistantGestures.kt` (~34-39) `listening` побеждает
|
||||||
|
все остальные ветки → никакого двойного тапа в ANSWER/THINKING не видно, «выход из режима»
|
||||||
|
недоступен.
|
||||||
|
|
||||||
|
## Целевое поведение (договорно)
|
||||||
|
1. **Один тап** (в режиме общения, не слушаю) → начать слушать (микрофон открыт, `listening=true`,
|
||||||
|
красный кружок). Без изменений.
|
||||||
|
2. **Пустой клик без голоса** (тап, но человек ничего не сказал) → телефон шлёт `StopStt(cancel=false)`
|
||||||
|
с пустым текстом → очки **возвращаются в «слушаю»** (как сейчас, `resumeSttListening`) — это
|
||||||
|
желаемое поведение, НЕ трогать.
|
||||||
|
3. **Двойной тап в состоянии «слушаю»** → **отмена диктовки**: микрофон закрыт, `listening=false`,
|
||||||
|
`thinking=false`, остаёмся в режиме общения (показываем последний ответ), НЕ выходим из режима
|
||||||
|
целиком. И НЕ открываем микрофон заново. Это новое поведение — сейчас оно не работает.
|
||||||
|
4. **Таймаут тишины 30с** (`reason="timeout"`) → выход ИЗ режима общения (`exitAssistantMode`),
|
||||||
|
как сейчас. НЕ трогать.
|
||||||
|
5. После любой посылки/отмены микрофон должен быть закрыт, `listening` сбрасывать на `false`.
|
||||||
|
|
||||||
|
## Механика правки (семантика протокола)
|
||||||
|
`SttCancel.reason` — свободная строка, значения:
|
||||||
|
- `"user"` — пустой клик без голоса → очки возвращаются в «слушаю» (resume). (без изменений)
|
||||||
|
- `"timeout"` — тишина 30с → очки ВЫХОДЯТ из режима (exitAssistantMode). (без изменений)
|
||||||
|
- **`"cancel"`** — НОВОЕ: явная отмена диктовки двойным тапом → очки закрывают микрофон,
|
||||||
|
`listening=false`, `thinking=false`, остаются в режиме (показывают последний ответ),
|
||||||
|
НЕ открывают микрофон заново.
|
||||||
|
|
||||||
|
### Точки правки (подтверди точные строки/логику при чтении)
|
||||||
|
1. **lib-core** `.../core/protocol/Messages.kt` — `SttCancel` уже несёт `reason: String = "user"`.
|
||||||
|
Ввести константу-компаньон `SttCancel.REASON_CANCEL = "cancel"` (и REASON_USER/REASON_TIMEOUT
|
||||||
|
для читабельности). Протокол меняется на ОБЕИХ сторонах — после правки переустанавливать И
|
||||||
|
app-glasses, И app-phone (рассинхрон версий вернёт старый баг).
|
||||||
|
2. **app-phone** `.../phone/GlassesServer.kt` (~206) — обработчик `StopStt`:
|
||||||
|
- `StopStt(cancel=true)` → бродкастить `SttCancel(reason=REASON_CANCEL)` (ранее шло "user").
|
||||||
|
- `StopStt(cancel=false)` с пустым `full` → `SttCancel(reason=REASON_USER)` (возврат в «слушаю»).
|
||||||
|
- `StopStt(cancel=false)` с непустым текстом → `SttDone(...)` (без изменений).
|
||||||
|
3. **app-glasses** `.../glasses/.../HostConnection.kt` (~362-377) — ветка `SttCancel`:
|
||||||
|
- `reason == REASON_CANCEL` → закрыть микрофон + `listening=false` + `thinking=false`,
|
||||||
|
НЕ `resumeSttListening()`, НЕ выход из режима — остаться в режиме со следующим ответом.
|
||||||
|
- `reason == REASON_USER` → как сейчас (`thinking=false` + `resumeSttListening()`).
|
||||||
|
- `reason == REASON_TIMEOUT` → `exitAssistantMode()` (как сейчас).
|
||||||
|
4. **app-glasses** `.../glasses/MainActivity.kt` (~279-321) — `sendPhrase()` и `cancelDictation()`:
|
||||||
|
после закрытия микрофона обязательно `listening=false`. Ввести/использовать `cancelDictation()`
|
||||||
|
как точку «отмена слушания» (закрывает микрофон, сбрасывает `listening`).
|
||||||
|
5. **app-glasses** `.../glasses/gestures/AssistantGestures.kt` (~34-39, ~103-107) — проверить, что
|
||||||
|
**двойной тап в ветке LISTENING** маршрутизируется в `cancelDictation()` (которое шлёт
|
||||||
|
`StopStt(cancel=true)` → на телефоне `StopStt(cancel=true)` → `SttCancel(REASON_CANCEL)`).
|
||||||
|
Убедиться, что приоритет `listening` не перебивает двойной тап и не «залипает».
|
||||||
|
|
||||||
|
## Подтвердить при чтении (не догадываться)
|
||||||
|
- Реально ли `GestureDetectorWithSound` при двойном тапе НЕ дёргает промежуточный single-Click
|
||||||
|
(гонка таймингов). Если дёргает — двойной тап превращается в single+single и отмена не
|
||||||
|
срабатывает. При необходимости поправить дискриминацию single/double.
|
||||||
|
- Точные имена полей/функций в `Messages.kt` и номера строк в `HostConnection.kt`/`GlassesServer.kt`
|
||||||
|
— сверить с реальным кодом, не брать на веру из описания выше.
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
- НЕ менять поведение `reason="user"` (пустой клик → возврат в «слушаю») и `reason="timeout"`
|
||||||
|
(выход из режима) — это зафиксированные решения от 23.08.
|
||||||
|
- Рабочая копия: `/root/WORK/view-mate`. Не трогать абсолютные пути вне workspace.
|
||||||
|
- Картинки/red-индикатор (504e66f) и историю диалога (706cc10) не ломать.
|
||||||
|
- Язык кода — только Kotlin.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
- **JVM-юнит** (lib-core + app-glasses gestures): новый `SttCancel(reason="cancel")`:
|
||||||
|
- очки НЕ вызывают `resumeSttListening()`, НЕ делают `exitAssistantMode()`, `listening=false`.
|
||||||
|
- `reason="user"` → `resumeSttListening()` (регресс).
|
||||||
|
- `reason="timeout"` → `exitAssistantMode()` (регресс).
|
||||||
|
- **app-phone test** (`GlassesServerTest`): `StopStt(cancel=true)` → бродкастится `SttCancel("cancel")`;
|
||||||
|
`StopStt(cancel=false)` пустой → `SttCancel("user")`; непустой → `SttDone`.
|
||||||
|
- Гейт: `./gradlew :lib-core:jvmTest :app-glasses:testDebugUnitTest :app-phone:testDebugUnitTest`.
|
||||||
|
- Полная сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug :lib-core:jvmTest`.
|
||||||
|
|
||||||
|
## Примечание по приёмке на железе
|
||||||
|
DEBUG-крюк: `adb -s 7687f645 shell 'am broadcast -a pw.binom.viewmate.glasses.DEBUG_GESTURE --es type double_click'`
|
||||||
|
проверяет, как машина жествов реагирует на двойной тап в состоянии «слушаю». Проверка на очках
|
||||||
|
после установки — за Hermes-ом (не агента).
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# TASK-glasses-chat-fix.md — три кассия по голосовому диалогу (отчёт теста 23.08)
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
Режим общения с ассистентом: очки (app-glasses) → BT SPP → телефон (app-phone) → STT (Whisper+VAD) →
|
||||||
|
LLM → ShowText. Пользовательский тест от 23.08 нашёл три кассия. Корневые причины уже установлены
|
||||||
|
расследованием — ниже точные правки. Не трогать: аппаратное ускорение GPU на телефоне, скорость —
|
||||||
|
проверено, не трогать.
|
||||||
|
|
||||||
|
## Кассий №3 (приоритет): VAD-пауза = самоотправка в LLM
|
||||||
|
**Корень:** `PhoneApp.ensureStt()` (app-phone, ~строка 355): колбэк `onPhrase` SttStreamer делает
|
||||||
|
`assistantChannel.send(phrase)` — каждая VAD-фраза (сегмент речи, отделённый паузой ~0.25–20с)
|
||||||
|
немедленно улетает в `assistantLoop` → LLM. Человек говорит, замолчал на секунду — VAD вырезал
|
||||||
|
сегмент, и запрос уже ушёл в нейронку.
|
||||||
|
|
||||||
|
**Правка:**
|
||||||
|
1. В `PhoneApp.ensureStt` убрать из `onPhrase` отправку в `assistantChannel` (оставить только
|
||||||
|
`broadcast(SttPhrase(...))` — экрану очков показывать распознавание по-прежнему нужно).
|
||||||
|
2. Отправка в `assistantChannel` — только по `StopStt(cancel=false)` из хаба
|
||||||
|
(`GlassesServer.handle`, ветка `is StopStt`): `val full = stt?.finish() ?: ""; stt?.reset()`,
|
||||||
|
затем (если `full.isNotBlank()`) — `assistantChannel.send(full.trim())` и бродкаст.
|
||||||
|
Т.е. единственный путь текста в LLM — повторный клик пользователя.
|
||||||
|
3. В логах сохранить читаемость: `log("stt", ...)` что фраза в LLM отправлена только по клику.
|
||||||
|
|
||||||
|
Проверка логики: VAD-сегменты во время речи НИКОГДА не уходят в LLM; пустая пауза — тоже нет.
|
||||||
|
|
||||||
|
## Кассий №2: пустой клик → висящее «думаю...» 10+ с → выкидывание из режима
|
||||||
|
**Корень:** клик без голоса → `StopStt(cancel=false)` → телефон шлёт `SttDone("")` + LLM-цикл
|
||||||
|
получает пустоту (после правки №3 вообще не получит). Очки в `HostConnection`: `SttDone` →
|
||||||
|
`listening=false`, а `thinking` сбрасывается только на `ShowText`/`SttCancel` — а их нет (пустая
|
||||||
|
фраза в LLM не уходит). Оверлей висит в «думаю...» до автo-отмены тишины 30с (SttCancel) →
|
||||||
|
`listening=false, thinking=false` → оверлей прячется → пользователь видит кино (пауза).
|
||||||
|
«Выкинуло из режима» + долгое «думаю» объяснено.
|
||||||
|
|
||||||
|
**Правка:**
|
||||||
|
1. Телефон: `StopStt(cancel=false)` с пустым `full` — НЕ шлёт `SttDone`, а бродкастит `SttCancel`
|
||||||
|
(«фразы не было») и `stt?.reset()` — очки возвращаются в «слушаю» (см. п.2).
|
||||||
|
2. Очки `HostConnection`: `SttCancel` — сброс `thinking=false`, `listening` = true (вернуться в
|
||||||
|
«слушаю», микрофон открыть заново — старт `sttMic` вызвать из обработчика через
|
||||||
|
`GlassesApp.instance.mainActivity`). Заголовок оверлея автоматически вернётся к «слушаю...»
|
||||||
|
(`sttText` пустой — это OK, он при SttCancel не сбрасывается: оставил бы накопленное, если было).
|
||||||
|
Реализация — в `MainActivity`: новый хук `onSttCancelledBackToListening()` (по аналогии
|
||||||
|
`startAssistantMode` без очистки стtText).
|
||||||
|
3. В `AssistantGestures.overlayHeader` поведение не менять (логика «слушаю...» уже корректна).
|
||||||
|
|
||||||
|
Проверка: клик без голоса → очки мгновенно в «слушаю...», без «думаю» и без выхода из режима.
|
||||||
|
|
||||||
|
## Кассий №1: в свайпе видна только последняя пара сообщений, история диалога не доступна
|
||||||
|
**Корень:** протокол передаёт очки только последний ответ (`ShowText.text`) и текущую
|
||||||
|
распознаваемую фразу (`sttText`). История сессии живёт в SQLite телефона
|
||||||
|
(`ChatDao.messagesForSession`) — очкам её никто не отдаёт. Скроллить в оверлее нечего.
|
||||||
|
|
||||||
|
**Правка:**
|
||||||
|
1. `lib-core/.../protocol/HostToGlasses.kt`: новое сообщение
|
||||||
|
`@SerialName("chat_history") data class ChatHistoryMsg(val sessionId: String?, val messages: List<ChatHistoryItem>)`,
|
||||||
|
где `ChatHistoryItem(val role: String /*"user"|"assistant"|"tool"*/, val content: String)`
|
||||||
|
(только user/assistant; tool-сообщения и toolCalls не нужны очкам — фильтр на телефоне).
|
||||||
|
2. `GlassesServer` (tелефон): на `StopStt(cancel=false)` с непустым full — ПОСЛЕ отправки в
|
||||||
|
LLM-цикл (или на `Welcome` — при подключении очков, если сессия активна) бродкаст
|
||||||
|
`ChatHistoryMsg` с историей текущей/активной сессии (`chatDao.messagesForSession`,
|
||||||
|
`chatDao.latestSessionId()`), роль user/assistant, content (tool-контент не передавать).
|
||||||
|
Точка отправки — после показа ответа, чтобы в истории был и ответ ассистента:
|
||||||
|
простейший вариант — бродкаст истории в `assistantLoop` сразу после `broadcast(ShowText(answer))`.
|
||||||
|
3. Очки: `GlassesApp` — `chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList())`
|
||||||
|
(`ChatEntry(role, text)`); `HostConnection` на `ChatHistoryMsg` — `filter { role == "user" ||
|
||||||
|
role == "assistant" }`, сохранить. Новый `ShowText` не должен стирать историю (это последнее
|
||||||
|
сообщение, оно уже в истории).
|
||||||
|
4. `AssistantOverlay`: вместо пары «sttText + assistantText» — список `chatHistory`:
|
||||||
|
«я: ...» (user, правее/одним цветом) и «ассистент: ...» (вторым цветом), один
|
||||||
|
`verticalScroll` на весь список, автоскролл к концу при новом `ShowText`
|
||||||
|
(`LaunchedEffect(lastHistoryVersion) { scrollState.animateScrollTo(scrollState.maxValue) }`).
|
||||||
|
Режимы «слушаю»/«думаю» заголовок — как сейчас; свайпы — те же `ScrollAnswer` дельты на общий
|
||||||
|
скролл (механизм `answerScrollDelta` сохранить, теперь он скроллит список, не одно сообщение).
|
||||||
|
5. `AssistantGestures` — без изменений (матрица жестов не меняется).
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
- Код только Kotlin. Не трогать: `LocalLlm.kt` (GPU-конфиг, uses-native-library), скорость,
|
||||||
|
клавиатуру телефона, автоименование сессий.
|
||||||
|
- Протокол — backward-совместимый: новые сообщения с `@SerialName`, старые клиенты не падают
|
||||||
|
(ксеролайн — ignore-неизвестные в `protocolJson`?). Проверить флаг `ignoreUnknownKeys`
|
||||||
|
в `ProtocolJson`/`protocolJson` — если не включён, включить (очки новой версии → телефон старой
|
||||||
|
и обратно).
|
||||||
|
- Unit-тесты: жесты не менять, но добавить/поправить, если `AssistantState` меняется.
|
||||||
|
- Сборка: `./gradlew :app-glasses:assembleDebug :app-phone:assembleDebug :lib-core:build` —
|
||||||
|
компиляция и тесты без ошибок.
|
||||||
|
|
||||||
|
## Определяющий тест (прогон на устройстве позже)
|
||||||
|
1. Кликаю → говорю → пауза 2с → продолжаю → клик. LLM получает ВСЮ фразу целиком, один раз.
|
||||||
|
(Логи: одна `ВЕСЬ ТЕКСТ`, один `получена фраза`.)
|
||||||
|
2. Клик без голоса → «слушаю...» без «думаю», остаюсь в режиме.
|
||||||
|
3. Несколько реплик подряд → свайп показывает все предыдущие пары я/ассистент, автоскролл к концу.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# TASK-glasses-scroll-fix.md — фикс скролла диалога на очках + пауза звука телефона
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
После касяков 1–3 (коммит 706cc10) тест на железе выявил два новых дефекта (отчёт 23.08 21:02-21:03):
|
||||||
|
|
||||||
|
**Дефект А (глаза скроллят по-разному + «всё зависло»):**
|
||||||
|
Пользователь: «текст будто бы по-разному скроллится... скролл по-разному работал для левого и правого глаза. мб ошибка связана с тем, что текст рисуется дважды (для каждого глаза)».
|
||||||
|
Корень (установлен расследованием):
|
||||||
|
- `Binocular()` (app-glasses/.../ui/ViewUtils.kt) вызывает content **ДВАЖДЫ**: левый глаз — `PositionedBox(x=0)` с `BinocularScreenSide=LEFT`, правый — `PositionedBox(x=parentWidth)` с `BinocularScreenSide=RIGHT`.
|
||||||
|
- `AssistantOverlayContent` (app-glasses/.../ui/AssistantOverlay.kt) внутри этого контента создаёт:
|
||||||
|
- `rememberScrollState()` — **два независимых экземпляра** (по одному на глаз);
|
||||||
|
- `LaunchedEffect(chatHistory.size, state.assistantText) { animateScrollTo(maxValue) }` — **два экземпляра**, анимируют каждый СВОЙ scrollState;
|
||||||
|
- `LaunchedEffect(Unit) { app.answerScrollDelta.collect { ... } }` — **два коллектора** на один общий `answerScrollDelta`: первый читает дельту и обнуляет аккумулятор, второй в это же время видит 0 или уже обработанную дельту → скролл расходится между глазами, автоскролл и свайпы дерутся → визуальная «каша» и ощущение зависания.
|
||||||
|
|
||||||
|
**Дефект Б (звук на телефоне после паузы на очках):**
|
||||||
|
Пользователь: «я нажал три раза (вызвать ассистента). На очках фильм остановился, а на телефоне ещё какое-то время звук шёл».
|
||||||
|
Корень: `startAssistantMode()` (app-glasses/.../MainActivity.kt, ~строка 251) вызывает `movieController.pauseIfPlaying()` (пауза ExoPlayer очков), но **не вызывает `hostConnection.sendPlaybackPositionNow()`**. Телефон ставит аудио-плеер на паузу ТОЛЬКО по `PlaybackPosition` (`PhoneApp.onPlaybackPosition → audioSync.sync`), а статус шлётся раз в ~5 секунд. В `onPause`/`SCREEN_OFF`/`display OFF` очки такой вызов делают — в `startAssistantMode` забыли.
|
||||||
|
|
||||||
|
## Правки (только app-glasses; всё остальное НЕ трогать)
|
||||||
|
|
||||||
|
### 1. AssistantOverlay.kt — один scrollState и один коллектор на оба глаза
|
||||||
|
Сейчас: `AssistantOverlay()` → `overlayBox(app, state, chatHistory)` → `Binocular { ... AssistantOverlayContent(state, chatHistory) }`, а `AssistantOverlayContent` сама создаёт scrollState и LaunchedEffect'ы — поэтому они дублируются на каждый глаз.
|
||||||
|
|
||||||
|
Требование: **поднять состояние и эффекты на уровень ВЫШЕ Binocular** (в `AssistantOverlay()`), чтобы они существовали в единственном экземпляре:
|
||||||
|
1. В `AssistantOverlay()` после проверки `overlayVisible`:
|
||||||
|
- `val scrollState = rememberScrollState()` — ОДИН экземпляр на оба глаза;
|
||||||
|
- ОДИН `LaunchedEffect(chatHistory.size, assistantText)` — автоскролл к концу: `if (hasHistory || assistantText.isNotBlank()) scrollState.animateScrollTo(scrollState.maxValue)`;
|
||||||
|
- ОДИН `LaunchedEffect(Unit)` — коллектор `app.answerScrollDelta`: логика как сейчас (`scrollOffset`, обнуление аккумулятора, `animateScrollTo(target, tween(150))`).
|
||||||
|
2. `AssistantOverlayContent` сделать **чистой отрисовкой**: параметры `(state, chatHistory, scrollState)`, ВНУТРИ НЕ создавать ни rememberScrollState, ни LaunchedEffect'ов, ни коллекторов. Только Column + header + список `chatHistory` (как сейчас: «я: ...» белым / «ассистент: ...» `0xFFB0BEC5`, verticalScroll(scrollState)) + fallback на `assistantText` при пустой истории.
|
||||||
|
3. `overlayBox` — передавать `scrollState` в `AssistantOverlayContent` (сигнатуры поправить).
|
||||||
|
|
||||||
|
Проверка логики: в `Binocular` контент вызывается дважды, но scrollState/LaunchedEffect теперь живут вне него — оба глаза рисуют одно и то же состояние, гонки нет.
|
||||||
|
|
||||||
|
### 2. MainActivity.kt — мгновенная отправка позиции при вызове ассистента
|
||||||
|
В `startAssistantMode()` после `app.movieController.pauseIfPlaying()` добавить:
|
||||||
|
```kotlin
|
||||||
|
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||||
|
```
|
||||||
|
(по образцу `onPause`/`SCREEN_OFF`: пауза + немедленный статус, чтобы телефон тут же поставил звук на паузу).
|
||||||
|
|
||||||
|
### 3. Сборка, тесты, коммит
|
||||||
|
- `./gradlew :lib-core:build :app-glasses:assembleDebug :app-phone:assembleDebug` — BUILD SUCCESSFUL, тесты проходят.
|
||||||
|
- `git add -A && git commit -m 'Очки: единый скролл диалога на оба глаза + мгновенная пауза звука телефона при вызове ассистента'` (БЕЗ push).
|
||||||
|
|
||||||
|
## КРИТИЧЕСКИЕ ПРАВИЛА
|
||||||
|
- workspace = /root/WORK/view-mate. Пути ТОЛЬКО относительные от корня (app-glasses/...). НИКОГДА /root/WORK/app-glasses (без view-mate) — external_directory, permission-guard отклонит.
|
||||||
|
- НЕ трогать: app-phone, lib-core, LocalLlm.kt (GPU), скорость, клавиатуру, автоименование, жесты (AssistantGestures.kt), матрицу режимов.
|
||||||
|
- Коммит одного файла оверлея + одного метода MainActivity.
|
||||||
|
- В конце — краткий отчёт: файлы, что изменено, результат сборки/тестов.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# TASK-red-dot-recording.md — индикатор записи микрофона на очках
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
Пользователь просит: когда на очках идёт запись микрофона (режим «слушаю», `listening=true`),
|
||||||
|
показывать в углу экрана красный кружок (индикатор записи, как у камер/диктофонов).
|
||||||
|
Только очки (app-glasses), только UI. Через опен-код, как всегда.
|
||||||
|
|
||||||
|
## Где это живёт
|
||||||
|
- `app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/AssistantOverlay.kt`
|
||||||
|
- `AssistantOverlay()` собирает `state` (listening/thinking/sttText/assistantText), затем
|
||||||
|
`overlayBox(app, state, chatHistory, scrollState)`.
|
||||||
|
- `overlayBox()` внутри `Binocular(scale=false)` рисует `Box` с чёрным затемнением
|
||||||
|
`Color.Black.copy(alpha = 0.72f)` и в нём `AssistantOverlayContent(...)`.
|
||||||
|
- Binocular рендерит контент дважды (левый/правый глаз) — кружок появится в каждом глазу,
|
||||||
|
это правильно (в каждой половине экрана свой индикатор).
|
||||||
|
|
||||||
|
## Правка
|
||||||
|
В `overlayBox()`: внутри внешнего `Box` (тот, что с `.background(Color.Black.copy(alpha = 0.72f))`)
|
||||||
|
добавить поверх контента, когда `state.listening == true`, маленький красный круг в
|
||||||
|
верхнем правом углу (по центру своей половины экрана, т.е. с отступом от края):
|
||||||
|
```kotlin
|
||||||
|
if (state.listening) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(top = 12.dp, end = 12.dp)
|
||||||
|
.size(16.dp)
|
||||||
|
.background(Color(0xFFFF3B30), CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Нужные импорты добавить: `androidx.compose.foundation.shape.CircleShape`,
|
||||||
|
`androidx.compose.foundation.layout.size`, `Modifier.align` (уже есть `Alignment`).
|
||||||
|
Проверить, что `Box`-контейнер позволяет `align` (должен, т.к. это BoxScope).
|
||||||
|
|
||||||
|
Кружок виден только в режиме «слушаю» (listening=true): при «думаю...» и при показе ответа —
|
||||||
|
не показывается (микрофон уже закрыт). Это соответствует смыслу: индикатор = идёт запись.
|
||||||
|
|
||||||
|
Никаких изменений протокола, телефона, жестов, логики — только отрисовка индикатора.
|
||||||
|
|
||||||
|
## Сборка, тесты, коммит
|
||||||
|
- `./gradlew :lib-core:build :app-glasses:assembleDebug :app-phone:assembleDebug` — BUILD SUCCESSFUL.
|
||||||
|
- `git add -A && git commit -m 'Очки: красный индикатор записи микрофона в режиме «слушаю»'` (БЕЗ push).
|
||||||
|
|
||||||
|
## КРИТИЧЕСКИЕ ПРАВИЛА
|
||||||
|
- workspace = /root/WORK/view-mate. Пути ТОЛЬКО относительные от корня (app-glasses/...).
|
||||||
|
НИКОГДА /root/WORK/app-* (без view-mate) — external_directory, permission-guard отклонит.
|
||||||
|
- НЕ трогать: app-phone, lib-core, LocalLlm.kt (GPU), скорость, клавиатуру, автоименование,
|
||||||
|
AssistantGestures.kt, матрицу жестов, HostConnection.kt, MainActivity.kt, GlassesApp.kt.
|
||||||
|
- В конце — краткий отчёт: файлы, изменения, сборка, тесты.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# TASK-stt-cancel-reason.md — очки не должны слушать в фоне (цикл SttCancel)
|
||||||
|
|
||||||
|
## Контекст (отчёт пользователя 23.08 ~21:10)
|
||||||
|
«Смотрю видео на фоне, очки рядом лежат — походу он всё это время слушает». По логам очков:
|
||||||
|
бесконечный цикл каждые ~31с: `стt отменено — возврат в «слушаю»` → микрофон запущен →
|
||||||
|
30с тишины → телефон шлёт SttCancel → очки снова открывают микрофон. При этом очки в лаунчере
|
||||||
|
(Activity не в фокусе), процесс жив. Очки даже распознают фоновый звук ТВ. Система начала
|
||||||
|
блокировать запись (AppOps RECORD_AUDIO Operation not started).
|
||||||
|
|
||||||
|
**Корень:** `SttCancel` приходит от ДВУХ разных источников, а очки обрабатывают их одинаково:
|
||||||
|
1. **Пустой клик пользователя** (`GlassesServer`, StopStt(cancel=false) с пустым full) — по касяку №2
|
||||||
|
очки должны вернуться в «слушаю» (resumeSttListening).
|
||||||
|
2. **Watchdog тишины 30с** (`PhoneApp.ensureStt` onSilence30s) — пользователь молчит/ушёл, нужно
|
||||||
|
ВЫЙТИ из режима общения (микрофон закрыть, оверлей скрыть), как было в старой логике SttCancel
|
||||||
|
(listening=false, thinking=false).
|
||||||
|
Сейчас оба шлют голый `SttCancel()`, и очки на любой из них вызывают resumeSttListening → цикл.
|
||||||
|
|
||||||
|
## Правки
|
||||||
|
|
||||||
|
### 1. lib-core — протокол: SttCancel получает reason
|
||||||
|
`lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt`:
|
||||||
|
```kotlin
|
||||||
|
@SerialName("stt_cancel")
|
||||||
|
class SttCancel(val reason: String = "user") : HostToGlasses
|
||||||
|
```
|
||||||
|
- `reason="user"` — отмена/пустой клик пользователя: очки возвращаются в «слушаю»;
|
||||||
|
- `reason="timeout"` — авто-отмена по тишине 30с: очки ВЫХОДЯТ из режима (микрофон закрыть,
|
||||||
|
оверлей скрыть, как exitAssistantMode).
|
||||||
|
Backward-compatible: старое сообщение без поля десериализуется как reason="user".
|
||||||
|
Поправить тест round-trip SttCancel в `lib-core/src/commonTest/.../HostToGlassesTest.kt`.
|
||||||
|
|
||||||
|
### 2. Телефон — источники
|
||||||
|
- `app-phone/.../PhoneApp.kt` onSilence30s (~строка 376): `broadcast(SttCancel())` → `broadcast(SttCancel(reason = "timeout"))`.
|
||||||
|
- `app-phone/.../GlassesServer.kt` StopStt(cancel=false) с пустым full: `broadcast(SttCancel())` → `broadcast(SttCancel(reason = "user"))` (явно; можно и дефолт, но явно читаемее).
|
||||||
|
- StopStt(cancel=true) (двойной клик, отмена распознавания): тоже `SttCancel(reason = "user")` —
|
||||||
|
пользователь остаётся в разговоре, очки возвращаются в «слушаю».
|
||||||
|
|
||||||
|
### 3. Очки — обработка по reason
|
||||||
|
`app-glasses/.../HostConnection.kt` ветка `is SttCancel`:
|
||||||
|
- `reason == "timeout"` → выход из режима: `GlassesApp.instance.listening.value = false`,
|
||||||
|
`thinking.value = false`, и остановить микрофон. Для этого в `MainActivity` сделать
|
||||||
|
**публичный** метод (например `fun onSttTimeout()`), который вызывает логику exitAssistantMode()
|
||||||
|
(стоп микрофона + сброс состояний) — или сделать exitAssistantMode() публичной и вызывать
|
||||||
|
`GlassesApp.instance.mainActivity?.exitAssistantMode()`. Выбрать чистое решение по коду.
|
||||||
|
- иначе (user) → как сейчас: `GlassesApp.instance.thinking.value = false` +
|
||||||
|
`mainActivity?.resumeSttListening()`.
|
||||||
|
|
||||||
|
### 4. Очки — защита «лежат и слушают» (onPause)
|
||||||
|
`app-glasses/.../MainActivity.kt` onPause(): сейчас там только пауза кино + sendPlaybackPositionNow.
|
||||||
|
Добавить: если режим общения активен (listening || thinking || assistantText.isNotBlank()) —
|
||||||
|
выход из режима общения (та же логика exitAssistantMode: sttMic.stop(), сброс состояний).
|
||||||
|
Т.е. при потере фокуса Activity (пользователь ушёл в лаунчер) микрофон гарантированно
|
||||||
|
закрывается, цикл невозможен.
|
||||||
|
|
||||||
|
### 5. Сборка, тесты, коммит
|
||||||
|
- `./gradlew :lib-core:build :app-glasses:assembleDebug :app-phone:assembleDebug` — BUILD SUCCESSFUL.
|
||||||
|
- `git add -A && git commit -m 'Очки: разграничение SttCancel (user/timeout) — выход из режима при тишине 30с, микрофон закрывается при потере фокуса'` (БЕЗ push).
|
||||||
|
|
||||||
|
## КРИТИЧЕСКИЕ ПРАВИЛА
|
||||||
|
- workspace = /root/WORK/view-mate. Пути ТОЛЬКО относительные от корня (app-glasses/...,
|
||||||
|
app-phone/..., lib-core/...). НИКОГДА /root/WORK/app-* (без view-mate) — external_directory,
|
||||||
|
permission-guard отклонит.
|
||||||
|
- НЕ трогать: LocalLlm.kt (GPU), скорость, клавиатуру, автоименование, AssistantGestures.kt,
|
||||||
|
матрицу жестов, AssistantOverlay.kt (скролл уже починен).
|
||||||
|
- В конце — краткий отчёт: файлы, изменения, сборка, тесты.
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# TASK: VAD-интерфейс (стриминг) + две реализации + PhraseRecognizer + «чистый» full-text-тест
|
||||||
|
|
||||||
|
## Контекст (факты, проверены)
|
||||||
|
- `app-phone/.../phone/stt/SttStreamer.kt` — стриминг: `accept(pcm: ByteArray)` (s16le 16 кГц mono)
|
||||||
|
→ `s16leToFloat` → окна по 512 → sherpa `vad.acceptWaveform(front()/pop()/flush())` →
|
||||||
|
`stt.recognize(samples)` (WhisperStt, нативка) → `full` → `finish()` возвращает весь текст.
|
||||||
|
- `WhisperStt` — класс с методом `recognize(samples: FloatArray): String` (sherpa OfflineRecognizer).
|
||||||
|
- Батч-`VadSegmenter` (оффлайн, SttDebug) — Android-only, ЭТОТ МОДУЛЬ НЕ ТРОГАЕМ (п. 7).
|
||||||
|
- Новая библиотека (mavenLocal, уже опубликована!): `pw.binom.ai.voice:vad-jvm:1.0.0-SNAPSHOT`:
|
||||||
|
`pw.binom.ai.voice.vad.VadInstance(16000)` : `java.lang.AutoCloseable`,
|
||||||
|
`windowSizeSamples` (512 при 16 кГц), `processingWindow(FloatArray|FloatBuffer): Float`
|
||||||
|
(вероятность речи на окно 512 сэмплов, истинный Silero на ONNX 1.16.0; реальный onnx-файл
|
||||||
|
`silero_vad.onnx` ВНУТРИ jar; нативка linux-x64 в зависимости onnxruntime — поднимается на JVM).
|
||||||
|
Зависимости jar: kotlin-stdlib + onnxruntime + JTransforms (pw.binom УБРАН — проверено по .module).
|
||||||
|
- `settings.gradle.kts` view-mate уже содержит `mavenLocal()` в dependencyResolutionManagement ✓.
|
||||||
|
- Фикстур: `voice-samples/masha-anton.wav` — 16 кГц, mono, s16le, 60.2 с (в корне репо).
|
||||||
|
|
||||||
|
## Правки
|
||||||
|
|
||||||
|
### 1. Новый интерфейс (MAIN): `app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/StreamingVad.kt`
|
||||||
|
```kotlin
|
||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
/** Сегмент речи: [startSample, startSample+samples.size) в сэмплах 16 кГц. */
|
||||||
|
class VadSegment(val startSample: Int, val samples: FloatArray)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Стриминговый VAD: подача окнами по windowSizeSamples → готовые сегменты.
|
||||||
|
* Реализации: [SherpaStreamingVad] (боевая, Android), SileroStreamingVad (тесты, JVM).
|
||||||
|
*/
|
||||||
|
interface StreamingVad {
|
||||||
|
val windowSizeSamples: Int
|
||||||
|
/** Подать окно; готовые сегменты (если появились) забираются popSegment(). */
|
||||||
|
fun acceptWindow(window: FloatArray)
|
||||||
|
/** Последний готовый сегмент (копию caller должен делать сам, если нужно удерживать). */
|
||||||
|
fun popSegment(): VadSegment?
|
||||||
|
/** Закрыть текущий незавершённый сегмент (если есть). */
|
||||||
|
fun flush()
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Новая боевая реализация (MAIN): `.../phone/stt/SherpaStreamingVad.kt`
|
||||||
|
Обёртка над `com.k2fsa.sherpa.onnx.Vad` (именно текущий код из `SttStreamer.vadConfig()`):
|
||||||
|
SileroVadModelConfig(model=modelPath, threshold=0.5f, minSilenceDuration=0.25f,
|
||||||
|
minSpeechDuration=0.25f, windowSize=512, maxSpeechDuration=20f), sampleRate=16000,
|
||||||
|
numThreads=1, provider="cpu".
|
||||||
|
- `acceptWindow(w)` = `vad.acceptWaveform(w)`.
|
||||||
|
- `popSegment()`: `if (vad.empty()) null else { val s = vad.front(); val out = VadSegment(s.start, s.samples); vad.pop(); out }`
|
||||||
|
- `flush()` = `vad.flush()` (после — сегменты через popSegment).
|
||||||
|
- `close()` = `runCatching { vad.release() }`.
|
||||||
|
- Класс закрывает AutoCloseable, НЕ наследуя ничего (close — обычная сигнатура).
|
||||||
|
|
||||||
|
### 3. Конструктор `SttStreamer` (MAIN, правим SttStreamer.kt)
|
||||||
|
- Было: `SttStreamer(stt: WhisperStt, vadModelPath: String, onPhrase, onSilence30s)` +
|
||||||
|
поле `private val vad: Vad = Vad(null, vadConfig())`.
|
||||||
|
- Стало: `SttStreamer(stt: PhraseRecognizer, vad: StreamingVad, onPhrase: (String,String)->Unit, onSilence30s: () -> Unit)`.
|
||||||
|
Убрать `vadConfig()` и импорт sherpa `Vad/VadModelConfig/SileroVadModelConfig` из SttStreamer
|
||||||
|
(теперь они в SherpaStreamingVad).
|
||||||
|
- `feedWindow(window)` → `vad.acceptWindow(window); var seg = vad.popSegment(); while (seg != null) { recognizeSegment(seg.samples.copyOf()); seg = vad.popSegment() }`
|
||||||
|
- `finish()` → `vad.flush(); var seg = vad.popSegment(); while (seg != null) { recognizeSegment(seg.samples.copyOf()); seg = vad.popSegment() }; (ожидание lastFuture как сейчас)`
|
||||||
|
- `resetLocked()` → `vad.flush(); var seg = vad.popSegment(); while (seg != null) { seg = vad.popSegment() }; (остальное как сейчас)`
|
||||||
|
- `close()` → `vad.close(); executor.shutdownNow(); watchdog.interrupt()` (как сейчас, только vad.close() вместо vad.release()).
|
||||||
|
- Всё остальное (lock, pending, session, deadline, s16leToFloat) — БЕЗ ИЗМЕНЕНИЙ.
|
||||||
|
|
||||||
|
### 4. Новая реализация PhraseRecognizer (MAIN, новый файл `.../phone/stt/PhraseRecognizer.kt`)
|
||||||
|
```kotlin
|
||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
/** Распознавание одной фразы (16 кГц mono PCM) в текст. */
|
||||||
|
interface PhraseRecognizer {
|
||||||
|
fun recognize(samples: FloatArray): String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`WhisperStt` (MAIN) — добавить реализацию: `class WhisperStt(...) : PhraseRecognizer` (метод уже
|
||||||
|
совпадает по сигнатуре; только добавить интерфейс + import).
|
||||||
|
|
||||||
|
### 5. PhoneApp.kt — `ensureStt()` (MAIN, правка минимальная)
|
||||||
|
Было:
|
||||||
|
```kotlin
|
||||||
|
val s = SttStreamer(stt = whisper, vadModelPath = vadModel.absolutePath, onPhrase=..., onSilence30s=...)
|
||||||
|
```
|
||||||
|
Стало:
|
||||||
|
```kotlin
|
||||||
|
val s = SttStreamer(stt = whisper, vad = SherpaStreamingVad(vadModel.absolutePath), onPhrase=..., onSilence30s=...)
|
||||||
|
```
|
||||||
|
(импорт `pw.binom.viewmate.phone.stt.SherpaStreamingVad`; остальное в PhoneApp не трогать).
|
||||||
|
|
||||||
|
### 6. Тестовые исходники (app-phone/src/test/.../phone/stt/)
|
||||||
|
- `SileroStreamingVad.kt` — реализация `StreamingVad` над `pw.binom.ai.voice.vad.VadInstance(16000)`.
|
||||||
|
Точный стриминговый постпроцесс (порядок окон, 512 сэмплов каждое; параметры те же, что
|
||||||
|
у sherpa, для поведенческого паритета: threshold 0.5, minSilence 0.25 с = 4000 сэмплов,
|
||||||
|
minSpeech 0.25 с = 4000 сэмплов, maxSpeech 20 с = 320000 сэмплов):
|
||||||
|
|
||||||
|
Состояние: `inSpeech: Boolean = false`, `segStart: Int = 0`, `lastSpeechEnd: Int = 0`,
|
||||||
|
`total: Int = 0` (обработано сэмплов), `buf = ArrayList<Float>()` (все сэмплы от начала,
|
||||||
|
для среза сегментов; 60 с ≈ 3.9 МБ — допустимо), `queue = ArrayDeque<VadSegment>()`.
|
||||||
|
|
||||||
|
`acceptWindow(w: FloatArray)`: `require(w.size == 512)`; `val p = vad.processingWindow(w)`;
|
||||||
|
`val s = total; val sEnd = s + 512; buf.addAll(w); total = sEnd`.
|
||||||
|
- if `p >= 0.5f`:
|
||||||
|
- if !inSpeech { inSpeech = true; segStart = s }
|
||||||
|
- lastSpeechEnd = sEnd
|
||||||
|
- if (lastSpeechEnd - segStart) >= 320000 { emit(); inSpeech = false }
|
||||||
|
- else:
|
||||||
|
- if inSpeech && (sEnd - lastSpeechEnd) >= 4000 {
|
||||||
|
if (lastSpeechEnd - segStart) >= 4000 { emit() }; inSpeech = false }
|
||||||
|
где `emit()`: `val seg = VadSegment(segStart, FloatArray(lastSpeechEnd - segStart){ buf[segStart + it] }); queue.addLast(seg)`.
|
||||||
|
|
||||||
|
`popSegment()`: `if (queue.isEmpty()) null else queue.removeFirst()`.
|
||||||
|
`flush()`: if (inSpeech && (lastSpeechEnd - segStart) >= 4000) emit().
|
||||||
|
`close()`: `runCatching { vad.close() }` (VadInstance — AutoCloseable).
|
||||||
|
Конструктор: `SileroStreamingVad()`, в init `private val vad = VadInstance(16000)`
|
||||||
|
(поднимает ONNX-нативку linux-x64 из jar — в JVM-юните это работает).
|
||||||
|
|
||||||
|
- `FakePhraseRecognizer.kt` — `class FakePhraseRecognizer(private val phrases: List<String> = listOf("привет"))` :
|
||||||
|
`PhraseRecognizer { override fun recognize(s: FloatArray): String = phrases[0] }` (тесту достаточно
|
||||||
|
одной фразы "привет"; при желании счётчик по index, но не требуется).
|
||||||
|
- `WavPcm.kt` (тест) — минимальный загрузчик: `fun loadPcm(path: String): ByteArray` — читает
|
||||||
|
RIFF WAV s16le mono 16000 (header + raw PCM → ByteArray), если sampleRate/channels не 16000/mono —
|
||||||
|
fail(). (Можно переиспользовать `WavReader` из main — он принимает InputStream и отдаёт
|
||||||
|
FloatArray; нам нужен именно ByteArray s16le — проще написать свой 20-строчный по факту,
|
||||||
|
т.к. WavReader отдаёт FloatArray и конверсию float→s16le обратно — глупость. Пишем свой.)
|
||||||
|
|
||||||
|
### 7. `VadSegmenter.kt` (MAIN) — НЕ ТРОГАТЬ. SttDebug — не трогаем (Android-only).
|
||||||
|
|
||||||
|
### 8. Зависимость в `app-phone/build.gradle.kts`
|
||||||
|
```kotlin
|
||||||
|
testImplementation("pw.binom.ai.voice:vad-jvm:1.0.0-SNAPSHOT")
|
||||||
|
```
|
||||||
|
(в блоке testImplementation рядом с kotlin("test")). mavenLocal() уже в settings ✓.
|
||||||
|
|
||||||
|
### 9. Восстановление full-text-теста в `app-phone/src/test/.../phone/GlassesServerTest.kt`
|
||||||
|
Заменить коммент (стр. ~197-200) на тест:
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun stopSttFullTextSendsSttDone() = runServer { port, hub ->
|
||||||
|
hub.sttFactory = {
|
||||||
|
SttStreamer(
|
||||||
|
stt = FakePhraseRecognizer(),
|
||||||
|
vad = SileroStreamingVad(),
|
||||||
|
onPhrase = { _, _ -> },
|
||||||
|
onSilence30s = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val pcm = WavPcm.loadPcm("../voice-samples/masha-anton.wav")
|
||||||
|
// кидать по 3200 байт (100 мс @ 16к s16 mono)
|
||||||
|
var off = 0
|
||||||
|
while (off < pcm.size) {
|
||||||
|
val end = minOf(off + 3200, pcm.size)
|
||||||
|
hub.handle("c1", protocolJson.encodeToString(GlassesToHost.serializer(), SttAudio(pcm.copyOfRange(off, end))))
|
||||||
|
off = end
|
||||||
|
}
|
||||||
|
hub.handle("c1", protocolJson.encodeToString(GlassesToHost.serializer(), StopStt(cancel = false)))
|
||||||
|
// ждём SttDone с непустым текстом (все «привет» от фейка по числу сегментов VAD)
|
||||||
|
val got = awaitTrue { hub.sttDoneText?.isNotBlank() ?: false } // по факту: смотреть, как
|
||||||
|
assert that SttDone получен и текст = "привет" повторён N раз (N — число сегментов, ≥1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
ВАЖНО: посмотреть по факту, как тесты этого файла ловят broadcast (есть ли у hub очередь
|
||||||
|
SttDone или шлём на ws-клиент `wsClient()` — тесты здесь через Ktor ws-клиент). Использовать
|
||||||
|
существующий паттерн `wsClient()` + `awaitTrue` — как в stopSttCancelSendsSttCancelReasonCancel (стр. 173).
|
||||||
|
Текст: `SttCancel`/`SttDone` ловятся на `hub.broadcast` → ws-клиент. Имя SttDone смотреть в
|
||||||
|
`HostToGlasses.kt` (lib-core) — это data-класс `SttDone(val text: String)` (по факту сверить).
|
||||||
|
|
||||||
|
### 10. Второй тест (ЧИСТЫЙ, без фейков — реальный VAD): `SileroVadRealAudioTest.kt` (test)
|
||||||
|
- `fun segmentsOnRealAudio()`: `SileroStreamingVad()`, кидаем весь PCM (790 656 сэмплов = 60 с)
|
||||||
|
окнами по 512 → popSegment-цикл → assert:
|
||||||
|
- segments ≥ 1,
|
||||||
|
- каждый сегмент: start >= 0, start+len <= total,
|
||||||
|
- суммарная речь в пределах [5000, 500000] сэмплов (0.3с — 31с),
|
||||||
|
- после flush() popSegment() == null.
|
||||||
|
- Это и есть «чистый тест распознавания» (VAD реальный, ONNX реальный, без pw.binom в classpath).
|
||||||
|
|
||||||
|
### 11. Прогон (ВСЁ, без исключений)
|
||||||
|
- `./gradlew :lib-core:jvmTest` — зелёные (81).
|
||||||
|
- `./gradlew :app-phone:testDebugUnitTest` — зелёные (156 + 2 новых = ~158, включая восстановленный
|
||||||
|
full-text и SileroVadRealAudio).
|
||||||
|
- `./gradlew :app-glasses:testDebugUnitTest` — 91, не сломать.
|
||||||
|
- `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug` — BUILD SUCCESSFUL.
|
||||||
|
- Если `../voice-samples/masha-anton.wav` не резолвится из workdir теста (gradle test working dir)
|
||||||
|
— проверить `./gradlew :app-phone:testDebugUnitTest --tests "*SileroVadRealAudio*" -i` → рабочий
|
||||||
|
каталог; если не тот — читать через `Path.of("../voice-samples/masha-anton.wav").toAbsolutePath()`.
|
||||||
|
|
||||||
|
### 12. Коммит
|
||||||
|
- `git add -A && git commit -m "stt: VAD-интерфейс (Sherpa боевая / Silero тестовая), PhraseRecognizer (Whisper), чистый full-text-тест на реальном Silero VAD"`
|
||||||
|
- НЕ push.
|
||||||
|
|
||||||
|
## Оковы
|
||||||
|
- Пути ТОЛЬКО относительные от корня /root/WORK/view-mate. Никаких /tmp.
|
||||||
|
- ЗАПРЕЩЕНО трогать: `lib-core/.../HostToGlasses.kt`, `app-phone/.../GlassesServer.kt`,
|
||||||
|
`app-glasses/.../HostConnection.kt`, `app-glasses/.../MainActivity.kt` (GlassesServer не правится
|
||||||
|
— стtFactory уже там; все правки вокруг него).
|
||||||
|
- SttStreamer.kt разрешён (сейчас правится). PhoneApp.kt — только ensureStt() (2 строки).
|
||||||
|
- В MAIN source set — НУЛЬ упоминаний `pw.binom.voice.vad` (Silero — только в test).
|
||||||
|
- `VadSegmenter.kt` (батч) не трогаем вообще.
|
||||||
@@ -6,6 +6,9 @@ import com.rayneo.arsdk.android.MercurySDK
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
|
||||||
|
data class ChatEntry(val role: String, val text: String)
|
||||||
|
|
||||||
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
|
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
|
||||||
object GlassesConfig {
|
object GlassesConfig {
|
||||||
// Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон
|
// Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон
|
||||||
@@ -58,6 +61,13 @@ class GlassesApp : Application() {
|
|||||||
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
|
/** Текст ответа ассистента (обновляется на ShowText); остаётся до новой сессии. */
|
||||||
val assistantText = MutableStateFlow("")
|
val assistantText = MutableStateFlow("")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* История диалога сессии (роль wire: "user"/"assistant") для оверлея со скроллом
|
||||||
|
* (TASK-glasses-chat-fix.md п.1): приходит целиком в ChatHistoryMsg с телефона,
|
||||||
|
* новый ответ (ShowText) — последнее сообщение истории, не стирает её.
|
||||||
|
*/
|
||||||
|
val chatHistory = MutableStateFlow<List<ChatEntry>>(emptyList())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText).
|
* Фаза «думаю»: фраза отправлена (Click в «слушаю») — ждём ответ (ShowText).
|
||||||
* Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим:
|
* Устанавливается glasses-сторона, сбрасывается на ShowText/SttCancel/выход/вход в режим:
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import pw.binom.viewmate.core.MediaCommand
|
|||||||
import pw.binom.viewmate.core.net.GlassesTransport
|
import pw.binom.viewmate.core.net.GlassesTransport
|
||||||
import pw.binom.viewmate.core.net.WifiGlassesTransport
|
import pw.binom.viewmate.core.net.WifiGlassesTransport
|
||||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||||
|
import pw.binom.viewmate.core.protocol.ChatHistoryItem
|
||||||
|
import pw.binom.viewmate.core.protocol.ChatHistoryMsg
|
||||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
||||||
import pw.binom.viewmate.core.protocol.DeleteFiles
|
import pw.binom.viewmate.core.protocol.DeleteFiles
|
||||||
import pw.binom.viewmate.core.protocol.DownloadFiles
|
import pw.binom.viewmate.core.protocol.DownloadFiles
|
||||||
@@ -340,6 +342,13 @@ class HostConnection(
|
|||||||
|
|
||||||
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
|
is ChatSessionMsg -> addMessage("ChatSession: ${msg.sessionId} — ${msg.title}")
|
||||||
|
|
||||||
|
is ChatHistoryMsg -> {
|
||||||
|
log("chat", "история сессии ${msg.sessionId ?: "-"}: ${msg.messages.size} сообщ.")
|
||||||
|
GlassesApp.instance.chatHistory.value = msg.messages
|
||||||
|
.filter { it.role == "user" || it.role == "assistant" }
|
||||||
|
.map { ChatEntry(it.role, it.content) }
|
||||||
|
}
|
||||||
|
|
||||||
is SttPhrase -> {
|
is SttPhrase -> {
|
||||||
GlassesApp.instance.sttText.value = msg.full
|
GlassesApp.instance.sttText.value = msg.full
|
||||||
log("stt", "фраза: ${msg.phrase}")
|
log("stt", "фраза: ${msg.phrase}")
|
||||||
@@ -351,10 +360,32 @@ class HostConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is SttCancel -> {
|
is SttCancel -> {
|
||||||
log("stt", "отменено")
|
when (msg.reason) {
|
||||||
|
SttCancel.REASON_CANCEL -> {
|
||||||
|
// Явная отмена диктовки двойным тапом: закрыть микрофон, сбросить
|
||||||
|
// listening/thinking, НО остаться в режиме (показать последний ответ),
|
||||||
|
// микрофон заново НЕ открывать, из режима НЕ выходить.
|
||||||
|
log("stt", "отмена диктовки (двойной тап) — микрофон закрыт, остаёмся в режиме")
|
||||||
GlassesApp.instance.listening.value = false
|
GlassesApp.instance.listening.value = false
|
||||||
GlassesApp.instance.thinking.value = false
|
GlassesApp.instance.thinking.value = false
|
||||||
}
|
}
|
||||||
|
SttCancel.REASON_TIMEOUT -> {
|
||||||
|
// Авто-отмена по тишине 30с: пользователь молчит/ушёл — ВЫХОДИМ из
|
||||||
|
// режима (микрофон закрыть, оверлей скрыть), как exitAssistantMode.
|
||||||
|
log("stt", "тишина 30с — выход из режима общения")
|
||||||
|
GlassesApp.instance.mainActivity?.onSttTimeout()
|
||||||
|
?: log("stt", "mainActivity ещё нет — не могу выйти из режима")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// reason="user" — пустой клик без голоса: не выходим из режима,
|
||||||
|
// возвращаемся в «слушаю» (микрофон снова открыт).
|
||||||
|
log("stt", "отменено — возврат в «слушаю»")
|
||||||
|
GlassesApp.instance.thinking.value = false
|
||||||
|
GlassesApp.instance.mainActivity?.resumeSttListening()
|
||||||
|
?: log("stt", "mainActivity ещё нет — не могу вернуть микрофон")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is Shutdown -> {
|
is Shutdown -> {
|
||||||
addMessage("Shutdown: закрываю приложение")
|
addMessage("Shutdown: закрываю приложение")
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ class MainActivity : BaseEventActivity() {
|
|||||||
if (app.movieController.pauseIfPlaying()) {
|
if (app.movieController.pauseIfPlaying()) {
|
||||||
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
|
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
|
||||||
}
|
}
|
||||||
|
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
||||||
app.listening.value = true
|
app.listening.value = true
|
||||||
app.thinking.value = false
|
app.thinking.value = false
|
||||||
app.sttText.value = ""
|
app.sttText.value = ""
|
||||||
@@ -278,6 +279,7 @@ class MainActivity : BaseEventActivity() {
|
|||||||
private fun sendPhrase() {
|
private fun sendPhrase() {
|
||||||
log("gesture", "Click → отправить фразу")
|
log("gesture", "Click → отправить фразу")
|
||||||
sttMic.stop()
|
sttMic.stop()
|
||||||
|
GlassesApp.instance.listening.value = false
|
||||||
GlassesApp.instance.thinking.value = true
|
GlassesApp.instance.thinking.value = true
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
||||||
@@ -296,10 +298,24 @@ class MainActivity : BaseEventActivity() {
|
|||||||
sttMic.start(filesDir) { app.listening.value }
|
sttMic.start(filesDir) { app.listening.value }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возврат в «слушаю» после отмены распознавания (SttCancel с телефона — клик без
|
||||||
|
* голоса или двойной клик): сброс «думаю», открытие микрофона. Разговор не прерывается,
|
||||||
|
* накопленный sttText/история сохраняются (TASK-glasses-chat-fix.md касяк №2).
|
||||||
|
*/
|
||||||
|
fun resumeSttListening() {
|
||||||
|
val app = GlassesApp.instance
|
||||||
|
app.thinking.value = false
|
||||||
|
app.listening.value = true
|
||||||
|
sttMic.start(filesDir) { app.listening.value }
|
||||||
|
log("stt", "возврат в «слушаю» (SttCancel)")
|
||||||
|
}
|
||||||
|
|
||||||
/** DoubleClick («слушаю»): отмена распознавания — остаёмся в разговоре (без выхода). */
|
/** DoubleClick («слушаю»): отмена распознавания — остаёмся в разговоре (без выхода). */
|
||||||
private fun cancelDictation() {
|
private fun cancelDictation() {
|
||||||
log("gesture", "DoubleClick → отмена распознавания")
|
log("gesture", "DoubleClick → отмена распознавания")
|
||||||
sttMic.stop()
|
sttMic.stop()
|
||||||
|
GlassesApp.instance.listening.value = false
|
||||||
GlassesApp.instance.thinking.value = false
|
GlassesApp.instance.thinking.value = false
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
|
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
|
||||||
@@ -307,8 +323,17 @@ class MainActivity : BaseEventActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** DoubleClick («думаю»/«ответ виден»): выход — закрыть оверлей; плей/пауза кино не трогаем. */
|
/** DoubleClick («думаю»/«ответ виден»): выход — закрыть оверлей; плей/пауза кино не трогаем. */
|
||||||
private fun exitAssistantMode() {
|
private fun exitAssistantMode() = finishAssistantMode("DoubleClick → выход из режима (кино остаётся на паузе)")
|
||||||
log("gesture", "DoubleClick → выход из режима (кино остаётся на паузе)")
|
|
||||||
|
/**
|
||||||
|
* Таймаут тишины 30с с телефона (SttCancel reason="timeout"): пользователь молчит/ушёл —
|
||||||
|
* выход из режима общения (микрофон закрыть, оверлей скрыть). Вызывается из HostConnection.
|
||||||
|
*/
|
||||||
|
fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима")
|
||||||
|
|
||||||
|
/** Общая логика выхода из режима общения: стоп микрофона + сброс состояний. */
|
||||||
|
private fun finishAssistantMode(reason: String) {
|
||||||
|
log("gesture", reason)
|
||||||
sttMic.stop()
|
sttMic.stop()
|
||||||
val app = GlassesApp.instance
|
val app = GlassesApp.instance
|
||||||
app.listening.value = false
|
app.listening.value = false
|
||||||
@@ -353,11 +378,18 @@ class MainActivity : BaseEventActivity() {
|
|||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
log("ui", "onPause")
|
log("ui", "onPause")
|
||||||
val was = GlassesApp.instance.movieController.pauseIfPlaying()
|
val app = GlassesApp.instance
|
||||||
|
// Защита «очки лежат и слушают»: при потере фокуса Activity (пользователь ушёл
|
||||||
|
// в лаунчер) режим общения гарантированно закрывается — микрофон не останется открытым.
|
||||||
|
if (app.listening.value || app.thinking.value || app.assistantText.value.isNotBlank()) {
|
||||||
|
log("ui", "onPause: режим общения активен — выход (микрофон закрыт)")
|
||||||
|
finishAssistantMode("onPause — потеря фокуса, выход из режима общения")
|
||||||
|
}
|
||||||
|
val was = app.movieController.pauseIfPlaying()
|
||||||
resumeAfterScreenOn = resumeAfterScreenOn || was
|
resumeAfterScreenOn = resumeAfterScreenOn || was
|
||||||
logEvent("onPause: pause wasPlaying=$was")
|
logEvent("onPause: pause wasPlaying=$was")
|
||||||
GlassesApp.instance.movieController.ensureWifiLock()
|
app.movieController.ensureWifiLock()
|
||||||
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
app.hostConnection.sendPlaybackPositionNow()
|
||||||
if (was) log("ui", "экран погас — пауза")
|
if (was) log("ui", "экран погас — пауза")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import android.media.AudioRecord
|
|||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -27,6 +29,9 @@ class SttMicStream(
|
|||||||
) {
|
) {
|
||||||
private var job: Job? = null
|
private var job: Job? = null
|
||||||
|
|
||||||
|
/** Debug-дампер сырого микрофона (открывается по маркеру stt_dump.marker). */
|
||||||
|
private var dump: WavWriter? = null
|
||||||
|
|
||||||
fun start(filesDir: File, shouldRun: () -> Boolean) {
|
fun start(filesDir: File, shouldRun: () -> Boolean) {
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
val recorder = runCatching {
|
val recorder = runCatching {
|
||||||
@@ -42,19 +47,26 @@ class SttMicStream(
|
|||||||
if (recorder == null || recorder.state != AudioRecord.STATE_INITIALIZED) {
|
if (recorder == null || recorder.state != AudioRecord.STATE_INITIALIZED) {
|
||||||
recorder?.release()
|
recorder?.release()
|
||||||
log("stt", "микрофон недоступен — отладка: PCM из файла")
|
log("stt", "микрофон недоступен — отладка: PCM из файла")
|
||||||
|
dump = null
|
||||||
job = scope.launch { sendWavFileAsStream(filesDir, shouldRun) }
|
job = scope.launch { sendWavFileAsStream(filesDir, shouldRun) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dump = openLiveMicDump(filesDir)
|
||||||
|
val dumpWriter = dump
|
||||||
job = scope.launch(Dispatchers.IO) {
|
job = scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
recorder.startRecording()
|
recorder.startRecording()
|
||||||
log("stt", "микрофон запущен (16 кГц, s16, mono)")
|
log("stt", "микрофон запущен (16 кГц, s16, mono)")
|
||||||
val buf = ByteArray(3_200)
|
val buf = ByteArray(CHUNK_BYTES) // = 1600 кадров (16-бит моно) = 100 мс
|
||||||
while (isActive && shouldRun()) {
|
while (isActive && shouldRun()) {
|
||||||
val n = recorder.read(buf, 0, buf.size)
|
val frames = recorder.read(buf, 0, CHUNK_BYTES / 2) // inFrameCount — КАДРЫ
|
||||||
if (n > 0) {
|
if (frames > 0) {
|
||||||
val chunk = if (n == buf.size) buf else buf.copyOf(n)
|
val chunk = micChunkBytes(buf, frames)
|
||||||
|
if (dumpWriter != null) {
|
||||||
|
runCatching { dumpWriter.write(chunk) }
|
||||||
|
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||||
|
}
|
||||||
onChunk(chunk)
|
onChunk(chunk)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +75,8 @@ class SttMicStream(
|
|||||||
} finally {
|
} finally {
|
||||||
runCatching { recorder.stop() }
|
runCatching { recorder.stop() }
|
||||||
recorder.release()
|
recorder.release()
|
||||||
|
runCatching { dumpWriter?.close() }
|
||||||
|
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||||
log("stt", "микрофон остановлен")
|
log("stt", "микрофон остановлен")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +85,24 @@ class SttMicStream(
|
|||||||
fun stop() {
|
fun stop() {
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
job = null
|
job = null
|
||||||
|
runCatching { dump?.close() }
|
||||||
|
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker,
|
||||||
|
* открыть stt_dump_HHmmss.wav — ВСЕ чанки живого микрофона допишутся туда
|
||||||
|
* до отправки в onChunk (эмуляторную ветку не трогаем).
|
||||||
|
*/
|
||||||
|
private fun openLiveMicDump(filesDir: File): WavWriter? {
|
||||||
|
if (!File(filesDir, "stt_dump.marker").exists()) return null
|
||||||
|
val stamp = LocalTime.now().format(DateTimeFormatter.ofPattern("HHmmss"))
|
||||||
|
val path = File(filesDir, "stt_dump_$stamp.wav")
|
||||||
|
return runCatching {
|
||||||
|
WavWriter(path).also { log("stt", "dump микрофона: $path") }
|
||||||
|
}
|
||||||
|
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||||
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,6 +138,20 @@ class SttMicStream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 100 мс при 16 кГц, s16, моно: 1600 кадров × 2 байта = 3200 байт. */
|
||||||
|
internal const val CHUNK_BYTES = 3_200
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AudioRecord.read(byte[], int, inFrameCount) возвращает ЧИСЛО КАДРОВ, а не байт.
|
||||||
|
* 16-бит моно: кадр = 2 байта. Байты = frames * 2.
|
||||||
|
* Старый баг (заделан в c940a7e): return read() (кадры) сравнивали с buf.size (байты)
|
||||||
|
* — всегда ложь → copyOf(n) резал кусок пополам (терялись 2-е 50 мс каждого 100 мс).
|
||||||
|
*/
|
||||||
|
internal fun micChunkBytes(buf: ByteArray, frames: Int): ByteArray {
|
||||||
|
val bytes = frames * 2
|
||||||
|
return if (bytes == buf.size) buf else buf.copyOf(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
/** FloatArray [-1, 1] → s16le ByteArray (16 кГц, mono). */
|
/** FloatArray [-1, 1] → s16le ByteArray (16 кГц, mono). */
|
||||||
internal fun floatSamplesToPcm16(samples: FloatArray): ByteArray {
|
internal fun floatSamplesToPcm16(samples: FloatArray): ByteArray {
|
||||||
val out = ByteArray(samples.size * 2)
|
val out = ByteArray(samples.size * 2)
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package pw.binom.viewmate.glasses.stt
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.RandomAccessFile
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный WAV-райтер (PCM s16le): 44-байтовый RIFF-заголовок пишется один раз
|
||||||
|
* при создании файла, [write] дописывает чанки, на close в заголовок дописываются
|
||||||
|
* реальные размеры (best-effort — при сбое ридер читает файл до EOF, размеры
|
||||||
|
* 0xFFFFFFFF — конвенция стриминговых WAV).
|
||||||
|
*
|
||||||
|
* Не потокобезопасен — вызывать из одного потока (поток микрофона).
|
||||||
|
*/
|
||||||
|
class WavWriter(
|
||||||
|
private val path: File,
|
||||||
|
private val sampleRate: Int = 16_000,
|
||||||
|
private val channels: Int = 1,
|
||||||
|
) {
|
||||||
|
private val out: FileOutputStream
|
||||||
|
private var dataBytes = 0L
|
||||||
|
private var closed = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
out = FileOutputStream(path)
|
||||||
|
out.write(headerBytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Дописать чанк s16le PCM (16 кГц, mono). */
|
||||||
|
fun write(pcm: ByteArray) {
|
||||||
|
out.write(pcm)
|
||||||
|
dataBytes += pcm.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Закрыть файл и прописать реальные размеры в заголовок (идемпотентно). */
|
||||||
|
fun close() {
|
||||||
|
if (closed) return
|
||||||
|
closed = true
|
||||||
|
out.close()
|
||||||
|
runCatching {
|
||||||
|
RandomAccessFile(path, "rw").use { raf ->
|
||||||
|
raf.seek(4)
|
||||||
|
raf.writeInt((36 + dataBytes).toInt()) // размер RIFF
|
||||||
|
raf.seek(40)
|
||||||
|
raf.writeInt(dataBytes.toInt()) // размер data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun headerBytes(): ByteArray =
|
||||||
|
ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN).apply {
|
||||||
|
put("RIFF".toByteArray())
|
||||||
|
putInt(-1) // placeholder
|
||||||
|
put("WAVE".toByteArray())
|
||||||
|
put("fmt ".toByteArray())
|
||||||
|
putInt(16)
|
||||||
|
putShort(1.toShort()) // PCM
|
||||||
|
putShort(channels.toShort())
|
||||||
|
putInt(sampleRate)
|
||||||
|
putInt(sampleRate * channels * 2)
|
||||||
|
putShort((channels * 2).toShort())
|
||||||
|
putShort(16.toShort())
|
||||||
|
put("data".toByteArray())
|
||||||
|
putInt(-1) // placeholder
|
||||||
|
}.array()
|
||||||
|
}
|
||||||
@@ -11,7 +11,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -25,6 +28,7 @@ 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.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pw.binom.viewmate.glasses.ChatEntry
|
||||||
import pw.binom.viewmate.glasses.GlassesApp
|
import pw.binom.viewmate.glasses.GlassesApp
|
||||||
import pw.binom.viewmate.glasses.gestures.AssistantState
|
import pw.binom.viewmate.glasses.gestures.AssistantState
|
||||||
import pw.binom.viewmate.glasses.gestures.overlayHeader
|
import pw.binom.viewmate.glasses.gestures.overlayHeader
|
||||||
@@ -35,7 +39,8 @@ import pw.binom.viewmate.glasses.gestures.scrollOffset
|
|||||||
* Оверлей режима общения с ассистентом: затемняет 72% чёрным ЛЮБУЮ картинку
|
* Оверлей режима общения с ассистентом: затемняет 72% чёрным ЛЮБУЮ картинку
|
||||||
* (кино или экран подключения) — режим общения явный (docs/gestures-and-modes.md).
|
* (кино или экран подключения) — режим общения явный (docs/gestures-and-modes.md).
|
||||||
* По глазам ([Binocular]), как все экраны. Состояния: «слушаю»/«думаю...» — заголовок,
|
* По глазам ([Binocular]), как все экраны. Состояния: «слушаю»/«думаю...» — заголовок,
|
||||||
* «ответ виден» — прокручиваемый ответ (дельта скролла от жестов — [GlassesApp.answerScrollDelta]).
|
* «ответ виден» — прокручиваемая история диалога (дельта скролла от жестов —
|
||||||
|
* [GlassesApp.answerScrollDelta]).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AssistantOverlay() {
|
fun AssistantOverlay() {
|
||||||
@@ -44,14 +49,46 @@ fun AssistantOverlay() {
|
|||||||
val thinking by app.thinking.collectAsState()
|
val thinking by app.thinking.collectAsState()
|
||||||
val sttText by app.sttText.collectAsState()
|
val sttText by app.sttText.collectAsState()
|
||||||
val assistantText by app.assistantText.collectAsState()
|
val assistantText by app.assistantText.collectAsState()
|
||||||
|
val chatHistory by app.chatHistory.collectAsState()
|
||||||
val state = AssistantState(listening, thinking, sttText, assistantText)
|
val state = AssistantState(listening, thinking, sttText, assistantText)
|
||||||
if (!overlayVisible(state)) return
|
if (!overlayVisible(state)) return
|
||||||
overlayBox(app, state)
|
// Скролл и эффекты живут ВЫШЕ Binocular: Binocular рендерит контент дважды
|
||||||
|
// (левый/правый глаз), поэтому scrollState и коллекторы должны существовать
|
||||||
|
// в единственном экземпляре — иначе глаза скроллят по-разному.
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
val hasHistory = chatHistory.isNotEmpty()
|
||||||
|
|
||||||
|
// Новый ответ (ShowText) или пополнившаяся история — к последнему сообщению.
|
||||||
|
LaunchedEffect(chatHistory.size, state.assistantText) {
|
||||||
|
if (hasHistory || state.assistantText.isNotBlank()) {
|
||||||
|
scrollState.animateScrollTo(scrollState.maxValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Накопленная дельта скролла от жестов. Коллектор один на оба глаза;
|
||||||
|
// аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset].
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
app.answerScrollDelta.collect { pending ->
|
||||||
|
if (pending == 0f) return@collect
|
||||||
|
val target = scrollOffset(scrollState.value, pending, scrollState.maxValue)
|
||||||
|
app.answerScrollDelta.value = 0f
|
||||||
|
if (target != scrollState.value) {
|
||||||
|
scrollState.animateScrollTo(target, tween(150))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayBox(app, state, chatHistory, scrollState)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поглазник: тот же scale/offset-контейнер, что у экранов, но с затемнением вместо чёрного. */
|
/** Поглазник: тот же scale/offset-контейнер, что у экранов, но с затемнением вместо чёрного. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun overlayBox(app: GlassesApp, state: AssistantState) {
|
private fun overlayBox(
|
||||||
|
app: GlassesApp,
|
||||||
|
state: AssistantState,
|
||||||
|
chatHistory: List<ChatEntry>,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
) {
|
||||||
Binocular(scale = false) {
|
Binocular(scale = false) {
|
||||||
val scale by app.scale.collectAsState()
|
val scale by app.scale.collectAsState()
|
||||||
val screenWidthPx = BinocularScreenSize.current.width
|
val screenWidthPx = BinocularScreenSize.current.width
|
||||||
@@ -66,42 +103,37 @@ private fun overlayBox(app: GlassesApp, state: AssistantState) {
|
|||||||
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
|
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
|
||||||
.background(Color.Black.copy(alpha = 0.72f)),
|
.background(Color.Black.copy(alpha = 0.72f)),
|
||||||
) {
|
) {
|
||||||
AssistantOverlayContent(state)
|
AssistantOverlayContent(state, chatHistory, scrollState)
|
||||||
|
|
||||||
|
if (state.listening) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(top = 12.dp, end = 12.dp)
|
||||||
|
.size(16.dp)
|
||||||
|
.background(Color(0xFFFF3B30), CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AssistantOverlayContent(state: AssistantState) {
|
private fun AssistantOverlayContent(
|
||||||
val app = GlassesApp.instance
|
state: AssistantState,
|
||||||
val hasAnswer = state.assistantText.isNotBlank()
|
chatHistory: List<ChatEntry>,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
) {
|
||||||
|
val hasHistory = chatHistory.isNotEmpty()
|
||||||
|
val fallbackAnswer = chatHistory.isEmpty() && state.assistantText.isNotBlank()
|
||||||
val header = overlayHeader(state)
|
val header = overlayHeader(state)
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
|
|
||||||
// Новый ответ приходит с обнулением answerScrollDelta (ShowText) — к началу текста.
|
|
||||||
LaunchedEffect(state.assistantText) {
|
|
||||||
scrollState.scrollTo(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Накопленная дельта скролла от жестов: каждый глаз — свой [LaunchedEffect],
|
|
||||||
// аккумулятор (answerScrollDelta) общий в GlassesApp. Цель — чистый [scrollOffset].
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
app.answerScrollDelta.collect { pending ->
|
|
||||||
if (pending == 0f) return@collect
|
|
||||||
val target = scrollOffset(scrollState.value, pending, scrollState.maxValue)
|
|
||||||
app.answerScrollDelta.value = 0f
|
|
||||||
if (target != scrollState.value) {
|
|
||||||
scrollState.animateScrollTo(target, tween(150))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = if (hasAnswer) Arrangement.Top else Arrangement.Center,
|
verticalArrangement = if (hasHistory || fallbackAnswer) Arrangement.Top else Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
if (!header.isNullOrBlank()) {
|
if (!header.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
@@ -111,7 +143,28 @@ private fun AssistantOverlayContent(state: AssistantState) {
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (hasAnswer) {
|
if (hasHistory) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
chatHistory.forEach { entry ->
|
||||||
|
Text(
|
||||||
|
text = if (entry.role == "user") "я: ${entry.text}" else "ассистент: ${entry.text}",
|
||||||
|
color = if (entry.role == "user") Color.White else Color(0xFFB0BEC5),
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (fallbackAnswer) {
|
||||||
|
// Старый клиент телефона не шлёт историю: показываем одиночный ответ.
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -120,10 +173,13 @@ private fun AssistantOverlayContent(state: AssistantState) {
|
|||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = state.assistantText,
|
text = "ассистент: ${state.assistantText}",
|
||||||
color = Color(0xFFB0BEC5),
|
color = Color(0xFFB0BEC5),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Start,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package pw.binom.viewmate.glasses.stt
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotSame
|
||||||
|
import kotlin.test.assertSame
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Регрессия c940a7e: AudioRecord.read возвращает ЧИСЛО КАДРОВ, не байт.
|
||||||
|
* 16-бит моно: кадр = 2 байта. Старики брали return read() как байты —
|
||||||
|
* и copyOf(кадры) резал каждый 100-мс кусок пополам (терялись 2-е 50 мс).
|
||||||
|
*/
|
||||||
|
class MicChunkTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fullChunk_all1600Frames_returnsAll3200Bytes() {
|
||||||
|
val buf = ByteArray(CHUNK_BYTES)
|
||||||
|
val out = micChunkBytes(buf, CHUNK_BYTES / 2) // 1600 кадров = полная 100 мс
|
||||||
|
assertEquals(CHUNK_BYTES, out.size, "полный чанк = 3200 байт (100 мс), а не 1600")
|
||||||
|
assertSame(buf, out, "полный чанк не копируется — реюзаем buf без аллокаций")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shortRead_800Frames_gives1600Bytes_not800() {
|
||||||
|
val buf = ByteArray(CHUNK_BYTES)
|
||||||
|
val out = micChunkBytes(buf, 800) // 800 кадров = 1600 байт
|
||||||
|
assertEquals(1_600, out.size, "байты = кадры × 2, не равны количеству кадров")
|
||||||
|
assertNotSame(buf, out, "короткий чанк копируется, иначе хвост buf — мусор")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun oldLogic_lostSecondHalf_ofEachChunk() {
|
||||||
|
// Задокументированный баг: n (кадры) брался как байты.
|
||||||
|
val buf = ByteArray(CHUNK_BYTES)
|
||||||
|
val frames = recorderWouldReturn(1600) // полный 100-мс запрос вернул 1600 кадров
|
||||||
|
val oldChunk = if (frames == buf.size) buf else buf.copyOf(frames) // СТАРОЕ
|
||||||
|
assertEquals(1_600, oldChunk.size, "старая логика теряла 2-ю половину куска — отсюда «как через один»")
|
||||||
|
val newChunk = micChunkBytes(buf, frames)
|
||||||
|
assertEquals(3_200, newChunk.size, "новая логика отдаёт полные 100 мс")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun framesRequested_forFull100MsIs1600() {
|
||||||
|
assertEquals(1_600, CHUNK_BYTES / 2, "read() просит КАДРОВ: 1600 (не 3200)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recorderWouldReturn(frames: Int) = frames
|
||||||
|
}
|
||||||
@@ -111,6 +111,8 @@ dependencies {
|
|||||||
implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0")
|
implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.0")
|
||||||
|
|
||||||
testImplementation(kotlin("test"))
|
testImplementation(kotlin("test"))
|
||||||
|
// Тестовый стриминговый VAD (Silero на ONNX, JVM) — из mavenLocal
|
||||||
|
testImplementation("pw.binom.ai.voice:vad-jvm:1.0.0-SNAPSHOT")
|
||||||
testImplementation("io.ktor:ktor-server-test-host:3.3.0")
|
testImplementation("io.ktor:ktor-server-test-host:3.3.0")
|
||||||
testImplementation("io.ktor:ktor-client-websockets:3.3.0")
|
testImplementation("io.ktor:ktor-client-websockets:3.3.0")
|
||||||
testImplementation("io.ktor:ktor-client-content-negotiation:3.3.0")
|
testImplementation("io.ktor:ktor-client-content-negotiation:3.3.0")
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ class GlassesHub(
|
|||||||
@Volatile
|
@Volatile
|
||||||
var stt: SttStreamer? = null
|
var stt: SttStreamer? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единственный путь текста в LLM: вызывает PhoneApp (assistantChannel.send)
|
||||||
|
* на StopStt(cancel=false) с непустым текстом. VAD-фразы в LLM НЕ уходят —
|
||||||
|
* только повторный клик пользователя.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
var onStopFullText: ((String) -> Unit)? = null
|
||||||
|
|
||||||
/** Фабрика SttStreamer на первый SttAudio (тяжёлая — создаётся один раз). */
|
/** Фабрика SttStreamer на первый SttAudio (тяжёлая — создаётся один раз). */
|
||||||
@Volatile
|
@Volatile
|
||||||
var sttFactory: (() -> SttStreamer?)? = null
|
var sttFactory: (() -> SttStreamer?)? = null
|
||||||
@@ -195,13 +203,22 @@ class GlassesHub(
|
|||||||
is StopStt -> {
|
is StopStt -> {
|
||||||
if (msg.cancel) {
|
if (msg.cancel) {
|
||||||
log("stt", "распознавание отменено (очки)")
|
log("stt", "распознавание отменено (очки)")
|
||||||
send(connId, SttCancel())
|
send(connId, SttCancel(reason = SttCancel.REASON_CANCEL))
|
||||||
stt?.reset()
|
stt?.reset()
|
||||||
} else {
|
} else {
|
||||||
val full = stt?.finish() ?: ""
|
val full = stt?.finish() ?: ""
|
||||||
log("stt", "ВЕСЬ ТЕКСТ: $full")
|
log("stt", "ВЕСЬ ТЕКСТ: $full")
|
||||||
send(connId, SttDone(full))
|
|
||||||
stt?.reset()
|
stt?.reset()
|
||||||
|
if (full.isNotBlank()) {
|
||||||
|
send(connId, SttDone(full))
|
||||||
|
val phrase = full.trim()
|
||||||
|
log("stt", "фраза в LLM (по клику): $phrase")
|
||||||
|
onStopFullText?.invoke(phrase)
|
||||||
|
} else {
|
||||||
|
// Фразы не было — очки возвращаются в «слушаю» (SttCancel).
|
||||||
|
log("stt", "фразы не было — очки в «слушаю»")
|
||||||
|
broadcast(SttCancel(reason = SttCancel.REASON_USER))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ 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.AssistantState
|
||||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||||
|
import pw.binom.viewmate.core.protocol.ChatHistoryItem
|
||||||
|
import pw.binom.viewmate.core.protocol.ChatHistoryMsg
|
||||||
import pw.binom.viewmate.core.protocol.ShowText
|
import pw.binom.viewmate.core.protocol.ShowText
|
||||||
import pw.binom.viewmate.core.protocol.SttCancel
|
import pw.binom.viewmate.core.protocol.SttCancel
|
||||||
import pw.binom.viewmate.core.protocol.SttPhrase
|
import pw.binom.viewmate.core.protocol.SttPhrase
|
||||||
@@ -30,6 +32,7 @@ import pw.binom.viewmate.phone.agent.AgentToolkit
|
|||||||
import pw.binom.viewmate.phone.agent.Assistant
|
import pw.binom.viewmate.phone.agent.Assistant
|
||||||
import pw.binom.viewmate.phone.agent.ChatDao
|
import pw.binom.viewmate.phone.agent.ChatDao
|
||||||
import pw.binom.viewmate.phone.agent.ChatDb
|
import pw.binom.viewmate.phone.agent.ChatDb
|
||||||
|
import pw.binom.viewmate.phone.agent.ChatRole
|
||||||
import pw.binom.viewmate.phone.agent.Gemma4E2B
|
import pw.binom.viewmate.phone.agent.Gemma4E2B
|
||||||
import pw.binom.viewmate.phone.agent.GemmaModelDownloader
|
import pw.binom.viewmate.phone.agent.GemmaModelDownloader
|
||||||
import pw.binom.viewmate.phone.agent.JellyfinMediaSource
|
import pw.binom.viewmate.phone.agent.JellyfinMediaSource
|
||||||
@@ -43,6 +46,7 @@ import pw.binom.viewmate.phone.agent.RemoteLlmClient
|
|||||||
import pw.binom.viewmate.phone.agent.SkillRegistry
|
import pw.binom.viewmate.phone.agent.SkillRegistry
|
||||||
import pw.binom.viewmate.phone.agent.SkillRepository
|
import pw.binom.viewmate.phone.agent.SkillRepository
|
||||||
import pw.binom.viewmate.phone.agent.ToolsetRegistry
|
import pw.binom.viewmate.phone.agent.ToolsetRegistry
|
||||||
|
import pw.binom.viewmate.phone.stt.SherpaStreamingVad
|
||||||
import pw.binom.viewmate.phone.stt.SttStreamer
|
import pw.binom.viewmate.phone.stt.SttStreamer
|
||||||
import pw.binom.viewmate.phone.stt.WhisperStt
|
import pw.binom.viewmate.phone.stt.WhisperStt
|
||||||
|
|
||||||
@@ -290,10 +294,25 @@ class PhoneApp : Application() {
|
|||||||
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
||||||
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
||||||
server.hub.broadcast(ShowText(answer))
|
server.hub.broadcast(ShowText(answer))
|
||||||
|
// Очки показывают историю всей сессии (свайпами); ответ уже в ней.
|
||||||
|
server.hub.broadcast(chatHistoryMsg())
|
||||||
server.hub.broadcast(AssistantStateMsg(AssistantState.IDLE))
|
server.hub.broadcast(AssistantStateMsg(AssistantState.IDLE))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* История активной (или последней) сессии для очков: только роли user/assistant,
|
||||||
|
* tool-сообщения и toolCalls очкам не нужны (ТЗ-касяк №1).
|
||||||
|
*/
|
||||||
|
private fun chatHistoryMsg(): ChatHistoryMsg {
|
||||||
|
val id = _activeSessionId.value ?: chatDao.latestSessionId()
|
||||||
|
if (id == null) return ChatHistoryMsg(sessionId = null, messages = emptyList())
|
||||||
|
val items = chatDao.messagesForSession(id)
|
||||||
|
.filter { it.role == ChatRole.USER || it.role == ChatRole.ASSISTANT }
|
||||||
|
.map { ChatHistoryItem(role = it.role.wire, content = it.content) }
|
||||||
|
return ChatHistoryMsg(sessionId = id.toString(), messages = items)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отправка текста в ассистента из чата на телефоне (не с очков).
|
* Отправка текста в ассистента из чата на телефоне (не с очков).
|
||||||
* Та же очередь, что и голосовые фразы — сериализация LLM сохраняется.
|
* Та же очередь, что и голосовые фразы — сериализация LLM сохраняется.
|
||||||
@@ -347,20 +366,27 @@ class PhoneApp : Application() {
|
|||||||
tokensPath = tokens.absolutePath,
|
tokensPath = tokens.absolutePath,
|
||||||
numThreads = 4,
|
numThreads = 4,
|
||||||
)
|
)
|
||||||
|
val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) {
|
||||||
|
File(filesDir, "stt_dump_phone.raw")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
val s = SttStreamer(
|
val s = SttStreamer(
|
||||||
stt = whisper,
|
stt = whisper,
|
||||||
vadModelPath = vadModel.absolutePath,
|
vad = SherpaStreamingVad(vadModel.absolutePath),
|
||||||
onPhrase = { phrase, full ->
|
onPhrase = { phrase, full ->
|
||||||
log("stt", "фраза: $phrase")
|
log("stt", "фраза: $phrase")
|
||||||
|
// Только показ на очках — в LLM фраза НЕ уходит (только по клику).
|
||||||
scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) }
|
scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) }
|
||||||
scope.launch { assistantChannel.send(phrase) }
|
|
||||||
},
|
},
|
||||||
onSilence30s = {
|
onSilence30s = {
|
||||||
log("stt", "тишина 30 с — автo-отмена")
|
log("stt", "тишина 30 с — автo-отмена")
|
||||||
scope.launch { server.hub.broadcast(SttCancel()) }
|
scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) }
|
||||||
},
|
},
|
||||||
|
dumpPcm = dumpPcm,
|
||||||
)
|
)
|
||||||
sttStreamer = s
|
sttStreamer = s
|
||||||
|
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
|
||||||
log("stt", "SttStreamer готов (Whisper-small int8, VAD fp32)")
|
log("stt", "SttStreamer готов (Whisper-small int8, VAD fp32)")
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -404,6 +430,7 @@ class PhoneApp : Application() {
|
|||||||
DebugHttpServer.start(this)
|
DebugHttpServer.start(this)
|
||||||
nsd.publish()
|
nsd.publish()
|
||||||
server.hub.sttFactory = { ensureStt() }
|
server.hub.sttFactory = { ensureStt() }
|
||||||
|
server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } }
|
||||||
scope.launch { assistantLoop() }
|
scope.launch { assistantLoop() }
|
||||||
|
|
||||||
val lastPositionAt = AtomicLong(0)
|
val lastPositionAt = AtomicLong(0)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
/** Распознавание одной фразы (16 кГц mono PCM) в текст. */
|
||||||
|
interface PhraseRecognizer {
|
||||||
|
fun recognize(samples: FloatArray): String
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
import com.k2fsa.sherpa.onnx.SileroVadModelConfig
|
||||||
|
import com.k2fsa.sherpa.onnx.Vad
|
||||||
|
import com.k2fsa.sherpa.onnx.VadModelConfig
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Боевой стриминговый VAD на нативной [Vad] (sherpa-onnx, Silero).
|
||||||
|
* Те же параметры, что были в vadConfig() SttStreamer: threshold 0.5,
|
||||||
|
* minSilence/minSpeech 0.25 с, окно 512, maxSpeech 20 с, 16 кГц, 1 поток, CPU.
|
||||||
|
*/
|
||||||
|
class SherpaStreamingVad(modelPath: String) : StreamingVad {
|
||||||
|
|
||||||
|
private val vad: Vad = Vad(
|
||||||
|
null,
|
||||||
|
VadModelConfig(
|
||||||
|
sileroVadModelConfig = SileroVadModelConfig(
|
||||||
|
model = modelPath,
|
||||||
|
threshold = 0.5f,
|
||||||
|
minSilenceDuration = 0.25f,
|
||||||
|
minSpeechDuration = 0.25f,
|
||||||
|
windowSize = 512,
|
||||||
|
maxSpeechDuration = 20f,
|
||||||
|
),
|
||||||
|
sampleRate = 16000,
|
||||||
|
numThreads = 1,
|
||||||
|
provider = "cpu",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
override val windowSizeSamples: Int = 512
|
||||||
|
|
||||||
|
override fun acceptWindow(window: FloatArray) {
|
||||||
|
vad.acceptWaveform(window)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popSegment(): VadSegment? {
|
||||||
|
if (vad.empty()) return null
|
||||||
|
val s = vad.front()
|
||||||
|
val out = VadSegment(s.start, s.samples)
|
||||||
|
vad.pop()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun flush() {
|
||||||
|
vad.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
runCatching { vad.release() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
/** Сегмент речи: [startSample, startSample+samples.size) в сэмплах 16 кГц. */
|
||||||
|
class VadSegment(val startSample: Int, val samples: FloatArray)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Стриминговый VAD: подача окнами по windowSizeSamples → готовые сегменты.
|
||||||
|
* Реализации: [SherpaStreamingVad] (боевая, Android), SileroStreamingVad (тесты, JVM).
|
||||||
|
*/
|
||||||
|
interface StreamingVad {
|
||||||
|
val windowSizeSamples: Int
|
||||||
|
/** Подать окно; готовые сегменты (если появились) забираются popSegment(). */
|
||||||
|
fun acceptWindow(window: FloatArray)
|
||||||
|
/** Последний готовый сегмент (копию caller должен делать сам, если нужно удерживать). */
|
||||||
|
fun popSegment(): VadSegment?
|
||||||
|
/** Закрыть текущий незавершённый сегмент (если есть). */
|
||||||
|
fun flush()
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
package pw.binom.viewmate.phone.stt
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
import com.k2fsa.sherpa.onnx.SileroVadModelConfig
|
import java.io.File
|
||||||
import com.k2fsa.sherpa.onnx.Vad
|
import java.io.FileOutputStream
|
||||||
import com.k2fsa.sherpa.onnx.VadModelConfig
|
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.Future
|
import java.util.concurrent.Future
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
import kotlin.concurrent.withLock
|
import kotlin.concurrent.withLock
|
||||||
|
import pw.binom.viewmate.phone.log
|
||||||
|
|
||||||
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
|
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
|
||||||
internal fun s16leToFloat(pcm: ByteArray): FloatArray {
|
internal fun s16leToFloat(pcm: ByteArray): FloatArray {
|
||||||
@@ -36,10 +36,11 @@ internal fun s16leToFloat(pcm: ByteArray): FloatArray {
|
|||||||
* (автo-отмена, вызывается один раз за сессию).
|
* (автo-отмена, вызывается один раз за сессию).
|
||||||
*/
|
*/
|
||||||
class SttStreamer(
|
class SttStreamer(
|
||||||
private val stt: WhisperStt,
|
private val stt: PhraseRecognizer,
|
||||||
private val vadModelPath: String,
|
private val vad: StreamingVad,
|
||||||
private val onPhrase: (phrase: String, full: String) -> Unit,
|
private val onPhrase: (phrase: String, full: String) -> Unit,
|
||||||
private val onSilence30s: () -> Unit,
|
private val onSilence30s: () -> Unit,
|
||||||
|
private val dumpPcm: File? = null,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val SILENCE_TIMEOUT_MS = 30_000L
|
private const val SILENCE_TIMEOUT_MS = 30_000L
|
||||||
@@ -47,7 +48,6 @@ class SttStreamer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val lock = java.util.concurrent.locks.ReentrantLock()
|
private val lock = java.util.concurrent.locks.ReentrantLock()
|
||||||
private val vad: Vad = Vad(null, vadConfig())
|
|
||||||
private val executor = Executors.newSingleThreadExecutor { r ->
|
private val executor = Executors.newSingleThreadExecutor { r ->
|
||||||
Thread(r, "stt-recognize").apply { isDaemon = true }
|
Thread(r, "stt-recognize").apply { isDaemon = true }
|
||||||
}
|
}
|
||||||
@@ -64,6 +64,18 @@ class SttStreamer(
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var deadlineMs = 0L
|
private var deadlineMs = 0L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug-дампа принятых PCM: сырые s16le (16 кГц, mono), без WAV-заголовка —
|
||||||
|
* формат помечен в логе. Сбой открытия не ломает распознавание.
|
||||||
|
*/
|
||||||
|
private val dumpOut: FileOutputStream? = dumpPcm?.let { file ->
|
||||||
|
runCatching {
|
||||||
|
FileOutputStream(file, true).also {
|
||||||
|
log("stt", "PCM-дампа: ${file.absolutePath} (16 кГц, s16, mono)")
|
||||||
|
}
|
||||||
|
}.onFailure { log("stt", "PCM-дампа: ${it.message}") }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
/** Недозаполненные сэмплы (< 512) для следующего окна VAD. */
|
/** Недозаполненные сэмплы (< 512) для следующего окна VAD. */
|
||||||
private var pending = FloatArray(0)
|
private var pending = FloatArray(0)
|
||||||
|
|
||||||
@@ -113,6 +125,11 @@ class SttStreamer(
|
|||||||
}
|
}
|
||||||
if (i < floats.size) pending = floats.copyOfRange(i, floats.size)
|
if (i < floats.size) pending = floats.copyOfRange(i, floats.size)
|
||||||
}
|
}
|
||||||
|
val out = dumpOut
|
||||||
|
if (out != null) {
|
||||||
|
runCatching { out.write(pcm) }
|
||||||
|
.onFailure { log("stt", "PCM-дампа: ${it.message}") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,10 +140,11 @@ class SttStreamer(
|
|||||||
fun finish(): String {
|
fun finish(): String {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
vad.flush()
|
vad.flush()
|
||||||
while (!vad.empty()) {
|
var seg = vad.popSegment()
|
||||||
val seg = vad.front()
|
while (seg != null) {
|
||||||
recognizeSegment(seg.samples)
|
// Копия: массив может переиспользоваться после pop.
|
||||||
vad.pop()
|
recognizeSegment(seg.samples.copyOf())
|
||||||
|
seg = vad.popSegment()
|
||||||
}
|
}
|
||||||
val last = lastFuture
|
val last = lastFuture
|
||||||
if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) }
|
if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) }
|
||||||
@@ -142,8 +160,10 @@ class SttStreamer(
|
|||||||
fun close() {
|
fun close() {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
deadlineMs = 0
|
deadlineMs = 0
|
||||||
runCatching { vad.release() }
|
vad.close()
|
||||||
}
|
}
|
||||||
|
runCatching { dumpOut?.close() }
|
||||||
|
.onFailure { log("stt", "PCM-дампа: ${it.message}") }
|
||||||
executor.shutdownNow()
|
executor.shutdownNow()
|
||||||
watchdog.interrupt()
|
watchdog.interrupt()
|
||||||
}
|
}
|
||||||
@@ -152,7 +172,10 @@ class SttStreamer(
|
|||||||
// Выбросить остаток VAD-буфера прошлой сессии, чтобы он не склеился
|
// Выбросить остаток VAD-буфера прошлой сессии, чтобы он не склеился
|
||||||
// с началом следующей (cancel отбрасывает текст, flush не признаётся).
|
// с началом следующей (cancel отбрасывает текст, flush не признаётся).
|
||||||
runCatching { vad.flush() }
|
runCatching { vad.flush() }
|
||||||
while (!vad.empty()) vad.pop()
|
var seg = vad.popSegment()
|
||||||
|
while (seg != null) {
|
||||||
|
seg = vad.popSegment()
|
||||||
|
}
|
||||||
session++
|
session++
|
||||||
full = ""
|
full = ""
|
||||||
pending = FloatArray(0)
|
pending = FloatArray(0)
|
||||||
@@ -161,12 +184,12 @@ class SttStreamer(
|
|||||||
|
|
||||||
/** Подать окно ровно по 512 сэмплов в VAD и забрать готовые сегменты. */
|
/** Подать окно ровно по 512 сэмплов в VAD и забрать готовые сегменты. */
|
||||||
private fun feedWindow(window: FloatArray) {
|
private fun feedWindow(window: FloatArray) {
|
||||||
vad.acceptWaveform(window)
|
vad.acceptWindow(window)
|
||||||
while (!vad.empty()) {
|
var seg = vad.popSegment()
|
||||||
val seg = vad.front()
|
while (seg != null) {
|
||||||
// Копия: массив из нативного VAD может переиспользоваться после pop.
|
// Копия: массив может переиспользоваться после pop.
|
||||||
recognizeSegment(seg.samples.copyOf())
|
recognizeSegment(seg.samples.copyOf())
|
||||||
vad.pop()
|
seg = vad.popSegment()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,18 +206,4 @@ class SttStreamer(
|
|||||||
onPhrase(phrase, newFull)
|
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",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class WhisperStt(
|
|||||||
private val tokensPath: String,
|
private val tokensPath: String,
|
||||||
private val numThreads: Int = 4,
|
private val numThreads: Int = 4,
|
||||||
private val language: String = "ru",
|
private val language: String = "ru",
|
||||||
) {
|
) : PhraseRecognizer {
|
||||||
private val recognizer: OfflineRecognizer = OfflineRecognizer(
|
private val recognizer: OfflineRecognizer = OfflineRecognizer(
|
||||||
null,
|
null,
|
||||||
OfflineRecognizerConfig(
|
OfflineRecognizerConfig(
|
||||||
@@ -33,7 +33,7 @@ class WhisperStt(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/** Распознать 16 кГц mono PCM. Возвращает текст. */
|
/** Распознать 16 кГц mono PCM. Возвращает текст. */
|
||||||
fun recognize(samples: FloatArray): String {
|
override fun recognize(samples: FloatArray): String {
|
||||||
val stream = recognizer.createStream()
|
val stream = recognizer.createStream()
|
||||||
try {
|
try {
|
||||||
stream.acceptWaveform(samples, 16000)
|
stream.acceptWaveform(samples, 16000)
|
||||||
|
|||||||
@@ -22,11 +22,20 @@ import pw.binom.viewmate.core.protocol.GlassesToHost
|
|||||||
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.SetMode
|
import pw.binom.viewmate.core.protocol.SetMode
|
||||||
|
import pw.binom.viewmate.core.protocol.StopStt
|
||||||
|
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.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.FakePhraseRecognizer
|
||||||
|
import pw.binom.viewmate.phone.stt.SileroStreamingVad
|
||||||
|
import pw.binom.viewmate.phone.stt.SttStreamer
|
||||||
|
import pw.binom.viewmate.phone.stt.WavPcm
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertIs
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertTrue
|
||||||
import kotlin.test.fail
|
import kotlin.test.fail
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,6 +173,67 @@ class GlassesServerTest {
|
|||||||
assertEquals("0.2.0", hub.appVersion.value)
|
assertEquals("0.2.0", hub.appVersion.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- StopStt → SttCancel/SttDone (TASK-cancel-listen.md) ----------
|
||||||
|
|
||||||
|
/** StopStt(cancel=true) — двойной тап-отмена на очках: очки получают SttCancel(reason=cancel). */
|
||||||
|
@Test
|
||||||
|
fun stopSttCancelSendsSttCancelReasonCancel() = runServer { port, hub ->
|
||||||
|
val client = wsClient()
|
||||||
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
|
send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = true)))
|
||||||
|
val frame = incoming.receive() as Frame.Text
|
||||||
|
val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
||||||
|
val cancel = assertIs<SttCancel>(msg)
|
||||||
|
assertEquals(SttCancel.REASON_CANCEL, cancel.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** StopStt(cancel=false) с пустым текстом — очки возвращаются в «слушаю»: SttCancel(reason=user). */
|
||||||
|
@Test
|
||||||
|
fun stopSttEmptyTextBroadcastsSttCancelReasonUser() = runServer { port, hub ->
|
||||||
|
val client = wsClient()
|
||||||
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
|
send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = false)))
|
||||||
|
val frame = incoming.receive() as Frame.Text
|
||||||
|
val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
||||||
|
val cancel = assertIs<SttCancel>(msg)
|
||||||
|
assertEquals(SttCancel.REASON_USER, cancel.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полная фраза (StopStt(cancel=false) с текстом → SttDone): реальный Silero VAD
|
||||||
|
* (SileroStreamingVad, JVM/ONNX) сегментирует фикстуру, фейк-распознаватель
|
||||||
|
* даёт «привет» на каждый сегмент → очки получают SttDone с непустым текстом.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun stopSttFullTextSendsSttDone() = runServer { port, hub ->
|
||||||
|
hub.sttFactory = {
|
||||||
|
SttStreamer(
|
||||||
|
stt = FakePhraseRecognizer(),
|
||||||
|
vad = SileroStreamingVad(),
|
||||||
|
onPhrase = { _, _ -> },
|
||||||
|
onSilence30s = { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val pcm = WavPcm.loadPcm(WavPcm.fixture().absolutePath)
|
||||||
|
val client = wsClient()
|
||||||
|
client.webSocket("ws://127.0.0.1:$port$GLASSES_WS_PATH") {
|
||||||
|
// кидать по 3200 байт (100 мс @ 16 кГц s16le mono)
|
||||||
|
var off = 0
|
||||||
|
while (off < pcm.size) {
|
||||||
|
val end = minOf(off + 3200, pcm.size)
|
||||||
|
send(json.encodeToString(GlassesToHost.serializer(), SttAudio(pcm.copyOfRange(off, end))))
|
||||||
|
off = end
|
||||||
|
}
|
||||||
|
send(json.encodeToString(GlassesToHost.serializer(), StopStt(cancel = false)))
|
||||||
|
val frame = incoming.receive() as Frame.Text
|
||||||
|
val msg = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
||||||
|
val done = assertIs<SttDone>(msg)
|
||||||
|
assertTrue(done.full.isNotBlank())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- помощники ----------
|
// ---------- помощники ----------
|
||||||
|
|
||||||
private inline fun runServer(crossinline block: suspend (port: Int, hub: GlassesHub) -> Unit) {
|
private inline fun runServer(crossinline block: suspend (port: Int, hub: GlassesHub) -> Unit) {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
/** Тестовый [PhraseRecognizer]: любая фраза → "привет" (первая строка списка). */
|
||||||
|
class FakePhraseRecognizer(
|
||||||
|
private val phrases: List<String> = listOf("привет"),
|
||||||
|
) : PhraseRecognizer {
|
||||||
|
|
||||||
|
override fun recognize(samples: FloatArray): String = phrases[0]
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
import pw.binom.voice.vad.VadInstance
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тестовый стриминговый VAD на реальном Silero (JVM, ONNX через [VadInstance]).
|
||||||
|
* Постпроцесс — поведенческий паритет с боевой SherpaStreamingVad:
|
||||||
|
* threshold 0.5; minSilence = minSpeech = 0.25 с = 4000 сэмплов;
|
||||||
|
* maxSpeech = 20 с = 320 000 сэмплов; окно 512 сэмплов.
|
||||||
|
*
|
||||||
|
* ВНИМАНИЕ: `buf` хранит все сэмплы с начала стрима (60 с ≈ 3.9 МБ) —
|
||||||
|
* для тестов допустимо, для боевого кода не предлагать.
|
||||||
|
*/
|
||||||
|
class SileroStreamingVad : StreamingVad {
|
||||||
|
|
||||||
|
private val vad = VadInstance(16000)
|
||||||
|
|
||||||
|
private var inSpeech = false
|
||||||
|
private var segStart = 0
|
||||||
|
private var lastSpeechEnd = 0
|
||||||
|
private var total = 0 // обработано сэмплов
|
||||||
|
private val buf = ArrayList<Float>() // все сэмплы от начала, для среза сегментов
|
||||||
|
private val queue = ArrayDeque<VadSegment>()
|
||||||
|
|
||||||
|
override val windowSizeSamples: Int = 512
|
||||||
|
|
||||||
|
override fun acceptWindow(window: FloatArray) {
|
||||||
|
require(window.size == 512) { "Окно должно быть 512 сэмплов, подано " + window.size }
|
||||||
|
val p = vad.processingWindow(window)
|
||||||
|
val s = total
|
||||||
|
val sEnd = s + 512
|
||||||
|
for (w in window) {
|
||||||
|
buf.add(w)
|
||||||
|
}
|
||||||
|
total = sEnd
|
||||||
|
if (p >= 0.5f) {
|
||||||
|
if (!inSpeech) {
|
||||||
|
inSpeech = true
|
||||||
|
segStart = s
|
||||||
|
}
|
||||||
|
lastSpeechEnd = sEnd
|
||||||
|
// maxSpeech: 20 с — сегмент принудительно закрывается.
|
||||||
|
if (lastSpeechEnd - segStart >= 320_000) {
|
||||||
|
emit()
|
||||||
|
inSpeech = false
|
||||||
|
}
|
||||||
|
} else if (inSpeech && sEnd - lastSpeechEnd >= 4000) {
|
||||||
|
// minSilence: 0.25 с тишины после речи — сегмент готов.
|
||||||
|
if (lastSpeechEnd - segStart >= 4000) {
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
inSpeech = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popSegment(): VadSegment? {
|
||||||
|
if (queue.isEmpty()) return null
|
||||||
|
return queue.removeFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun flush() {
|
||||||
|
if (inSpeech && lastSpeechEnd - segStart >= 4000) {
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
runCatching { vad.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Готовый сегмент: срез из общего буфера [segStart, lastSpeechEnd). */
|
||||||
|
private fun emit() {
|
||||||
|
val len = lastSpeechEnd - segStart
|
||||||
|
val seg = VadSegment(segStart, FloatArray(len) { buf[segStart + it] })
|
||||||
|
queue.addLast(seg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Чистый» тест распознавания: реальный Silero VAD (ONNX через vad-jvm) +
|
||||||
|
* реальная аудио-фикстура voice-samples/masha-anton.wav.
|
||||||
|
* Без pw.binom.viewmate в classpath — только библиотека VAD и тестовый код.
|
||||||
|
*/
|
||||||
|
class SileroVadRealAudioTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun segmentsOnRealAudio() {
|
||||||
|
val vad = SileroStreamingVad()
|
||||||
|
val pcm = WavPcm.loadPcm(WavPcm.fixture().absolutePath)
|
||||||
|
val n = pcm.size / 2 // сэмплов 16 кГц
|
||||||
|
assertTrue(n >= 512, "PCM фикстуры пуст")
|
||||||
|
|
||||||
|
// Весь PCM → окнами по 512 (хвост < 512 дополняем нулями — тишиной).
|
||||||
|
val total = ((n + 511) / 512) * 512 // обработано сэмплов на VAD
|
||||||
|
val segments = mutableListOf<VadSegment>()
|
||||||
|
for (i in 0 until total step 512) {
|
||||||
|
val window = FloatArray(512)
|
||||||
|
for (j in 0 until 512) {
|
||||||
|
val idx = i + j
|
||||||
|
if (idx < n) {
|
||||||
|
val lo = pcm[idx * 2].toInt() and 0xff
|
||||||
|
val hi = pcm[idx * 2 + 1].toInt() and 0xff
|
||||||
|
var s = lo or (hi shl 8)
|
||||||
|
if (s >= 0x8000) s -= 0x10000
|
||||||
|
window[j] = s / 32768f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vad.acceptWindow(window)
|
||||||
|
var seg = vad.popSegment()
|
||||||
|
while (seg != null) {
|
||||||
|
segments.add(seg)
|
||||||
|
seg = vad.popSegment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(segments.size >= 1, "сегментов не найдено (VAD не нашёл речь в 60-секундной фикстуре)")
|
||||||
|
var speechSamples = 0
|
||||||
|
for (seg in segments) {
|
||||||
|
assertTrue(seg.startSample >= 0, "start < 0 в сегменте ${seg.startSample}")
|
||||||
|
assertTrue(
|
||||||
|
seg.startSample + seg.samples.size <= total,
|
||||||
|
"сегмент выходит за конец потока: start=${seg.startSample}, len=${seg.samples.size}, total=$total",
|
||||||
|
)
|
||||||
|
speechSamples += seg.samples.size
|
||||||
|
}
|
||||||
|
assertTrue(
|
||||||
|
speechSamples in 5000..500_000,
|
||||||
|
"суммарная речь ${speechSamples} сэмплов вне ожидаемого [5000..500000] " +
|
||||||
|
"(${speechSamples / 16000.0}s вне 0.3–31с), сегментов=${segments.size}",
|
||||||
|
)
|
||||||
|
|
||||||
|
vad.flush()
|
||||||
|
assertTrue(vad.popSegment() == null, "после flush() остались сегменты")
|
||||||
|
vad.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальный загрузчик RIFF/WAVE для тестов: s16le, 16 кГц, mono.
|
||||||
|
* (Свой, а не WavReader из main — тот отдаёт FloatArray, а нужно как раз
|
||||||
|
* сырые s16le-байты PCM.)
|
||||||
|
*/
|
||||||
|
object WavPcm {
|
||||||
|
|
||||||
|
/** Абсолютный путь к фикстуре voice-samples/masha-anton.wav (корень репо). */
|
||||||
|
fun fixture(): File {
|
||||||
|
// working dir юнит-теста gradle — каталог модуля (app-phone) или корень;
|
||||||
|
// ищем voice-samples/ вверх от user.dir.
|
||||||
|
var dir = File(System.getProperty("user.dir")).absoluteFile
|
||||||
|
while (true) {
|
||||||
|
val cand = File(dir, "voice-samples/masha-anton.wav")
|
||||||
|
if (cand.isFile) return cand
|
||||||
|
dir = dir.parentFile ?: break
|
||||||
|
}
|
||||||
|
return File("voice-samples/masha-anton.wav").absoluteFile
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PCM s16le, 16 кГц, mono. Если формат не тот — fail(). */
|
||||||
|
fun loadPcm(path: String): ByteArray {
|
||||||
|
val f = File(path)
|
||||||
|
check(f.isFile) { "WAV не найден: $path" }
|
||||||
|
val b = f.readBytes()
|
||||||
|
check(b.size > 44) { "WAV: файл короче заголовка" }
|
||||||
|
fun str(off: Int, len: Int) = String(b.copyOfRange(off, off + len))
|
||||||
|
check(str(0, 4) == "RIFF") { "WAV: не RIFF" }
|
||||||
|
check(str(8, 4) == "WAVE") { "WAV: не WAVE" }
|
||||||
|
fun i16(off: Int) = ByteBuffer.wrap(b, off, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt()
|
||||||
|
fun i32(off: Int) = ByteBuffer.wrap(b, off, 4).order(ByteOrder.LITTLE_ENDIAN).int
|
||||||
|
|
||||||
|
var off = 12
|
||||||
|
var audioFormat = 0
|
||||||
|
var channels = 0
|
||||||
|
var sampleRate = 0
|
||||||
|
var bitsPerSample = 0
|
||||||
|
var dataOff = -1
|
||||||
|
var dataLen = 0
|
||||||
|
while (off + 8 <= b.size) {
|
||||||
|
val id = str(off, 4)
|
||||||
|
val len = i32(off + 4)
|
||||||
|
when (id) {
|
||||||
|
"fmt " -> {
|
||||||
|
audioFormat = i16(off + 8)
|
||||||
|
channels = i16(off + 10)
|
||||||
|
sampleRate = i32(off + 12)
|
||||||
|
bitsPerSample = i16(off + 22)
|
||||||
|
}
|
||||||
|
"data" -> {
|
||||||
|
dataOff = off + 8
|
||||||
|
dataLen = len
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
off += 8 + len + (len and 1) // чанки выравниваются по чётным байтам
|
||||||
|
}
|
||||||
|
check(audioFormat == 1) { "WAV: не PCM (format=$audioFormat)" }
|
||||||
|
check(channels == 1) { "WAV: каналов=$channels, ожидался mono" }
|
||||||
|
check(sampleRate == 16000) { "WAV: частота=$sampleRate, ожидалась 16000" }
|
||||||
|
check(bitsPerSample == 16) { "WAV: битов/сэмпл=$bitsPerSample, ожидалось 16" }
|
||||||
|
require(dataOff >= 0) { "WAV: нет чанка data" }
|
||||||
|
val end = minOf(b.size, dataOff + dataLen)
|
||||||
|
return b.copyOfRange(dataOff, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,10 +91,32 @@ data class SttPhrase(val phrase: String, val full: String) : HostToGlasses
|
|||||||
@SerialName("stt_done")
|
@SerialName("stt_done")
|
||||||
data class SttDone(val full: String) : HostToGlasses
|
data class SttDone(val full: String) : HostToGlasses
|
||||||
|
|
||||||
/** Распознавание отменено (двойной клик или 30 с тишины) — текст не выводится. */
|
/**
|
||||||
|
* Распознавание отменено — текст не выводится.
|
||||||
|
* reason="user" — пустой клик без голоса: очки возвращаются в «слушаю»;
|
||||||
|
* reason="cancel" — явная отмена диктовки двойным тапом: очки закрывают микрофон,
|
||||||
|
* listening=false, thinking=false, остаются в режиме (показывают последний ответ);
|
||||||
|
* reason="timeout" — авто-отмена по тишине 30с: очки ВЫХОДЯТ из режима.
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("stt_cancel")
|
@SerialName("stt_cancel")
|
||||||
class SttCancel : HostToGlasses
|
class SttCancel(val reason: String = REASON_USER) : HostToGlasses {
|
||||||
|
companion object {
|
||||||
|
const val REASON_USER = "user"
|
||||||
|
const val REASON_CANCEL = "cancel"
|
||||||
|
const val REASON_TIMEOUT = "timeout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Один элемент истории сессии (только user/assistant — tool/calls очкам не нужны). */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("chat_history_item")
|
||||||
|
data class ChatHistoryItem(val role: String, val content: String)
|
||||||
|
|
||||||
|
/** История сессии для очков (показ всего диалога со скроллом свайпами). */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("chat_history")
|
||||||
|
data class ChatHistoryMsg(val sessionId: String?, val messages: List<ChatHistoryItem>) : HostToGlasses
|
||||||
|
|
||||||
/** Полное выключение приложения на очках (по команде с телефона). */
|
/** Полное выключение приложения на очках (по команде с телефона). */
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -221,5 +221,48 @@ class HostToGlassesTest {
|
|||||||
assertEquals("""{"type":"stt_cancel"}""", text)
|
assertEquals("""{"type":"stt_cancel"}""", text)
|
||||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||||
assertIs<SttCancel>(decoded)
|
assertIs<SttCancel>(decoded)
|
||||||
|
assertEquals("user", decoded.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sttCancelTimeoutRoundTrip() {
|
||||||
|
val msg: HostToGlasses = SttCancel(reason = "timeout")
|
||||||
|
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||||
|
assertEquals("""{"type":"stt_cancel","reason":"timeout"}""", text)
|
||||||
|
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||||
|
assertIs<SttCancel>(decoded)
|
||||||
|
assertEquals("timeout", decoded.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sttCancelBackwardCompatibleDefault() {
|
||||||
|
// Старое сообщение без поля reason десериализуется как "user".
|
||||||
|
val decoded = json.decodeFromString(HostToGlasses.serializer(), """{"type":"stt_cancel"}""")
|
||||||
|
assertIs<SttCancel>(decoded)
|
||||||
|
assertEquals("user", decoded.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chatHistoryRoundTrip() {
|
||||||
|
val msg: HostToGlasses = ChatHistoryMsg(
|
||||||
|
sessionId = "s-1",
|
||||||
|
messages = listOf(
|
||||||
|
ChatHistoryItem(role = "user", content = "привет"),
|
||||||
|
ChatHistoryItem(role = "assistant", content = "здравствуй"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||||
|
assertEquals(
|
||||||
|
"""{"type":"chat_history","sessionId":"s-1","messages":[""" +
|
||||||
|
"""{"role":"user","content":"привет"},""" +
|
||||||
|
"""{"role":"assistant","content":"здравствуй"}]}""",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||||
|
assertIs<ChatHistoryMsg>(decoded)
|
||||||
|
assertEquals(2, decoded.messages.size)
|
||||||
|
assertEquals("user", decoded.messages[0].role)
|
||||||
|
assertEquals("привет", decoded.messages[0].content)
|
||||||
|
assertEquals("assistant", decoded.messages[1].role)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user