Compare commits
4 Commits
40edd88c1d
...
d674b31a81
| Author | SHA1 | Date | |
|---|---|---|---|
| d674b31a81 | |||
| c222a4ac76 | |||
| e21c27b0ee | |||
| 0d79b5aa33 |
@@ -0,0 +1,51 @@
|
||||
# TASK-full-buffer-stt.md — полный текст по клику: распознавание всего буфера (не VAD-сегментов)
|
||||
|
||||
## Симптом (24.08, подтверждено дампами)
|
||||
|
||||
Пользователь: TripleClick → говорит → Click → «думаю...» → сразу возврат в «слушаю». Полный текст пуст (`stt ВЕСЬ ТЕКСТ: ` пусто в логах телефона).
|
||||
|
||||
## Диагноз (по дампам голоса пользователя)
|
||||
|
||||
1. Микрофон RayNeo X2 пишет РВАНО: пачки 50–100мс с провалами до нуля (в дампе 213110, 16.7с — 66% тишины). Это железо/драйвер очков, не транспорт (очки→телефон совпадают 98%).
|
||||
2. Silero VAD (тот же `silero_vad.onnx` с телефона, прогнан на хосте) на усиленных дампах нарезает речь на 10+ сегментов по 0.3–0.4с (213110: 12 сегментов).
|
||||
3. Whisper (faster-whisper-base на хосте) на ПОЛНОМ дампе распознаёт «Ого! | А что ты делаешь?» (212441, 5.9с) и «...грека...» (213110) — НЕ пусто. На отдельных VAD-сегментах 0.3–0.4с whisper возвращает пусто/мусор.
|
||||
4. Вывод: **кликовый режим не должен зависеть от VAD-сегментации.** Начало/конец фразы задаёт пользователь кликами; полный текст = распознавание ВСЕГО накопленного PCM одним вызовом whisper. VAD остаётся только для промежуточных фраз на очки (не трогать).
|
||||
|
||||
## Задача
|
||||
|
||||
`app-phone` `SttStreamer.kt`:
|
||||
|
||||
1. **Буфер всей сессии**: в `accept(pcm)` накапливать принятые сэмплы (FloatArray) в поле `sessionPcm` (сбрасывается в `resetLocked()`). Не терять при VAD-обработке (VAD-путь остаётся как есть, параллельно).
|
||||
2. **`finish()`**: распознать полный буфер одним вызовом whisper:
|
||||
- `sessionPcm` целиком, если ≤ 30с (16000*30 сэмплов);
|
||||
- если длиннее — последние 30с (хвост: `sessionPcm.takeLast(16000*30)`), чтобы команда пользователя (сказанная перед кликом) точно попала;
|
||||
- результат: `stt.recognize(chunk)` — один вызов, текст в `full` (перезаписать, не конкатенировать с VAD-фразами).
|
||||
- `finish()` по-прежнему ждёт `lastFuture` (VAD-распознавания в executor), затем выполняет полный прогон.
|
||||
3. **`resetLocked()`**: очищать `sessionPcm`.
|
||||
4. Логи: при распознавании полного буфера `log("stt", "полный буфер: ${N} сэмплов (${dur}с) → '${text}'")`.
|
||||
|
||||
НЕ трогать: охраняемые 4 файла (`HostToGlasses.kt`, `GlassesServer.kt`, `HostConnection.kt`, `MainActivity.kt`), протокол `lib-core`, `app-glasses`, `WhisperStt`, `StreamingVad`/`SherpaStreamingVad`, VAD-путь и `onPhrase` (промежуточные фразы).
|
||||
|
||||
## Точки правки
|
||||
|
||||
- `app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt` — буфер + finish() + resetLocked().
|
||||
- Тесты: новый `app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FullBufferSttTest.kt` (JVM, фейковый `PhraseRecognizer`, `FakeVad` как в существующих тестах — см. `stopSttFullTextSendsSttDone` фикстуру).
|
||||
|
||||
## Тесты (JVM)
|
||||
|
||||
1. `accept` 3 чанка по 3200Б (9600Б = 0.3с) → `finish()` → фейковый recognizer получил ОДИН вызов с полным буфером (9600 байт → 4800 сэмплов), `full` = текст фейка.
|
||||
2. Длинный буфер > 30с: `accept` 40с → `finish()` → recognizer получил ровно 16000*30 сэмплов (хвост), текст не пуст.
|
||||
3. `reset()` → `finish()` → recognizer НЕ вызывался, `full` пуст.
|
||||
4. VAD-путь не сломан: `FakeVad` с одним сегментом → `onPhrase` по-прежнему вызывается (промежуточная фраза бродкастится), но `full` после `finish()` = результат полного буфера.
|
||||
|
||||
## Проверка
|
||||
|
||||
1. `./gradlew :app-phone:testDebugUnitTest` — зелёные (было 158 + новые).
|
||||
2. Полная: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug :lib-core:jvmTest`.
|
||||
3. Коммит: `stt(phone): полный текст по клику — whisper на всём буфере, VAD только для промежуточных фраз`. Push НЕ делать.
|
||||
|
||||
## Приёмка на железе (Hermes + пользователь)
|
||||
|
||||
- Установить оба APK, force-stop, `am start`.
|
||||
- Пользователь: TripleClick → фраза («поставь спокойную музыку») → Click.
|
||||
- Ожидание: `stt ВЕСЬ ТЕКСТ: поставь спокойную музыку` (не пусто) → LLM → ответ на очки.
|
||||
@@ -0,0 +1,82 @@
|
||||
# TASK-mic-gain.md — усиление микрофона очков (фикс «пустой фразы»)
|
||||
|
||||
## Симптом (пользователь, 24.08 вечер)
|
||||
|
||||
Очки: TripleClick → «слушаю» → пользователь говорит → Click («отправить фразу») → очки показывают «думаю...» и СРАЗУ возвращаются в «слушаю». Ответа нет. Впечатление: «процесс говорения сломан».
|
||||
|
||||
## Диагноз (подтверждён дампами и логами 24.08)
|
||||
|
||||
Цепь: очки `SttMicStream` (AudioRecord MIC 16 кГц s16 mono) → BT SPP → телефон `SttStreamer` (Silero VAD → sherpa whisper-small) → `StopStt(cancel=false)` → `GlassesServer` → `full.isBlank()` → `SttCancel(REASON_USER)` → очки возвращаются в «слушаю».
|
||||
|
||||
Факты:
|
||||
1. **Жесты/протокол/цикл здоровы** — симуляция `DEBUG_GESTURE` (triple_click → click) воспроизводит симптом, очки логируют `Click → отправить фразу`, `stt отменено — возврат в «слушаю» (SttCancel)`. Это штатное поведение для ПУСТОЙ фразы.
|
||||
2. **Микрофон RayNeo X2 пишет сигнал пиком 1–9% шкалы** (пики 181–2966 из 32768; RMS 9–184). Дампы `stt_dump_1930*.wav` (19:30, голос пользователя): после нормализации faster-whisper-base распознаёт «тихо тихо», «спокойная музыка» — РЕЧЬ есть, но амплитуда ничтожна.
|
||||
3. **Транспорт очки→телефон чист** (analyze.py: порядок окон 98%, лаг 0, объём совпадает) — телефон получает тот же тихий PCM.
|
||||
4. **STT-пайплайн здоров** — на эмуляторе (WAV нормального уровня) распознавание работает; на амплитудах ~0.005–0.09 Silero VAD/whisper-small не видят речи → `full` пуст → пустой клик → возврат в «слушаю».
|
||||
5. Вчерашний фикс кадров/байтов (`c940a7e`+`fc84ba1`+`40edd88`) правильный и НЕ трогается — он убрал «через один», но уровень микрофона не поднял.
|
||||
|
||||
**Корень: на пути аудио очки→телефон нет усиления. Микрофон тихий, VAD/Whisper не видят речь.**
|
||||
|
||||
## Задача
|
||||
|
||||
Добавить **фиксированное цифровое усиление** в `app-glasses` `SttMicStream.kt`, в живой ветке микрофона: чанк после `micChunkBytes(buf, frames)` усилить в `MIC_GAIN` раз (с clamp), и уже усиленный чанк отправлять `onChunk(...)` и писать в дамп.
|
||||
|
||||
- Точка правки: `app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/SttMicStream.kt`
|
||||
- Тесты: `app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/stt/MicChunkTest.kt` (существующий файл, добавить тесты)
|
||||
- НЕ трогать: `HostToGlasses.kt`, `GlassesServer.kt`, `HostConnection.kt`, `MainActivity.kt` (охраняемые 4 файла фикса `7557d72`), телефонную сторону (`app-phone`), протокол (`lib-core`), `WavWriter.kt`, существующую функцию `micChunkBytes` и её тесты.
|
||||
- `SttMicStream.kt` писать можно (не в охране).
|
||||
|
||||
## Реализация
|
||||
|
||||
В `SttMicStream.kt`, рядом с `micChunkBytes`:
|
||||
|
||||
```kotlin
|
||||
/** Коэффициент усиления микрофона (микрофон RayNeo X2 пишет пик 1–9% шкалы). */
|
||||
internal const val MIC_GAIN = 10f
|
||||
|
||||
/**
|
||||
* Цифровое усиление чанка s16le mono с насыщением.
|
||||
* Каждый сэмпл: v = (s * gain).roundToInt().coerceIn(-32768, 32767).
|
||||
* Размер чанка не меняется (3200 байт = 1600 сэмплов).
|
||||
*/
|
||||
internal fun gainChunk(bytes: ByteArray, gain: Float = MIC_GAIN): ByteArray
|
||||
```
|
||||
|
||||
Разбор s16le: младший байт + (старший << 8), знак как в `s16leToFloat` (app-phone) — `if (s >= 0x8000) s -= 0x10000`.
|
||||
|
||||
В цикле записи (живая ветка, после фикса `40edd88`):
|
||||
|
||||
```kotlin
|
||||
val frames = recorder.read(buf, 0, CHUNK_BYTES / 2)
|
||||
if (frames > 0) {
|
||||
val raw = micChunkBytes(buf, frames)
|
||||
val chunk = gainChunk(raw) // ← усиление здесь
|
||||
if (dumpWriter != null) runCatching { dumpWriter.write(chunk) }.onFailure { ... }
|
||||
onChunk(chunk)
|
||||
}
|
||||
```
|
||||
|
||||
**ВАЖНО: дамп (`WavWriter`) пишет УСИЛЕННЫЙ чанк** (то же, что уходит на телефон) — чтобы дампы очков отражали то, что реально получает STT. (Решение принято: диагностика по дампам очков = тому, что видит телефон; сырой микрофон больше не пишем.)
|
||||
|
||||
Обоснование gain = 10: лучший утренний пик речи 2966 (9.05%) → 29660 (90.5%); тихая фраза 483 (1.5%) → 4830 (14.7%) — уже видно VAD. При крике возможен клиппинг — приемлемо (лучше клиппинг, чем пустота). Если на железе VAD захочет ложно срабатывать — константу подстроим.
|
||||
|
||||
## Тесты (добавить в MicChunkTest.kt)
|
||||
|
||||
1. `gainChunk` тихого чанка (сэмплы ±1000): пик результата ≥ 9000, размер = 3200 байт.
|
||||
2. `gainChunk` громкого чанка (сэмплы ±30000): ни один сэмпл не выходит за ±32767 (clamp работает).
|
||||
3. `gainChunk` нулевого чанка: байты без изменений.
|
||||
4. `gainChunk` с gain = 1f: байты побайтово идентичны входу (для gain=1 не аллоцировать заново — вернуть `bytes`).
|
||||
5. Сохранение порядка: `gainChunk` не перемешивает сэмплы (знак/порядок сохраняется — проверка первых 2 сэмплов).
|
||||
|
||||
## Проверка
|
||||
|
||||
1. `./gradlew :app-glasses:testDebugUnitTest` — зелёные (было 95, станет 95 + новые).
|
||||
2. Полная сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug :lib-core:jvmTest`.
|
||||
3. Коммит отдельный: `stt(mic): усиление микрофона очков ×10 — VAD/Whisper видят речь (пустая фраза)`. **push НЕ делать** — приёмка на железе.
|
||||
|
||||
## Приёмка на железе (делает Hermes + пользователь, НЕ агент)
|
||||
|
||||
- Установить оба APK (очки + телефон) с этого коммита, force-stop, `am start`.
|
||||
- Маркеры дампов `stt_dump.marker` уже лежат на обоих устройствах — дампы пишутся.
|
||||
- Пользователь: TripleClick → говорит «поставь спокойную музыку» → Click.
|
||||
- Hermes снимает: дампы очков (пик речи должен стать ~50–90%), `stt_dump_phone.raw` (телефон получил усиленный PCM), logcat: `SttDone` с непустым текстом → LLM-запрос → ответ на очки.
|
||||
@@ -0,0 +1,62 @@
|
||||
# TASK-wav-header.md — починить размеры в WAV-заголовке (big-endian → little-endian)
|
||||
|
||||
## Симптом
|
||||
|
||||
Дампы `stt_dump_*.wav` (app-glasses WavWriter) не открываются в плеерах. Проверка hexdump:
|
||||
- `RIFF 00 02 e1 a4` — размер записан big-endian (Java `RandomAccessFile.writeInt`), а WAV требует little-endian.
|
||||
- Правильно: `a4 e1 02 00` для файла 188844 байт (data 188800).
|
||||
|
||||
## Точка правки
|
||||
|
||||
`app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/stt/WavWriter.kt`, метод `close()`:
|
||||
```kotlin
|
||||
runCatching {
|
||||
RandomAccessFile(path, "rw").use { raf ->
|
||||
raf.seek(4)
|
||||
raf.writeInt((36 + dataBytes).toInt()) // RIFF size — BIG-ENDIAN, БАГ
|
||||
raf.seek(40)
|
||||
raf.writeInt(dataBytes.toInt()) // data size — BIG-ENDIAN, БАГ
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`headerBytes()` уже использует `ByteBuffer.order(LITTLE_ENDIAN)` — там всё правильно (placeholder -1 одинаков в любом порядке, поэтому баг не виден до close()).
|
||||
|
||||
## Фикс
|
||||
|
||||
Писать размеры little-endian. Минимальная правка без новых зависимостей:
|
||||
```kotlin
|
||||
runCatching {
|
||||
RandomAccessFile(path, "rw").use { raf ->
|
||||
raf.seek(4)
|
||||
raf.write(leInt(36 + dataBytes))
|
||||
raf.seek(40)
|
||||
raf.write(leInt(dataBytes))
|
||||
}
|
||||
}
|
||||
```
|
||||
с helper рядом:
|
||||
```kotlin
|
||||
/** 4 байта little-endian (WAV-заголовок), в отличие от RandomAccessFile.writeInt (big-endian). */
|
||||
private fun leInt(v: Long): ByteArray =
|
||||
ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(v.toInt()).array()
|
||||
```
|
||||
(`ByteBuffer`/`ByteOrder` уже импортированы в файле.)
|
||||
|
||||
НЕ трогать: остальное в WavWriter (write/headerBytes), SttMicStream, охраняемые 4 файла, протокол.
|
||||
|
||||
## Тесты
|
||||
|
||||
В `app-glasses/src/test/.../stt/` (рядом с MicChunkTest) — новый `WavWriterTest`:
|
||||
1. Записать 3200 байт через WavWriter во временный файл, close(), прочитать заголовок:
|
||||
- байты 4..8 (RIFF size) == 36 + 3200 (little-endian, сравнить с ожидаемым ByteBuffer);
|
||||
- байты 40..44 (data size) == 3200 (little-endian);
|
||||
- python-wave-эквивалент: nframes == 1600 (можно проверить в JVM: прочитать заголовок вручную).
|
||||
2. Пустой файл (0 байт данных) — RIFF size == 36, data size == 0.
|
||||
3. Закрытие дважды (close два раза) — размеры не портятся (closed-флаг).
|
||||
|
||||
## Проверка
|
||||
|
||||
1. `./gradlew :app-glasses:testDebugUnitTest` — зелёные (старые + новые).
|
||||
2. `./gradlew :app-glasses:assembleDebug` — собирается.
|
||||
3. Коммит: `stt(dump): WAV-заголовок — размеры little-endian (плееры открывают дампы)`. Push НЕ делать.
|
||||
@@ -7,6 +7,7 @@ import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -29,7 +30,7 @@ class SttMicStream(
|
||||
) {
|
||||
private var job: Job? = null
|
||||
|
||||
/** Debug-дампер сырого микрофона (открывается по маркеру stt_dump.marker). */
|
||||
/** Debug-дампер микрофона: пишет УСИЛЕННЫЕ чанки — те же, что уходят на телефон (по маркеру stt_dump.marker). */
|
||||
private var dump: WavWriter? = null
|
||||
|
||||
fun start(filesDir: File, shouldRun: () -> Boolean) {
|
||||
@@ -62,7 +63,8 @@ class SttMicStream(
|
||||
while (isActive && shouldRun()) {
|
||||
val frames = recorder.read(buf, 0, CHUNK_BYTES / 2) // inFrameCount — КАДРЫ
|
||||
if (frames > 0) {
|
||||
val chunk = micChunkBytes(buf, frames)
|
||||
val raw = micChunkBytes(buf, frames)
|
||||
val chunk = gainChunk(raw) // усиление: в дамп и на телефон — один и тот же чанк
|
||||
if (dumpWriter != null) {
|
||||
runCatching { dumpWriter.write(chunk) }
|
||||
.onFailure { log("stt", "dump микрофона: ${it.message}") }
|
||||
@@ -91,7 +93,7 @@ class SttMicStream(
|
||||
|
||||
/**
|
||||
* Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker,
|
||||
* открыть stt_dump_HHmmss.wav — ВСЕ чанки живого микрофона допишутся туда
|
||||
* открыть stt_dump_HHmmss.wav — ВСЕ усиленные чанки живого микрофона допишутся туда
|
||||
* до отправки в onChunk (эмуляторную ветку не трогаем).
|
||||
*/
|
||||
private fun openLiveMicDump(filesDir: File): WavWriter? {
|
||||
@@ -152,6 +154,33 @@ internal fun micChunkBytes(buf: ByteArray, frames: Int): ByteArray {
|
||||
return if (bytes == buf.size) buf else buf.copyOf(bytes)
|
||||
}
|
||||
|
||||
/** Коэффициент усиления микрофона (микрофон RayNeo X2 пишет пик 1–9% шкалы). */
|
||||
internal const val MIC_GAIN = 10f
|
||||
|
||||
/**
|
||||
* Цифровое усиление чанка s16le mono с насыщением.
|
||||
* Каждый сэмпл: v = (s * gain).roundToInt().coerceIn(-32768, 32767).
|
||||
* Размер чанка не меняется (3200 байт = 1600 сэмплов).
|
||||
* Для gain = 1 не аллоцируем заново — возвращаем исходный массив.
|
||||
*/
|
||||
internal fun gainChunk(bytes: ByteArray, gain: Float = MIC_GAIN): ByteArray {
|
||||
if (gain == 1f) return bytes
|
||||
val out = ByteArray(bytes.size)
|
||||
var i = 0
|
||||
var j = 0
|
||||
while (i + 1 < bytes.size) {
|
||||
val lo = bytes[i].toInt() and 0xff
|
||||
val hi = bytes[i + 1].toInt() and 0xff
|
||||
i += 2
|
||||
var s = lo or (hi shl 8)
|
||||
if (s >= 0x8000) s -= 0x10000
|
||||
val v = (s * gain).roundToInt().coerceIn(-32_768, 32_767)
|
||||
out[j++] = (v and 0xff).toByte()
|
||||
out[j++] = ((v shr 8) and 0xff).toByte()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** FloatArray [-1, 1] → s16le ByteArray (16 кГц, mono). */
|
||||
internal fun floatSamplesToPcm16(samples: FloatArray): ByteArray {
|
||||
val out = ByteArray(samples.size * 2)
|
||||
|
||||
@@ -42,13 +42,17 @@ class WavWriter(
|
||||
runCatching {
|
||||
RandomAccessFile(path, "rw").use { raf ->
|
||||
raf.seek(4)
|
||||
raf.writeInt((36 + dataBytes).toInt()) // размер RIFF
|
||||
raf.write(leInt(36 + dataBytes)) // размер RIFF (little-endian)
|
||||
raf.seek(40)
|
||||
raf.writeInt(dataBytes.toInt()) // размер data
|
||||
raf.write(leInt(dataBytes)) // размер data (little-endian)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 4 байта little-endian (WAV-заголовок), в отличие от RandomAccessFile.writeInt (big-endian). */
|
||||
private fun leInt(v: Long): ByteArray =
|
||||
ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(v.toInt()).array()
|
||||
|
||||
private fun headerBytes(): ByteArray =
|
||||
ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN).apply {
|
||||
put("RIFF".toByteArray())
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package pw.binom.viewmate.glasses.stt
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotSame
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Регрессия c940a7e: AudioRecord.read возвращает ЧИСЛО КАДРОВ, не байт.
|
||||
@@ -44,5 +46,76 @@ class MicChunkTest {
|
||||
assertEquals(1_600, CHUNK_BYTES / 2, "read() просит КАДРОВ: 1600 (не 3200)")
|
||||
}
|
||||
|
||||
/*
|
||||
* Усиление микрофона (TASK-mic-gain): RayNeo X2 пишет пик 1–9% шкалы,
|
||||
* gainChunk ×10 поднимает речь до уровня, который видят Silero VAD/Whisper.
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun quietChunk_gained10_peakReachesAtLeast9000_sizeKept() {
|
||||
val chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 1_000 else -1_000 } // тихая речь: пик 1000 (~3%)
|
||||
val out = gainChunk(chunk)
|
||||
assertEquals(CHUNK_BYTES, out.size, "усиление не меняет размер чанка: 3200 байт")
|
||||
val peak = s16Samples(out).maxOf { abs(it) }
|
||||
assertTrue(peak >= 9_000, "тихий пик 1000 × 10 должен дать ≥ 9000, а не $peak")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loudChunk_gained10_clampedInsideS16Range() {
|
||||
val chunk = s16leChunk(1_600) { j -> if (j % 2 == 0) 30_000 else -30_000 } // громкий
|
||||
val out = gainChunk(chunk)
|
||||
val s = s16Samples(out)
|
||||
assertEquals(32_767, s.max(), "30000 × 10 = 300000 → насыщение до 32767")
|
||||
assertEquals(-32_768, s.min(), "-30000 × 10 = -300000 → насыщение до -32768")
|
||||
assertTrue(s.all { it in -32_768..32_767 }, "ни один сэмпл не выходит за s16-диапазон")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zeroChunk_gained10_bytesUnchanged() {
|
||||
val chunk = ByteArray(CHUNK_BYTES)
|
||||
val out = gainChunk(chunk)
|
||||
assertEquals(CHUNK_BYTES, out.size, "тихий (нулевой) чанк не меняет размер")
|
||||
assertTrue(out.all { it == 0.toByte() }, "нули остаются нулями после усиления")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gainEqualsOne_returnsSameInstance_noReallocation() {
|
||||
val chunk = s16leChunk(1_600) { j -> (j % 3_001) - 1_500 }
|
||||
assertSame(chunk, gainChunk(chunk, 1f), "gain = 1: не аллоцируем заново — возвратим сами bytes")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun orderPreserved_signKept_firstTwoSamplesScaled() {
|
||||
val chunk = s16leChunk(1_600) { j -> when (j) { 0 -> -123; 1 -> 456; else -> 0 } }
|
||||
val s = s16Samples(gainChunk(chunk))
|
||||
assertEquals(-1_230, s[0], "первый сэмпл: знак сохранён и усилен ×10")
|
||||
assertEquals(4_560, s[1], "второй сэмпл: порядок сохранён и усилен ×10")
|
||||
}
|
||||
|
||||
/** n сэмплов s16le (low byte + high<<8) из функции от индекса. */
|
||||
private fun s16leChunk(n: Int, make: (Int) -> Int): ByteArray {
|
||||
val out = ByteArray(n * 2)
|
||||
for (j in 0 until n) {
|
||||
val v = make(j)
|
||||
out[j * 2] = (v and 0xff).toByte()
|
||||
out[j * 2 + 1] = ((v shr 8) and 0xff).toByte()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** s16le ByteArray → IntArray сэмплов (разбор как в s16leToFloat app-phone). */
|
||||
private fun s16Samples(bytes: ByteArray): IntArray {
|
||||
val n = bytes.size / 2
|
||||
val out = IntArray(n)
|
||||
for (j in 0 until n) {
|
||||
val lo = bytes[j * 2].toInt() and 0xff
|
||||
val hi = bytes[j * 2 + 1].toInt() and 0xff
|
||||
var s = lo or (hi shl 8)
|
||||
if (s >= 0x8000) s -= 0x10000
|
||||
out[j] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun recorderWouldReturn(frames: Int) = frames
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package pw.binom.viewmate.glasses.stt
|
||||
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Регрессия: размеры в WAV-заголовке должны быть little-endian — из-за big-endian
|
||||
* (RandomAccessFile.writeInt) плееры не открывали дампы stt_dump_*.wav:
|
||||
* для файла 188844 байта байты 4..8 были 00 02 e1 a4 вместо a4 e1 02 00.
|
||||
*/
|
||||
class WavWriterTest {
|
||||
|
||||
@Test
|
||||
fun close_3200Data_riffAndDataSizesLittleEndian() {
|
||||
val bytes = writeAndClose(3_200)
|
||||
assertContentEquals(leIntBytes(36 + 3_200), bytes.copyOfRange(4, 8),
|
||||
"RIFF size = 36 + data, little-endian (байты 4..8) — не big-endian из writeInt")
|
||||
assertContentEquals(leIntBytes(3_200), bytes.copyOfRange(40, 44),
|
||||
"data size = 3200, little-endian (байты 40..44)")
|
||||
assertEquals(1_600, leIntAt(bytes, 40) / 2,
|
||||
"nframes = data/2 (16 кГц s16le mono) — как считает python wave")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun close_noData_riffSize36DataZero() {
|
||||
val bytes = writeAndClose(0)
|
||||
assertContentEquals(leIntBytes(36), bytes.copyOfRange(4, 8), "пустой data: RIFF size = 36")
|
||||
assertEquals(0, leIntAt(bytes, 40), "пустой data: data size = 0")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closeTwice_sizesUnchanged() {
|
||||
val file = tmpFile()
|
||||
val w = WavWriter(file)
|
||||
w.write(ByteArray(3_200))
|
||||
w.close()
|
||||
w.close()
|
||||
val bytes = file.readBytes()
|
||||
assertEquals(44 + 3_200, bytes.size, "второй close ничего не дописывает в файл")
|
||||
assertContentEquals(leIntBytes(36 + 3_200), bytes.copyOfRange(4, 8),
|
||||
"двойной close не портит размеры (флаг closed)")
|
||||
assertContentEquals(leIntBytes(3_200), bytes.copyOfRange(40, 44))
|
||||
}
|
||||
|
||||
private fun writeAndClose(dataBytes: Int): ByteArray {
|
||||
val file = tmpFile()
|
||||
val w = WavWriter(file)
|
||||
if (dataBytes > 0) w.write(ByteArray(dataBytes))
|
||||
w.close()
|
||||
assertEquals(44L + dataBytes, file.length(), "файл = 44-байтный заголовок + data")
|
||||
return file.readBytes()
|
||||
}
|
||||
|
||||
private fun tmpFile(): File {
|
||||
val f = Files.createTempFile("wvwriter-test", ".wav").toFile()
|
||||
f.deleteOnExit()
|
||||
return f
|
||||
}
|
||||
|
||||
/** 4 байта little-endian — ожидаемый вид записи в WAV-заголовке. */
|
||||
private fun leIntBytes(v: Int): ByteArray =
|
||||
ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(v).array()
|
||||
|
||||
/** Прочитать 4 байта little-endian с позиции offset (то, как rидер получит int32). */
|
||||
private fun leIntAt(bytes: ByteArray, offset: Int): Int {
|
||||
fun b(i: Int) = bytes[i].toInt() and 0xff
|
||||
return b(offset) or (b(offset + 1) shl 8) or (b(offset + 2) shl 16) or (b(offset + 3) shl 24)
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,9 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(project(":lib-core"))
|
||||
|
||||
// RayNeo Mercury BLE protocol (RayNeo X2): pair/connect/команды с телефона
|
||||
implementation("pw.binom.mercury:rayneo-mercury:0.1.0-SNAPSHOT")
|
||||
|
||||
// Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
|
||||
implementation(libs.koog.openai.client)
|
||||
implementation(libs.koog.http.client.okhttp)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package pw.binom.viewmate.phone
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.mercury.DiscoveredGlasses
|
||||
import pw.binom.mercury.RayNeoMercury
|
||||
|
||||
/**
|
||||
* Мост к либе rayneo-mercury (протокол RayNeo Mercury по BLE).
|
||||
*
|
||||
* Минимум для полевого теста: скан → pair → connect → погасить/включить экран
|
||||
* (deepSuspend). События либы логируются и частично проксируются в [status].
|
||||
*
|
||||
* Кнопки экрана — только проверка транспорта: если очки принимают команду,
|
||||
* протокол жив, дальше можно вешать остальные команды (яркость, WiFi, магазин).
|
||||
*/
|
||||
object MercuryBridge {
|
||||
|
||||
private const val TAG = "MercuryBridge"
|
||||
|
||||
private var mercury: RayNeoMercury? = null
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
data class UiState(
|
||||
val connected: Boolean = false,
|
||||
val glassesName: String? = null,
|
||||
val statusText: String = "не подключено",
|
||||
val lastError: String? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(UiState())
|
||||
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||
|
||||
private var currentGlasses: DiscoveredGlasses? = null
|
||||
|
||||
/** Инициализация (вызывать из Application). */
|
||||
fun init(context: Context) {
|
||||
if (mercury != null) return
|
||||
mercury = RayNeoMercury(context.applicationContext, scope)
|
||||
scope.launch {
|
||||
mercury!!.events.collect { event ->
|
||||
when (event) {
|
||||
is RayNeoMercury.Event.Connected -> setStatus("подключено (BLE)")
|
||||
is RayNeoMercury.Event.Disconnected -> setStatus("отключено: ${event.cause}")
|
||||
is RayNeoMercury.Event.JsonMessage -> log("json", "${event.uri} ${event.json.take(200)}")
|
||||
is RayNeoMercury.Event.StreamPacket -> log("stream", "packet seq=${event.seq} type=${event.type} ${event.payload.size}B")
|
||||
is RayNeoMercury.Event.AppInstallStatus -> setStatus("app: ${event.pkg} status=${event.status} err=${event.errCode}")
|
||||
is RayNeoMercury.Event.AppInstallProgress -> log("app", "${event.pkg}: ${event.progress}%")
|
||||
is RayNeoMercury.Event.WirelessAdbAddress -> setStatus("ADB: ${event.address}")
|
||||
is RayNeoMercury.Event.LogcatLine -> log("logcat", event.line)
|
||||
is RayNeoMercury.Event.GeneralStatusUpdate -> log("status", "brightness=${event.status.brightness} deepSuspend=${event.status.deepSuspend}")
|
||||
is RayNeoMercury.Event.WifiScanResult -> log("wifi", "сетей: ${event.networks.size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Прав на BLE нет — вернуть false; хост-приложение запрашивает само. */
|
||||
fun hasBlePermissions(context: Context): Boolean {
|
||||
val m = mercury ?: return false
|
||||
return m.hasPermissions() &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
/** Скан + pair (если нужно) + connect. Возвращает true при успехе. */
|
||||
suspend fun connectFirst(timeoutMillis: Long = 20_000): Boolean {
|
||||
val m = mercury ?: return false
|
||||
setStatus("сканирую очки…")
|
||||
val glasses = m.scanOnce(timeoutMillis)
|
||||
if (glasses == null) {
|
||||
setError("очки не найдены (скан $timeoutMillis мс)")
|
||||
return false
|
||||
}
|
||||
currentGlasses = glasses
|
||||
setStatus("найдены: ${glasses.name ?: glasses.address}")
|
||||
|
||||
val pairResult = m.pair(glasses)
|
||||
if (pairResult is pw.binom.mercury.pair.PairManager.PairingResult.Failed) {
|
||||
log("pair", "failed: ${pairResult.reason}")
|
||||
}
|
||||
|
||||
val ok = m.connect(glasses)
|
||||
if (!ok) setError("connect не удался")
|
||||
_state.value = _state.value.copy(connected = ok, glassesName = glasses.name)
|
||||
return ok
|
||||
}
|
||||
|
||||
suspend fun disconnect() {
|
||||
mercury?.disconnect()
|
||||
_state.value = _state.value.copy(connected = false)
|
||||
setStatus("отключено")
|
||||
}
|
||||
|
||||
/** Погасить экран очков (deepSuspend=1). */
|
||||
suspend fun screenOff(): Boolean {
|
||||
val ok = mercury?.setDeepSuspend(true) ?: false
|
||||
setStatus(if (ok) "команда «погасить экран» отправлена" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Включить экран очков (deepSuspend=0). */
|
||||
suspend fun screenOn(): Boolean {
|
||||
val ok = mercury?.setDeepSuspend(false) ?: false
|
||||
setStatus(if (ok) "команда «включить экран» отправлена" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
private fun setStatus(text: String) {
|
||||
log("status", text)
|
||||
_state.value = _state.value.copy(statusText = text, lastError = null)
|
||||
}
|
||||
|
||||
private fun setError(text: String) {
|
||||
Log.e(TAG, text)
|
||||
_state.value = _state.value.copy(statusText = text, lastError = text)
|
||||
}
|
||||
|
||||
private fun log(tag: String, msg: String) {
|
||||
Log.d(TAG, "[$tag] $msg")
|
||||
}
|
||||
}
|
||||
@@ -425,6 +425,8 @@ class PhoneApp : Application() {
|
||||
PlaybackService.start(this)
|
||||
log("server", "playback-сервис запущен (foreground, WAKE_LOCK)")
|
||||
|
||||
MercuryBridge.init(this)
|
||||
|
||||
server.start()
|
||||
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
|
||||
DebugHttpServer.start(this)
|
||||
|
||||
@@ -29,6 +29,11 @@ internal fun s16leToFloat(pcm: ByteArray): FloatArray {
|
||||
* Стриминговое распознавание: куски PCM → VAD-фразы (Silero, окна по 512) →
|
||||
* Whisper (в отдельном потоке) → колбэк [onPhrase].
|
||||
*
|
||||
* Весь PCM сессии дополнительно накапливается без зависимости от VAD и в
|
||||
* [finish] (по клику) распознаётся одним вызовом (хвост 30 с, если больше),
|
||||
* перезаписывая VAD-текст; VAD-фразы остаются только промежуточными
|
||||
* ([onPhrase]).
|
||||
*
|
||||
* Потокобезопасен: [accept]/[finish]/[reset] синхронизированы; распознавание
|
||||
* фраз идёт в однопоточном executor-е (не блокирует приём аудио).
|
||||
*
|
||||
@@ -45,6 +50,12 @@ class SttStreamer(
|
||||
companion object {
|
||||
private const val SILENCE_TIMEOUT_MS = 30_000L
|
||||
private const val WINDOW_SIZE = 512
|
||||
|
||||
/** Частота сэмплирования PCM: 16 кГц, s16le, mono. */
|
||||
private const val SAMPLE_RATE = 16_000
|
||||
|
||||
/** Максимум PCM для распознания по клику: 30 с (дольше — последние 30 с). */
|
||||
private val MAX_FULL_BUFFER_SAMPLES = SAMPLE_RATE * 30
|
||||
}
|
||||
|
||||
private val lock = java.util.concurrent.locks.ReentrantLock()
|
||||
@@ -79,6 +90,12 @@ class SttStreamer(
|
||||
/** Недозаполненные сэмплы (< 512) для следующего окна VAD. */
|
||||
private var pending = FloatArray(0)
|
||||
|
||||
/**
|
||||
* Весь PCM сессии (FloatArray @16 кГц) — не зависит от VAD-сегментации:
|
||||
* [finish] прогоняет его через распознаватель одним вызовом.
|
||||
*/
|
||||
private var sessionPcm: FloatArray = FloatArray(0)
|
||||
|
||||
/** Последняя поставленная в executor задача — finish ждёт её завершения. */
|
||||
private var lastFuture: Future<*>? = null
|
||||
|
||||
@@ -103,6 +120,7 @@ class SttStreamer(
|
||||
lock.withLock {
|
||||
if (deadlineMs == 0L) deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
|
||||
val floats = s16leToFloat(pcm)
|
||||
sessionPcm = sessionPcm + floats
|
||||
var i = 0
|
||||
|
||||
if (pending.isNotEmpty()) {
|
||||
@@ -134,8 +152,11 @@ class SttStreamer(
|
||||
|
||||
/**
|
||||
* Завершить сессию (клик): выбросить остаток буфера, дождаться всех
|
||||
* распознаваний и вернуть полный текст. Буферы НЕ сбрасывает — после
|
||||
* вызова нужно [reset] (состояние сессии снимает вызывающий).
|
||||
* распознаваний VAD, затем распознать весь буфер сессии одним вызовом
|
||||
* (хвост 30 с, если длиннее) — [full] перезаписывается результатом
|
||||
* (не конкатенация с VAD-фразами). Вернуть полный текст.
|
||||
* Буферы НЕ сбрасывает — после вызова нужно [reset] (состояние
|
||||
* сессии снимает вызывающий).
|
||||
*/
|
||||
fun finish(): String {
|
||||
lock.withLock {
|
||||
@@ -148,9 +169,40 @@ class SttStreamer(
|
||||
}
|
||||
val last = lastFuture
|
||||
if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) }
|
||||
recognizeFullBufferLocked()
|
||||
return full
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Распознать весь буфер сессии [sessionPcm] одним вызовом: целиком,
|
||||
* если ≤ 30 с, иначе последние 30 с (команда перед кликом точно влезает).
|
||||
* Результат перезаписывает [full] — VAD-фразы идут только в [onPhrase].
|
||||
* Вызывать с удержанным [lock].
|
||||
*/
|
||||
private fun recognizeFullBufferLocked() {
|
||||
val buffer = sessionPcm
|
||||
if (buffer.isEmpty()) return
|
||||
val chunk = if (buffer.size > MAX_FULL_BUFFER_SAMPLES) {
|
||||
buffer.copyOfRange(buffer.size - MAX_FULL_BUFFER_SAMPLES, buffer.size)
|
||||
} else buffer
|
||||
val sessionId = session
|
||||
val task = executor.submit {
|
||||
if (sessionId != session) return@submit
|
||||
val result = runCatching { stt.recognize(chunk) }
|
||||
if (result.isSuccess) {
|
||||
full = result.getOrThrow()
|
||||
log(
|
||||
"stt",
|
||||
"полный буфер: ${chunk.size} сэмплов (${chunk.size / SAMPLE_RATE.toFloat()}с) → '$full'",
|
||||
)
|
||||
} else {
|
||||
log("stt", "полный буфер: ошибка распознавания: ${result.exceptionOrNull()?.message}")
|
||||
}
|
||||
}
|
||||
lastFuture = task
|
||||
runCatching { task.get(30, TimeUnit.SECONDS) }
|
||||
}
|
||||
/** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */
|
||||
fun reset() {
|
||||
lock.withLock { resetLocked() }
|
||||
@@ -179,6 +231,7 @@ class SttStreamer(
|
||||
session++
|
||||
full = ""
|
||||
pending = FloatArray(0)
|
||||
sessionPcm = FloatArray(0)
|
||||
deadlineMs = 0
|
||||
}
|
||||
|
||||
|
||||
@@ -360,6 +360,9 @@ private fun GlassesStatusCard(
|
||||
fg = if (playing) PhoneBlueText else PhoneMuted,
|
||||
)
|
||||
}
|
||||
|
||||
// Полевое испытание либы rayneo-mercury (BLE-протокол очков)
|
||||
MercuryPanel()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package pw.binom.viewmate.phone.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.viewmate.phone.MercuryBridge
|
||||
|
||||
/**
|
||||
* Панель Mercury (BLE-протокол RayNeo X2) — полевое испытание либы rayneo-mercury.
|
||||
* Минимум: найти очки → подключить → погасить/включить экран (deepSuspend).
|
||||
*/
|
||||
@Composable
|
||||
fun MercuryPanel() {
|
||||
val scope = rememberCoroutineScope()
|
||||
val state by MercuryBridge.state.collectAsState()
|
||||
val busy = false
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(PhoneCard)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "🛰 Mercury BLE (полевой тест)",
|
||||
color = PhoneText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = state.statusText,
|
||||
color = if (state.lastError != null) PhoneRedText else PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
if (state.connected) {
|
||||
Text(
|
||||
text = "очки: ${state.glassesName ?: "?"} · BLE готов",
|
||||
color = PhoneBlueText,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.padding(top = 10.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
MercuryBridge.connectFirst()
|
||||
}
|
||||
},
|
||||
enabled = !busy,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneBlue,
|
||||
contentColor = PhoneBlueText,
|
||||
),
|
||||
) {
|
||||
Text("подключить очки", fontSize = 12.sp)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch { MercuryBridge.disconnect() }
|
||||
},
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneSurface,
|
||||
contentColor = PhoneMuted,
|
||||
),
|
||||
) {
|
||||
Text("отключить", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.padding(top = 8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch { MercuryBridge.screenOff() }
|
||||
},
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF2B2F36),
|
||||
contentColor = Color(0xFF9AA0A6),
|
||||
),
|
||||
) {
|
||||
Text("🌙 погасить экран", fontSize = 12.sp)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch { MercuryBridge.screenOn() }
|
||||
},
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneAccent,
|
||||
contentColor = Color(0xFF10251A),
|
||||
),
|
||||
) {
|
||||
Text("☀️ включить экран", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Полный текст по клику (TASK-full-buffer-stt.md): [SttStreamer] накапливает
|
||||
* весь PCM сессии независимо от VAD и [SttStreamer.finish] прогоняет буфер
|
||||
* (или хвост 30 с) одним вызовом [PhraseRecognizer], перезаписывая text.
|
||||
* VAD-путь (промежуточные фразы в [SttStreamer.onPhrase]-колбэк) не тронут.
|
||||
*/
|
||||
class FullBufferSttTest {
|
||||
|
||||
/**
|
||||
* Простейший [StreamingVad]: окна копятся, сегментов нет — кроме [flush],
|
||||
* который (если включён) выдаёт один сегмент из всех накопленных окон.
|
||||
*/
|
||||
private class FakeVad(private val segmentOnFlush: Boolean = false) : StreamingVad {
|
||||
override val windowSizeSamples = 512
|
||||
private val windowed = ArrayList<Float>()
|
||||
private var segment: VadSegment? = null
|
||||
|
||||
override fun acceptWindow(window: FloatArray) {
|
||||
for (s in window) windowed += s
|
||||
}
|
||||
|
||||
override fun popSegment(): VadSegment? {
|
||||
val s = segment
|
||||
segment = null
|
||||
return s
|
||||
}
|
||||
|
||||
override fun flush() {
|
||||
if (segmentOnFlush && windowed.isNotEmpty()) {
|
||||
segment = VadSegment(0, windowed.toFloatArray())
|
||||
}
|
||||
windowed.clear()
|
||||
}
|
||||
|
||||
override fun close() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Фейковый [PhraseRecognizer]: записывает полученные массивы, ответы —
|
||||
* по одному на вызов (последний — на все лишние). [calls] читается
|
||||
* после [SttStreamer.finish] — executor к этому моменту уже отработал
|
||||
* все задачи (finish дожидается их через Future).
|
||||
*/
|
||||
private class RecordingRecognizer(vararg val responses: String) : PhraseRecognizer {
|
||||
val calls = mutableListOf<FloatArray>()
|
||||
private var count = 0
|
||||
|
||||
override fun recognize(samples: FloatArray): String {
|
||||
calls.add(samples)
|
||||
val answer = responses[minOf(count, responses.size - 1)]
|
||||
count += 1
|
||||
return answer
|
||||
}
|
||||
}
|
||||
|
||||
/** s16le-чанк: [count] сэмплов с глобальных индексов [offset] (зубчатая волна, период 32768). */
|
||||
private fun pcmChunk(offset: Int, count: Int): ByteArray {
|
||||
val out = ByteArray(count * 2)
|
||||
var i = 0
|
||||
while (i < count) {
|
||||
val u = (offset + i) % 32768
|
||||
out[2 * i] = (u and 0xff).toByte()
|
||||
out[2 * i + 1] = ((u ushr 8) and 0xff).toByte()
|
||||
i += 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Точная обратная сторона [s16leToFloat] для нашей пачки (по глобальному индексу). */
|
||||
private fun expectedFloat(globalIndex: Int): Float =
|
||||
(globalIndex % 32768) / 32768f
|
||||
|
||||
/** 3 чанка по 3200 Б (100 мс @ 16 кГц s16le mono) = 0.3 с. */
|
||||
private fun acceptShortSession(streamer: SttStreamer) {
|
||||
var off = 0
|
||||
repeat(3) {
|
||||
streamer.accept(pcmChunk(off, 1600))
|
||||
off += 1600
|
||||
}
|
||||
}
|
||||
|
||||
/** Короткая сессия (0.3 с): finish → ОДИН вызов на весь буфер (4800 сэмплов), full = текст. */
|
||||
@Test
|
||||
fun shortBufferIsRecognizedInOneCall() {
|
||||
val recognizer = RecordingRecognizer("привет")
|
||||
val streamer = SttStreamer(
|
||||
stt = recognizer,
|
||||
vad = FakeVad(),
|
||||
onPhrase = { _, _ -> },
|
||||
onSilence30s = { },
|
||||
)
|
||||
try {
|
||||
acceptShortSession(streamer)
|
||||
val full = streamer.finish()
|
||||
assertEquals(1, recognizer.calls.size, "один вызов на весь буфер, без VAD-сегментации")
|
||||
assertEquals(4800, recognizer.calls[0].size)
|
||||
assertContentEquals(FloatArray(4800) { expectedFloat(it) }, recognizer.calls[0])
|
||||
assertEquals("привет", full)
|
||||
} finally {
|
||||
streamer.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** 40 с > 30 с: finish → распознаётся ровно хвост 16000*30 сэмплов, текст не пуст. */
|
||||
@Test
|
||||
fun longBufferPassesLastThirtySeconds() {
|
||||
val recognizer = RecordingRecognizer("ок")
|
||||
val streamer = SttStreamer(
|
||||
stt = recognizer,
|
||||
vad = FakeVad(),
|
||||
onPhrase = { _, _ -> },
|
||||
onSilence30s = { },
|
||||
)
|
||||
try {
|
||||
val total = 40 * 16_000
|
||||
var off = 0
|
||||
while (off < total) {
|
||||
streamer.accept(pcmChunk(off, 1600))
|
||||
off += 1600
|
||||
}
|
||||
val full = streamer.finish()
|
||||
assertEquals(1, recognizer.calls.size)
|
||||
val chunk = recognizer.calls[0]
|
||||
assertEquals(16_000 * 30, chunk.size, "ровно хвост 30 с")
|
||||
assertContentEquals(
|
||||
FloatArray(16_000 * 30) { expectedFloat(total - 16_000 * 30 + it) },
|
||||
chunk,
|
||||
"хвост, а не начало буфера",
|
||||
)
|
||||
assertTrue(full.isNotBlank(), "результат полного буфера не пуст")
|
||||
} finally {
|
||||
streamer.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** reset() → finish(): распознаватель не вызывался, полный текст пуст. */
|
||||
@Test
|
||||
fun resetThenFinishSkipsRecognizer() {
|
||||
val recognizer = RecordingRecognizer("привет")
|
||||
val streamer = SttStreamer(
|
||||
stt = recognizer,
|
||||
vad = FakeVad(),
|
||||
onPhrase = { _, _ -> },
|
||||
onSilence30s = { },
|
||||
)
|
||||
try {
|
||||
acceptShortSession(streamer)
|
||||
streamer.reset()
|
||||
val full = streamer.finish()
|
||||
assertEquals(0, recognizer.calls.size, "после reset буфер пуст — whisper не вызывается")
|
||||
assertEquals("", full)
|
||||
} finally {
|
||||
streamer.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** VAD-фразы по-прежнему бродкастятся; full после finish = результат полного буфера (не конкатенация). */
|
||||
@Test
|
||||
fun vadPhrasesAreBroadcastAndFullIsOverwritten() {
|
||||
val recognizer = RecordingRecognizer("фраза", "весь буфер")
|
||||
val phrases = mutableListOf<Pair<String, String>>()
|
||||
val streamer = SttStreamer(
|
||||
stt = recognizer,
|
||||
vad = FakeVad(segmentOnFlush = true),
|
||||
onPhrase = { phrase, full -> phrases += phrase to full },
|
||||
onSilence30s = { },
|
||||
)
|
||||
try {
|
||||
acceptShortSession(streamer)
|
||||
val full = streamer.finish()
|
||||
|
||||
// Промежуточная VAD-фраза доехала до колбэка.
|
||||
assertEquals(1, phrases.size)
|
||||
assertEquals("фраза", phrases[0].first)
|
||||
|
||||
// Полный прогон: второй вызов на все сессионные 4800 сэмплов.
|
||||
assertEquals(2, recognizer.calls.size)
|
||||
assertEquals(4800, recognizer.calls[1].size)
|
||||
|
||||
// full = результат полного буфера, а не «фраза весь буфер».
|
||||
assertEquals("весь буфер", full)
|
||||
} finally {
|
||||
streamer.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ dependencyResolutionManagement {
|
||||
maven(url = "https://jitpack.io")
|
||||
mavenCentral()
|
||||
google()
|
||||
// Домашний Nexus: личные Android/KMP-библиотеки (rayneo-mercury и др.)
|
||||
maven(url = "http://192.168.76.117/repository/caffeine/") {
|
||||
isAllowInsecureProtocol = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user