27f349550c
- sketches/index.html: все экраны одним документом в порядке 1·… (14–19 — бывшие approved) - approved/ (4 файла групп + 3 макета + README) удалён как дубль - стили вынесены в sketches/style.css - README, THEMES, BORROW-FROM-ASSISTENT, MARKDOWN-SOURCE, MIC-ASR-SEARCH приведены к фактическому состоянию
256 lines
15 KiB
Markdown
256 lines
15 KiB
Markdown
# Микрофон и распознавание: результаты поиска
|
||
|
||
> **Статус: реализовано** (см. `src/jvmMain/.../desktop/voice/`).
|
||
> `mic-api` + `asr-vosk` + `vad` подключены в `gradle/libs.versions.toml`,
|
||
> кнопка микрофона в чате активна: `VoiceController` (микрофон → VAD →
|
||
> распознавание), `VoskVoiceRecognizer` (Vosk-модель и `silero_vad.onnx` лежат
|
||
> внутри артефактов — качать нечего). Ниже — исходный поиск, из которого это
|
||
> выросло.
|
||
|
||
**Задача была:** найти готовые библиотеки для работы с микрофоном и распознавания,
|
||
прежде чем брать код из `ai/assistent`. **Код не пишем** — только поиск.
|
||
|
||
**Ответ короткий:** и то, и другое **уже есть у нас, готовое и опубликованное**.
|
||
Из `ai/assistent` брать код микрофона **не нужно вообще**.
|
||
|
||
---
|
||
|
||
## 1. Микрофон: библиотека есть — `mic-kmp`
|
||
|
||
**Репозиторий:** https://git.binom.pw/subochev/mic-kmp
|
||
**Каталог:** https://git.binom.pw/subochev/libraries (раздел «📦 Микрофон»)
|
||
|
||
KMP-библиотека: поток микрофона как `Flow<ByteArray>` (s16le mono PCM).
|
||
Три файла, вот и вся библиотека:
|
||
|
||
| Файл | Что делает |
|
||
|---|---|
|
||
| `mic-api/src/commonMain/.../MicAudio.kt` | интерфейс `MicDevice` + объект `MicAudio.record(...)` |
|
||
| `mic-api/src/jvmMain/.../MicJvm.kt` | JVM — `javax.sound.sampled` (`TargetDataLine`) |
|
||
| `mic-api/src/androidMain/.../MicAndroid.kt` | Android — `AudioRecord` |
|
||
|
||
**Таргеты: `jvm` и `android`.** JVM-таргет — ровно то, что нужно десктопу.
|
||
JVM-код собран на `JvmTarget.JVM_17`.
|
||
|
||
**Опубликована в Nexus `caffeine`, версия `1.0.0`:**
|
||
- `pw.binom.mic:mic-api:1.0.0` (KMP metadata)
|
||
- `pw.binom.mic:mic-api-jvm:1.0.0` (JVM — **наше**)
|
||
- `pw.binom.mic:mic-api-android:1.0.0`
|
||
|
||
Проверено: `maven-metadata.xml` отдаёт `HTTP 200`, latest = `1.0.0`.
|
||
|
||
**Использование — одна строка:**
|
||
|
||
```kotlin
|
||
MicAudio.record(sampleRate = 16000, channels = 1, chunkMillis = 100)
|
||
.onEach { chunk: ByteArray -> /* s16le mono PCM, ~100 мс */ }
|
||
```
|
||
|
||
**Чем лучше кода из assistent:** отмена **структурная**. Остановил корутину-коллектор —
|
||
микрофон освободился сам, в `finally`. Не надо помнить про `close()`. В assistent
|
||
(`MicrophoneRecorder`, 83 строки) — ручное управление через `start()`/`stop()`,
|
||
и связка «канал + флаг recording + внешний TargetDataLine». Наш вариант чище.
|
||
|
||
**Вывод: копировать `MicrophoneRecorder` из assistent НЕ надо — подключить `mic-kmp`.**
|
||
|
||
---
|
||
|
||
## 2. Распознавание: библиотека есть — `asr-kmp`
|
||
|
||
**Репозиторий:** https://git.binom.pw/subochev/asr-kmp
|
||
**Каталог:** https://git.binom.pw/subochev/libraries (раздел «🎙 ASR»)
|
||
|
||
KMP, таргеты **jvm** и **android** (плюс native: linux, macos, mingw).
|
||
Потоковый интерфейс — приём аудио кусками и **промежуточный текст по ходу**:
|
||
|
||
```kotlin
|
||
interface Stt : AutoCloseable {
|
||
fun feed(samples: FloatArray) // порция 16 кГц моно PCM, [-1, 1]
|
||
fun partialText(): String? // промежуточный текст (растёт по ходу)
|
||
fun endOfInput() // конец ввода
|
||
fun finalText(): String // итог
|
||
override fun close()
|
||
}
|
||
```
|
||
|
||
**Есть `partialText` — текст появляется по мере речи**, а не после конца записи.
|
||
Для нашей кнопки микрофона это именно то поведение, которое нужно.
|
||
|
||
### Движки (все опубликованы в `caffeine`)
|
||
|
||
| Движок | Координаты (группа `pw.binom.asr`) | Версии в Nexus |
|
||
|---|---|---|
|
||
| `asr-api` — интерфейс | `asr-api` / `-jvm` / `-android` | до `4` |
|
||
| `asr-vosk` — Vosk | `asr-vosk` / `-jvm` / `-android` | есть |
|
||
| `asr-qwen3` — Qwen3-ASR | `asr-qwen3` / `-jvm` | есть |
|
||
| `asr-whisper` — Whisper | `asr-whisper` / `-jvm` / `-android` | есть |
|
||
| `asr-openai` — удалённое (OpenAI-совместимое) | `asr-openai` | есть |
|
||
|
||
Под капотом нейронок — `sherpa-onnx` (`com.github.k2-fsa.sherpa-onnx`, `v1.13.6`),
|
||
для JVM отдельно тянется нативная библиотека `sherpa-onnx-native-lib-linux-x64`.
|
||
|
||
### Про модели (важно)
|
||
|
||
- **Qwen3-ASR** — модель уже на нашем статик-сервере, качать из-за бугра не надо:
|
||
`http://static.binom.pw/models/qwen3_06/` (~940 МБ: encoder 175M, decoder 721M, frontend 43M).
|
||
- **Whisper small int8** — модели на статике НЕТ, качать самостоятельно.
|
||
- **Vosk** (`vosk-model-small-ru-0.22`) — **входит в комплект**, распаковывается сам.
|
||
Самый простой путь для первого запуска: ничего качать не надо.
|
||
|
||
---
|
||
|
||
## 3. Рядом есть ещё три полезные библиотеки
|
||
|
||
Из того же каталога `subochev/libraries`:
|
||
|
||
### VAD — детектор речи, `vad-kmp`
|
||
https://git.binom.pw/subochev/vad-kmp — «VAD — Silero», KMP jvm+android,
|
||
**модель внутри артефакта** (2.3 МБ, ничего качать не надо).
|
||
|
||
```kotlin
|
||
VadInstance(16000).use { vad ->
|
||
val prob = vad.processingWindow(windowPcmFloats) // FloatArray → [0..1]
|
||
}
|
||
```
|
||
Координаты: `pw.binom.voice.vad:vad-jvm`, версия `2`.
|
||
**Зачем:** определять, говорит человек или молчит — не гнать тишину в распознавание.
|
||
|
||
### Есть и второй VAD
|
||
`pw.binom.ai.voice:vad` — «детектор речи (VAD) — `vad` / `vad-jvm`». Два разных VAD,
|
||
при выборе разобраться, какой свежее.
|
||
|
||
### OpenAI-клиент
|
||
`pw.binom.openai:api` / `api-jvm` + `ktor-impl` (HTTP-движок). Нужен для `asr-openai`
|
||
(распознавание на удалённом сервере, а не локальной нейронкой).
|
||
|
||
### Speaker-эмбеддинги и text-embedding
|
||
- `subochev/embedding-kmp` — voice embedding (кто говорит).
|
||
- `subochev/text-embedding-kmp` — text embedding (SigLIP2).
|
||
Нам сейчас не нужно, но пусть будет известно.
|
||
|
||
---
|
||
|
||
## 4. Как это собрано вместе — пример `view-mate`
|
||
|
||
**Репо:** `/root/WORK/view-mate` (очки RayNeo X2), модули `app-glasses`, `app-phone`, `app-host`, `lib-core`.
|
||
|
||
**Важно:** view-mate **не подключает** `mic-kmp`/`asr-kmp` как библиотеки — у него
|
||
своя реализация `SttMicStream` на `android.media.AudioRecord`
|
||
(`app-glasses/.../stt/SttMicStream.kt`). То есть `mic-kmp` — это **вынесенная
|
||
и обобщённая** версия того же подхода.
|
||
|
||
Из полезного в `SttMicStream.kt` — комментарий на строке 229:
|
||
`AudioRecord.read(byte[], int, inFrameCount)` возвращает **число кадров**, а не байт.
|
||
Классические грабли Android-записи.
|
||
|
||
---
|
||
|
||
## 5. Чем пользуется сам `ai/assistent`
|
||
|
||
Важно для понимания разницы подходов:
|
||
|
||
`chat-server/.../stt/SttClientImpl.kt` — это **не** локальное распознавание.
|
||
Это HTTP-клиент к OpenAI-совместимому API:
|
||
|
||
```kotlin
|
||
client.post("$base/audio/transcriptions") { ... } // multipart, как у OpenAI
|
||
```
|
||
|
||
Настройки через переменные окружения:
|
||
- `STT_API_KEY` — без него голосовой адаптер выключен;
|
||
- `STT_URL` — по умолчанию `https://api.openai.com/v1`;
|
||
- `STT_MODEL` — по умолчанию `whisper-1`.
|
||
|
||
**Вывод:** у assistent распознавание — **отправка файла на сервер**. У нас
|
||
`asr-kmp` умеет и локальные нейронки (Qwen3, Whisper, Vosk), и удалённое (`asr-openai`).
|
||
Наш вариант шире, а `mic-kmp` (поток) + `asr-kmp` (поток) + `vad-kmp` (детектор речи)
|
||
складываются в готовую цепочку: **микрофон → детектор речи → распознавание → текст**.
|
||
|
||
---
|
||
|
||
## 6. Итог: что делать
|
||
|
||
| Вопрос | Ответ |
|
||
|---|---|
|
||
| Та отдельная библиотечка для микрофона, которую мы делали — существует? | **Да.** `mic-kmp`, опубликована в `caffeine` версии `1.0.0`, jvm-таргет есть. |
|
||
| Библиотечки распознавания с нейронками где? | **`asr-kmp`** — Qwen3, Whisper, Vosk, удалённое. Всё в `caffeine`. Плюс каталог: `subochev/libraries`. |
|
||
| Есть ли где-то микрофон в каталоге библиотек? | **Да**, первым разделом — «📦 Микрофон». |
|
||
| Брать ли код микрофона из `ai/assistent`? | **Нет.** Подключаем `mic-kmp` — чище, с автоосвобождением, без внешних библиотек. |
|
||
| Нужны ли сторонние зависимости для звука? | **Нет.** Всё на стандартной Java (`javax.sound.sampled`) и sherpa-onnx для нейронок. |
|
||
|
||
**Код не писан** — только поиск, как и просили.
|
||
|
||
---
|
||
|
||
## 7. Что в итоге подключено
|
||
|
||
| Слой | Артефакт | Где в коде |
|
||
|---|---|---|
|
||
| Микрофон | `pw.binom.mic:mic-api:1.0.0` | `voice/VoiceController.kt` (`MicSource` → `MicAudio.record`) |
|
||
| Детектор речи | `pw.binom.voice.vad:vad:2` | `voice/VoiceRecognizer.kt` (`VadInstance`) |
|
||
| Распознавание | `pw.binom.asr:asr-vosk:4` (+ `asr-api:4`) | `voice/VoiceRecognizer.kt` (`VoskVoiceRecognizer`) |
|
||
| Сборка тракта | — | `voice/VoiceController.kt` (`VoiceState`: IDLE/PREPARING/RECORDING/RECOGNIZING) |
|
||
| Склейка с UI | — | `ui/AppState.kt` (`voiceState`, `voicePartial`, `composerDraft`), `ui/screens/ChatPane.kt` |
|
||
|
||
| Прогрев до skia | — | `voice/VoiceNative.kt` (`warmUp()` из `main()`) |
|
||
|
||
Тесты: `voice/AudioTest.kt` (s16le→float, нарезка окон VAD + реальная загрузка
|
||
`silero_vad.onnx`), `voice/VoiceControllerTest.kt` (фейковые микрофон и
|
||
распознаватель), `voice/VoskVoiceRecognizerIT.kt` (настоящие Vosk + VAD на
|
||
тишине), `voice/RealSpeechIT.kt` (настоящая русская речь после инициализации
|
||
skia — см. ниже).
|
||
|
||
---
|
||
|
||
## 8. ⚠️ Конфликт skiko и нативных библиотек (найдено 28.09.2026)
|
||
|
||
**Симптом.** Приложение Compose Desktop роняется с SIGSEGV (не исключение —
|
||
падение всего JVM) при первом нажатии микрофона. В `hs_err_pid`:
|
||
|
||
```
|
||
C [libc.so.6+0x184fdc]
|
||
C [libstdc++.so.6+0x15d820] std::ostream::_M_insert<double>
|
||
C [jnaNNNN.tmp+0x6838e7] kaldi::ParseOptions::RegisterSpecific(...)
|
||
j org.vosk.LibVosk.vosk_model_new(...)
|
||
```
|
||
|
||
То же самое — с ONNX (`OrtSession.createSession` → `std::ostream::_M_insert`):
|
||
C++-библиотека пишет в свой лог-стрим, а стрим оказывается невалидным.
|
||
|
||
**Причина — порядок загрузки.** skiko (нативный рендер Compose, грузит
|
||
`~/.skiko/<hash>/libskiko-linux-x64.so`) и C++-стек распознавания (Vosk/Kaldi,
|
||
onnxruntime) конфликтуют, если второй инициализируется ПОСЛЕ первого. Первое
|
||
создание модели/сессии после инициализации skia валит JVM.
|
||
|
||
**Проверено пробниками (позже удалены):**
|
||
|
||
| Порядок | Результат |
|
||
|---|---|
|
||
| ONNX → skia (raster) → ONNX | ок |
|
||
| skia (Compose-текст) → ONNX | **SIGSEGV** |
|
||
| skia (Compose-текст) → Vosk | **SIGSEGV** |
|
||
| **Vosk → skia (Compose-текст) → Vosk** | **ок** |
|
||
|
||
**Фикс.** `VoiceNative.warmUp()` вызывается в `main()` **до** `application {}`:
|
||
создаёт и закрывает `Stt.vosk(...)` и `VadInstance(...)`. После этого и свежий
|
||
распознаватель, и свежий VAD, созданные уже после отрисовки окна, работают.
|
||
Побочный плюс — модель Vosk (~46 МБ, ~2 с) грузится на старте, а не при первом
|
||
клике. Если прогрев не удался, `AppState.voiceAvailable = false` — кнопка
|
||
микрофона заблокирована (лучше «недоступно», чем краш).
|
||
|
||
**Следствие для тестов.** `jvmTest` форкает JVM на каждый тест-класс
|
||
(`forkEvery = 1`): иначе skiko-тесты (`ThemeRenderTest`) отравляют
|
||
нативные тесты голоса в том же JVM. `RealSpeechIT` проверяет ровно
|
||
продуктовый порядок: `warmUp()` → рендер Compose → создание распознавателя →
|
||
распознавание реального сэмпла (`src/jvmTest/resources/voice/raz-dva-tri.wav`
|
||
→ «раз два три четыре пять»).
|
||
|
||
Заметки на будущее:
|
||
|
||
- Создание распознавателя стоит секунды (загрузка модели Vosk), поэтому
|
||
распаковка zip в tmp кэшируется самим `asr-vosk`, а в UI есть состояние
|
||
«загружаю модель».
|
||
- `VadInstance.close()` **не идемпотентен** (второй `close()` роняет JVM через
|
||
ONNX `double free`) — закрывать ровно один раз.
|
||
|