Compare commits

..

4 Commits

15 changed files with 896 additions and 7 deletions
+51
View File
@@ -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 → ответ на очки.
+82
View File
@@ -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-запрос → ответ на очки.
+62
View File
@@ -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.io.FileInputStream
import java.time.LocalTime import java.time.LocalTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -29,7 +30,7 @@ class SttMicStream(
) { ) {
private var job: Job? = null private var job: Job? = null
/** Debug-дампер сырого микрофона (открывается по маркеру stt_dump.marker). */ /** Debug-дампер микрофона: пишет УСИЛЕННЫЕ чанки — те же, что уходят на телефон (по маркеру stt_dump.marker). */
private var dump: WavWriter? = null private var dump: WavWriter? = null
fun start(filesDir: File, shouldRun: () -> Boolean) { fun start(filesDir: File, shouldRun: () -> Boolean) {
@@ -62,7 +63,8 @@ class SttMicStream(
while (isActive && shouldRun()) { while (isActive && shouldRun()) {
val frames = recorder.read(buf, 0, CHUNK_BYTES / 2) // inFrameCount — КАДРЫ val frames = recorder.read(buf, 0, CHUNK_BYTES / 2) // inFrameCount — КАДРЫ
if (frames > 0) { if (frames > 0) {
val chunk = micChunkBytes(buf, frames) val raw = micChunkBytes(buf, frames)
val chunk = gainChunk(raw) // усиление: в дамп и на телефон — один и тот же чанк
if (dumpWriter != null) { if (dumpWriter != null) {
runCatching { dumpWriter.write(chunk) } runCatching { dumpWriter.write(chunk) }
.onFailure { log("stt", "dump микрофона: ${it.message}") } .onFailure { log("stt", "dump микрофона: ${it.message}") }
@@ -91,7 +93,7 @@ class SttMicStream(
/** /**
* Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker, * Debug-дампы (задача audio-dump): если в filesDir лежит stt_dump.marker,
* открыть stt_dump_HHmmss.wav — ВСЕ чанки живого микрофона допишутся туда * открыть stt_dump_HHmmss.wav — ВСЕ усиленные чанки живого микрофона допишутся туда
* до отправки в onChunk (эмуляторную ветку не трогаем). * до отправки в onChunk (эмуляторную ветку не трогаем).
*/ */
private fun openLiveMicDump(filesDir: File): WavWriter? { 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) 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). */ /** 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)
@@ -42,13 +42,17 @@ class WavWriter(
runCatching { runCatching {
RandomAccessFile(path, "rw").use { raf -> RandomAccessFile(path, "rw").use { raf ->
raf.seek(4) raf.seek(4)
raf.writeInt((36 + dataBytes).toInt()) // размер RIFF raf.write(leInt(36 + dataBytes)) // размер RIFF (little-endian)
raf.seek(40) 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 = private fun headerBytes(): ByteArray =
ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN).apply { ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN).apply {
put("RIFF".toByteArray()) put("RIFF".toByteArray())
@@ -1,9 +1,11 @@
package pw.binom.viewmate.glasses.stt package pw.binom.viewmate.glasses.stt
import kotlin.math.abs
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotSame import kotlin.test.assertNotSame
import kotlin.test.assertSame import kotlin.test.assertSame
import kotlin.test.assertTrue
/** /**
* Регрессия c940a7e: AudioRecord.read возвращает ЧИСЛО КАДРОВ, не байт. * Регрессия c940a7e: AudioRecord.read возвращает ЧИСЛО КАДРОВ, не байт.
@@ -44,5 +46,76 @@ class MicChunkTest {
assertEquals(1_600, CHUNK_BYTES / 2, "read() просит КАДРОВ: 1600 (не 3200)") 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 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)
}
}
+3
View File
@@ -94,6 +94,9 @@ dependencies {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(project(":lib-core")) 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-совместимый) // Koog (JetBrains) — LLM-клиент (OpenAI-совместимый)
implementation(libs.koog.openai.client) implementation(libs.koog.openai.client)
implementation(libs.koog.http.client.okhttp) 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) PlaybackService.start(this)
log("server", "playback-сервис запущен (foreground, WAKE_LOCK)") log("server", "playback-сервис запущен (foreground, WAKE_LOCK)")
MercuryBridge.init(this)
server.start() server.start()
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}") log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
DebugHttpServer.start(this) DebugHttpServer.start(this)
@@ -29,6 +29,11 @@ internal fun s16leToFloat(pcm: ByteArray): FloatArray {
* Стриминговое распознавание: куски PCM → VAD-фразы (Silero, окна по 512) → * Стриминговое распознавание: куски PCM → VAD-фразы (Silero, окна по 512) →
* Whisper (в отдельном потоке) → колбэк [onPhrase]. * Whisper (в отдельном потоке) → колбэк [onPhrase].
* *
* Весь PCM сессии дополнительно накапливается без зависимости от VAD и в
* [finish] (по клику) распознаётся одним вызовом (хвост 30 с, если больше),
* перезаписывая VAD-текст; VAD-фразы остаются только промежуточными
* ([onPhrase]).
*
* Потокобезопасен: [accept]/[finish]/[reset] синхронизированы; распознавание * Потокобезопасен: [accept]/[finish]/[reset] синхронизированы; распознавание
* фраз идёт в однопоточном executor-е (не блокирует приём аудио). * фраз идёт в однопоточном executor-е (не блокирует приём аудио).
* *
@@ -45,6 +50,12 @@ class SttStreamer(
companion object { companion object {
private const val SILENCE_TIMEOUT_MS = 30_000L private const val SILENCE_TIMEOUT_MS = 30_000L
private const val WINDOW_SIZE = 512 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() private val lock = java.util.concurrent.locks.ReentrantLock()
@@ -79,6 +90,12 @@ class SttStreamer(
/** Недозаполненные сэмплы (< 512) для следующего окна VAD. */ /** Недозаполненные сэмплы (< 512) для следующего окна VAD. */
private var pending = FloatArray(0) private var pending = FloatArray(0)
/**
* Весь PCM сессии (FloatArray @16 кГц) — не зависит от VAD-сегментации:
* [finish] прогоняет его через распознаватель одним вызовом.
*/
private var sessionPcm: FloatArray = FloatArray(0)
/** Последняя поставленная в executor задача — finish ждёт её завершения. */ /** Последняя поставленная в executor задача — finish ждёт её завершения. */
private var lastFuture: Future<*>? = null private var lastFuture: Future<*>? = null
@@ -103,6 +120,7 @@ class SttStreamer(
lock.withLock { lock.withLock {
if (deadlineMs == 0L) deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS if (deadlineMs == 0L) deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
val floats = s16leToFloat(pcm) val floats = s16leToFloat(pcm)
sessionPcm = sessionPcm + floats
var i = 0 var i = 0
if (pending.isNotEmpty()) { if (pending.isNotEmpty()) {
@@ -134,8 +152,11 @@ class SttStreamer(
/** /**
* Завершить сессию (клик): выбросить остаток буфера, дождаться всех * Завершить сессию (клик): выбросить остаток буфера, дождаться всех
* распознаваний и вернуть полный текст. Буферы НЕ сбрасывает — после * распознаваний VAD, затем распознать весь буфер сессии одним вызовом
* вызова нужно [reset] (состояние сессии снимает вызывающий). * (хвост 30 с, если длиннее) — [full] перезаписывается результатом
* (не конкатенация с VAD-фразами). Вернуть полный текст.
* Буферы НЕ сбрасывает — после вызова нужно [reset] (состояние
* сессии снимает вызывающий).
*/ */
fun finish(): String { fun finish(): String {
lock.withLock { lock.withLock {
@@ -148,9 +169,40 @@ class SttStreamer(
} }
val last = lastFuture val last = lastFuture
if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) } if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) }
recognizeFullBufferLocked()
return full 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() { fun reset() {
lock.withLock { resetLocked() } lock.withLock { resetLocked() }
@@ -179,6 +231,7 @@ class SttStreamer(
session++ session++
full = "" full = ""
pending = FloatArray(0) pending = FloatArray(0)
sessionPcm = FloatArray(0)
deadlineMs = 0 deadlineMs = 0
} }
@@ -360,6 +360,9 @@ private fun GlassesStatusCard(
fg = if (playing) PhoneBlueText else PhoneMuted, 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()
}
}
}
+4
View File
@@ -16,6 +16,10 @@ dependencyResolutionManagement {
maven(url = "https://jitpack.io") maven(url = "https://jitpack.io")
mavenCentral() mavenCentral()
google() google()
// Домашний Nexus: личные Android/KMP-библиотеки (rayneo-mercury и др.)
maven(url = "http://192.168.76.117/repository/caffeine/") {
isAllowInsecureProtocol = true
}
} }
} }