Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da8182443c | |||
| 17385d1784 | |||
| 98ac84ffe8 |
@@ -0,0 +1,119 @@
|
|||||||
|
# TASK: сегментация STT-буфера на фразы (whisper ≤30с)
|
||||||
|
|
||||||
|
## Проблема (доказана на железе 25.08, полевым тестом ac56e70)
|
||||||
|
|
||||||
|
sherpa-onnx OfflineRecognizer (whisper-small int8) принимает волны ТОЛЬКО ≤ 30 с:
|
||||||
|
лог движка «Only waves less than 30 seconds are supported. We process only the
|
||||||
|
first 30 seconds and discard the remaining data». При слитном буфере сессии
|
||||||
|
(полевой тест: 117.6 с после trimEdges) whisper обработал первые 30 с (тишина)
|
||||||
|
и выдал галлюцинации («Убираем. Убираем…», «Хуйню ты на распознавал»), а вся
|
||||||
|
речь после 30 с молча выброшена. Проверено на хосте тем же движком
|
||||||
|
(sherpa-onnx 1.13.6): тот же raw, нарезанный скользящим окном 30 с (шаг 15 с),
|
||||||
|
распознаётся чисто: «Я говорю что ты умеешь? Какие тебе доступны тулы? Ну
|
||||||
|
давай, а что из этого тулсета у тебя есть? Раз, два, три, четыре, пять».
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Перед распознаванием резать буфер на фразы по паузам тишины (≥ 0.6 с),
|
||||||
|
распознавать каждую фразу отдельно (гарантированно ≤ 30 с), склеивать
|
||||||
|
результаты через ПРОБЕЛ (решение пользователя, дословно: «пробелами надо
|
||||||
|
разделять»).
|
||||||
|
|
||||||
|
## Файлы (ТОЛЬКО эти — всё остальное в дереве ГОТОВО, не трогать)
|
||||||
|
|
||||||
|
1. `app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt`
|
||||||
|
— новая чистая функция `splitPhrases` + правка `recognizeFullBufferLocked`.
|
||||||
|
2. `app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/PhraseSplitTest.kt`
|
||||||
|
— НОВЫЙ файл, JVM-тесты `splitPhrases`.
|
||||||
|
3. `app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FullBufferSttTest.kt`
|
||||||
|
— добавить ОДИН кейс «две фразы через паузу → распознаны раздельно, склеены
|
||||||
|
пробелом» (переиспользовать приватный `RecordingRecognizer` того же класса).
|
||||||
|
|
||||||
|
ЗАПРЕЩЕНО трогать: `SttMicStream.kt`, `WhisperStt.kt`, `trimEdges` (кроме
|
||||||
|
вызова), `GlassesServer.kt`, `PhoneApp.kt`, `lib-core`, протокол, весь
|
||||||
|
app-glasses. Все scratch-файлы — внутри репо, в коммит не включать.
|
||||||
|
|
||||||
|
## 1. splitPhrases — чистая internal функция в SttStreamer.kt
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
/**
|
||||||
|
* Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон).
|
||||||
|
* Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с).
|
||||||
|
* Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) —
|
||||||
|
* НЕ менять без перепроверки на тех же дампах.
|
||||||
|
*/
|
||||||
|
internal fun splitPhrases(
|
||||||
|
samples: FloatArray,
|
||||||
|
threshold: Float = 0.005f,
|
||||||
|
windowSize: Int = 320, // 20 мс @ 16 кГц
|
||||||
|
minSilenceWindows: Int = 30, // 30 окон × 20 мс = пауза 0.6 с
|
||||||
|
minPhraseWindows: Int = 75, // 1.5 с — короче объединять с соседней
|
||||||
|
maxPhraseSamples: Int = 30 * 16_000, // страховка whisper ≤ 30 с
|
||||||
|
padWindows: Int = 15, // 300 мс пре/пост-ролла тишины у фразы
|
||||||
|
): List<FloatArray>
|
||||||
|
```
|
||||||
|
|
||||||
|
Семантика (точная, из полевого разбора):
|
||||||
|
|
||||||
|
- Окно [windowSize] сэмплов — ТИШИНА, если RMS < threshold.
|
||||||
|
Порог 0.005, НЕ 0.01 как в trimEdges: 0.01 режет тихие начала слов на
|
||||||
|
обрубки, whisper на обрубке выдаёт мусор. На усиленном ×3 потоке шум
|
||||||
|
~0.005–0.009, речь ~0.03–0.14.
|
||||||
|
- Пауза = подряд ≥ minSilenceWindows тихих окон (0.6 с). Пауза РАЗДЕЛЯЕТ фразы.
|
||||||
|
- Фраза = звуковые окна между паузами + отступ padWindows тихих окон с каждой
|
||||||
|
стороны (clamp к границам массива, как в trimEdges).
|
||||||
|
- Фраза короче minPhraseWindows (1.5 с): объединить с СОСЕДНЕЙ фразой (короткий
|
||||||
|
сегмент = обрубок; whisper на нём мусорит). Единственная фраза — оставить
|
||||||
|
как есть.
|
||||||
|
- Фраза длиннее maxPhraseSamples (30 с): резать жёстко на куски ≤ 30 с по
|
||||||
|
границе окна (sherpa-onnx молча обрезает длинную волну — страховка обязательна).
|
||||||
|
- Пустой вход / всё тишина → пустой список.
|
||||||
|
- Внутренние паузы < 0.6 с НЕ трогать (внутри фразы тишина остаётся: склейка
|
||||||
|
пауз ломает whisper — «де-тишина» давала «1,2,3,4,5,8,10», проверено).
|
||||||
|
|
||||||
|
## 2. recognizeFullBufferLocked — правка
|
||||||
|
|
||||||
|
Текущий код (строки ~187–211 SttStreamer.kt): `trimEdges(base)` → ОДИН вызов
|
||||||
|
`stt.recognize(chunk)`.
|
||||||
|
|
||||||
|
Новый поток:
|
||||||
|
|
||||||
|
1. `val chunk = trimEdges(base)` — как сейчас, НЕ менять.
|
||||||
|
2. `val phrases = splitPhrases(chunk)`.
|
||||||
|
3. `phrases` пуст → `return` (как сейчас при пустом chunk).
|
||||||
|
4. В executor-задаче: для КАЖДОЙ фразы последовательно `stt.recognize(phrase)`;
|
||||||
|
результат с `isBlank()` — пропускать (мусорный сегмент, в текст не вставлять);
|
||||||
|
итог — `joinToString(" ")` по непустым результатам.
|
||||||
|
5. Все фразы пустые → `full` НЕ перезаписывать (семантика finish без текста
|
||||||
|
сохраняется, вызывающий сам решит).
|
||||||
|
6. Лог: `полный буфер: N фраз (2.3с, 4.1с, …) → '$full'` — размеры фраз в
|
||||||
|
секундах, один знак после запятой.
|
||||||
|
7. `task.get(...)`: таймаут поднять с 30 до 60 секунд (N фраз × до 30 с каждая).
|
||||||
|
|
||||||
|
## 3. Тесты
|
||||||
|
|
||||||
|
PhraseSplitTest.kt — чистые JVM-тесты (синтез сигнала в стиле FullBufferSttTest:
|
||||||
|
зубчатая волна как «речь», нули как тишина; для «тихих начал» — плавное
|
||||||
|
нарастание амплитуды):
|
||||||
|
|
||||||
|
1. Две фразы (по ~2 с), пауза 0.7 с → ровно 2 элемента; обе начинаются с
|
||||||
|
звучного окна (не с тишины).
|
||||||
|
2. Пауза 0.4 с (< 0.6) НЕ разделяет → 1 элемент.
|
||||||
|
3. Тихие начала слов не режутся: фраза с окнами 0.003–0.009 на старте остаётся
|
||||||
|
целой (порог 0.005) → 1 элемент, начало фразы включает тихие окна.
|
||||||
|
4. Всё тишина → пустой список.
|
||||||
|
5. Пустой вход → пустой список.
|
||||||
|
6. Фраза 31 с → разрезана на 2 куска, каждый ≤ 30 с.
|
||||||
|
7. Две короткие фразы (0.8 с и 0.9 с, пауза 0.7 с) → объединены в одну.
|
||||||
|
|
||||||
|
FullBufferSttTest.kt — добавить кейс (внутри класса, до закрывающей `}`):
|
||||||
|
|
||||||
|
- буфер: «фраза А» (1.5 с зуба), пауза 0.7 с нулей, «фраза Б» (1.5 с зуба);
|
||||||
|
`RecordingRecognizer("А", "Б")`; после `finish()`: `calls.size == 2`,
|
||||||
|
`full == "А Б"` (пробел — разделитель по решению пользователя).
|
||||||
|
|
||||||
|
## Проверка (делает Hermes, не агент)
|
||||||
|
|
||||||
|
- `./gradlew :app-phone:testDebugUnitTest :app-phone:assembleDebug` — зелёные,
|
||||||
|
число тестов app-phone выросло на 8 (7 новых + 1 дополненный).
|
||||||
|
- `git status --short` чистый, коммит с реализацией и этим ТЗ есть.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# TASK: замена whisper-small на Qwen3-ASR-0.6B int8 (sherpa-onnx, CPU)
|
||||||
|
|
||||||
|
## Проблема (доказана на железе 25.08, полевым тестом ac56e70 + серверным экспериментом)
|
||||||
|
|
||||||
|
whisper-small int8 распознаёт русскую речь посредственно: «Какие тебе доступные
|
||||||
|
толулы?», «Ну давай, а что из этого туалета у тебя есть?» (было на дампах
|
||||||
|
пользователя). Qwen3-ASR-0.6B (тот же движок sherpa-onnx 1.13.6, тот же CPU)
|
||||||
|
без подсказок — ровня висперу, а с hotwords («тулсет,тулы,умеешь») исправляет
|
||||||
|
все провалы в ноль: «Какие тебе доступные тулы?», «Ну давай, а что из этого
|
||||||
|
тулсета у тебя есть?», «Что ты умеешь?». Эксперимент проведён на дампах
|
||||||
|
пользователя тем же движком и той же моделью (int8), скорость на серверном CPU
|
||||||
|
0.1-0.2 от реального времени (фраза 4.4 с → 0.9 с). Решение пользователя:
|
||||||
|
остаёмся на CPU (аппаратное ускорение NNAPI для трансформеров не даёт выигрыша).
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Новый класс `Qwen3AsrStt` (реализует тот же интерфейс `PhraseRecognizer`, что и
|
||||||
|
`WhisperStt`) + переключатель-константа в фабрике `PhoneApp.ensureStt()`.
|
||||||
|
Старый `WhisperStt` НЕ удалять — откат в одну строку. Сегментация по паузам
|
||||||
|
(`splitPhrases` + склейка пробелом в `SttStreamer`) НЕ меняется — Qwen3-ASR тоже
|
||||||
|
имеет лимит max_total_len=512 токенов (≈ 30-40 с аудио), нарезка остаётся в силе.
|
||||||
|
|
||||||
|
## Файлы (ТОЛЬКО эти — всё остальное в дереве ГОТОВО, не трогать)
|
||||||
|
|
||||||
|
1. `app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt` — НОВЫЙ файл.
|
||||||
|
2. `app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt` — только
|
||||||
|
`ensureStt()` (строки ~350-366) + 2 константы в companion object (строка 512).
|
||||||
|
|
||||||
|
ЗАПРЕЩЕНО трогать: `WhisperStt.kt`, `SttStreamer.kt`, `SttDebug.kt`,
|
||||||
|
`GlassesServer.kt`, `lib-core`, протокол, весь app-glasses. Все scratch-файлы —
|
||||||
|
вне репо или в коммит не включать.
|
||||||
|
|
||||||
|
## 1. Qwen3AsrStt.kt — новый класс
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
import com.k2fsa.sherpa.onnx.OfflineModelConfig
|
||||||
|
import com.k2fsa.sherpa.onnx.OfflineQwen3AsrModelConfig
|
||||||
|
import com.k2fsa.sherpa.onnx.OfflineRecognizer
|
||||||
|
import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig
|
||||||
|
|
||||||
|
/** Офлайн-распознавание: sherpa-onnx + Qwen3-ASR-0.6B (int8), CPU. */
|
||||||
|
class Qwen3AsrStt(
|
||||||
|
private val convFrontendPath: String,
|
||||||
|
private val encoderPath: String,
|
||||||
|
private val decoderPath: String,
|
||||||
|
private val tokenizerDir: String,
|
||||||
|
private val numThreads: Int = 4,
|
||||||
|
private val hotwords: String = "",
|
||||||
|
) : PhraseRecognizer {
|
||||||
|
private val recognizer: OfflineRecognizer = OfflineRecognizer(
|
||||||
|
null,
|
||||||
|
OfflineRecognizerConfig(
|
||||||
|
modelConfig = OfflineModelConfig(
|
||||||
|
qwen3Asr = OfflineQwen3AsrModelConfig(
|
||||||
|
convFrontend = convFrontendPath,
|
||||||
|
encoder = encoderPath,
|
||||||
|
decoder = decoderPath,
|
||||||
|
tokenizer = tokenizerDir,
|
||||||
|
maxTotalLen = 512,
|
||||||
|
maxNewTokens = 128,
|
||||||
|
temperature = 1e-6f,
|
||||||
|
topP = 0.8f,
|
||||||
|
seed = 42,
|
||||||
|
hotwords = hotwords,
|
||||||
|
),
|
||||||
|
numThreads = numThreads,
|
||||||
|
provider = "cpu",
|
||||||
|
modelType = "qwen3_asr",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Распознать 16 кГц mono PCM. Возвращает текст. */
|
||||||
|
override fun recognize(samples: FloatArray): String {
|
||||||
|
val stream = recognizer.createStream()
|
||||||
|
try {
|
||||||
|
stream.acceptWaveform(samples, 16000)
|
||||||
|
recognizer.decode(stream)
|
||||||
|
return recognizer.getResult(stream).text.trim()
|
||||||
|
} finally {
|
||||||
|
stream.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Точные имена полей и сигнатуры подтверждены байткодом sherpa-onnx v1.13.6
|
||||||
|
(`OfflineQwen3AsrModelConfig` — поля convFrontend/encoder/decoder/tokenizer/
|
||||||
|
maxTotalLen/maxNewTokens/temperature/topP/seed/hotwords; `OfflineModelConfig` —
|
||||||
|
поле qwen3Asr; нативная строка `qwen3_asr=` подтверждает modelType).
|
||||||
|
Дефолты декодирования — из C++-конфига sherpa-onnx 1.13.6: max_total_len=512,
|
||||||
|
max_new_tokens=128, temperature=1e-6, top_p=0.8, seed=42. Поле `tokens` в
|
||||||
|
OfflineModelConfig для qwen3_asr НЕ передавать (токенизатор — директория в
|
||||||
|
qwen3Asr.tokenizer, не файл).
|
||||||
|
|
||||||
|
## 2. PhoneApp.kt — фабрика и константы
|
||||||
|
|
||||||
|
В companion object (строка 512) добавить:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
/** Бэкенд STT: "qwen3_asr" | "whisper" (откат). */
|
||||||
|
private const val STT_BACKEND = "qwen3_asr"
|
||||||
|
|
||||||
|
/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */
|
||||||
|
private const val STT_HOTWORDS = "тулсет,тулы,умеешь"
|
||||||
|
```
|
||||||
|
|
||||||
|
В `ensureStt()` (строки ~350-366) — заменить блок создания whisper на:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val qwenConv = File(modelsDir, "conv_frontend.onnx")
|
||||||
|
val qwenEnc = File(modelsDir, "encoder.int8.onnx")
|
||||||
|
val qwenDec = File(modelsDir, "decoder.int8.onnx")
|
||||||
|
val qwenTok = File(modelsDir, "tokenizer")
|
||||||
|
if (STT_BACKEND == "qwen3_asr" &&
|
||||||
|
qwenConv.isFile && qwenEnc.isFile && qwenDec.isFile && qwenTok.isDirectory
|
||||||
|
) {
|
||||||
|
stt = Qwen3AsrStt(
|
||||||
|
convFrontendPath = qwenConv.absolutePath,
|
||||||
|
encoderPath = qwenEnc.absolutePath,
|
||||||
|
decoderPath = qwenDec.absolutePath,
|
||||||
|
tokenizerDir = qwenTok.absolutePath,
|
||||||
|
numThreads = 4,
|
||||||
|
hotwords = STT_HOTWORDS,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val encoder = File(modelsDir, "small-encoder.int8.onnx")
|
||||||
|
val decoder = File(modelsDir, "small-decoder.int8.onnx")
|
||||||
|
val tokens = File(modelsDir, "small-tokens.txt")
|
||||||
|
if (!encoder.isFile || !decoder.isFile || !tokens.isFile) {
|
||||||
|
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
stt = WhisperStt(
|
||||||
|
encoderPath = encoder.absolutePath,
|
||||||
|
decoderPath = decoder.absolutePath,
|
||||||
|
tokensPath = tokens.absolutePath,
|
||||||
|
numThreads = 4,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Тип `stt` — `PhraseRecognizer` (локальная переменная в synchronized-блоке;
|
||||||
|
дальше `SttStreamer(stt = stt, ...)` как сейчас). Лог создания: добавить строку
|
||||||
|
`log("stt", "бэкенд: $STT_BACKEND (hotwords: $STT_HOTWORDS)")` перед созданием
|
||||||
|
SttStreamer. Старая ветка whisper остаётся для отката.
|
||||||
|
|
||||||
|
## Проверка (делает Hermes, не агент)
|
||||||
|
|
||||||
|
- `./gradlew :app-phone:testDebugUnitTest :app-phone:assembleDebug` — зелёные,
|
||||||
|
число тестов app-phone НЕ меняется (новый класс не покрывается JVM-тестами —
|
||||||
|
Android + нативная библиотека; splitPhrases/trimEdges не трогаются).
|
||||||
|
- `git status --short` чистый, коммит с реализацией и этим ТЗ есть.
|
||||||
|
- Дальше Hermes сам: adb push моделей (conv_frontend.onnx, encoder.int8.onnx,
|
||||||
|
decoder.int8.onnx, tokenizer/ — из /root/viewmate/models/sherpa-onnx-qwen3-asr-0.6B-int8/)
|
||||||
|
на телефон в filesDir/models + установка APK + полевая приёмка на железе.
|
||||||
@@ -46,6 +46,8 @@ import pw.binom.viewmate.phone.agent.RemoteLlmClient
|
|||||||
import pw.binom.viewmate.phone.agent.SkillRegistry
|
import pw.binom.viewmate.phone.agent.SkillRegistry
|
||||||
import pw.binom.viewmate.phone.agent.SkillRepository
|
import pw.binom.viewmate.phone.agent.SkillRepository
|
||||||
import pw.binom.viewmate.phone.agent.ToolsetRegistry
|
import pw.binom.viewmate.phone.agent.ToolsetRegistry
|
||||||
|
import pw.binom.viewmate.phone.stt.PhraseRecognizer
|
||||||
|
import pw.binom.viewmate.phone.stt.Qwen3AsrStt
|
||||||
import pw.binom.viewmate.phone.stt.SttStreamer
|
import pw.binom.viewmate.phone.stt.SttStreamer
|
||||||
import pw.binom.viewmate.phone.stt.WhisperStt
|
import pw.binom.viewmate.phone.stt.WhisperStt
|
||||||
|
|
||||||
@@ -351,6 +353,23 @@ class PhoneApp : Application() {
|
|||||||
synchronized(sttLock) {
|
synchronized(sttLock) {
|
||||||
sttStreamer?.let { return it }
|
sttStreamer?.let { return it }
|
||||||
val modelsDir = File(filesDir, "models")
|
val modelsDir = File(filesDir, "models")
|
||||||
|
val qwenConv = File(modelsDir, "conv_frontend.onnx")
|
||||||
|
val qwenEnc = File(modelsDir, "encoder.int8.onnx")
|
||||||
|
val qwenDec = File(modelsDir, "decoder.int8.onnx")
|
||||||
|
val qwenTok = File(modelsDir, "tokenizer")
|
||||||
|
var stt: PhraseRecognizer
|
||||||
|
if (STT_BACKEND == "qwen3_asr" &&
|
||||||
|
qwenConv.isFile && qwenEnc.isFile && qwenDec.isFile && qwenTok.isDirectory
|
||||||
|
) {
|
||||||
|
stt = Qwen3AsrStt(
|
||||||
|
convFrontendPath = qwenConv.absolutePath,
|
||||||
|
encoderPath = qwenEnc.absolutePath,
|
||||||
|
decoderPath = qwenDec.absolutePath,
|
||||||
|
tokenizerDir = qwenTok.absolutePath,
|
||||||
|
numThreads = 4,
|
||||||
|
hotwords = STT_HOTWORDS,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
val encoder = File(modelsDir, "small-encoder.int8.onnx")
|
val encoder = File(modelsDir, "small-encoder.int8.onnx")
|
||||||
val decoder = File(modelsDir, "small-decoder.int8.onnx")
|
val decoder = File(modelsDir, "small-decoder.int8.onnx")
|
||||||
val tokens = File(modelsDir, "small-tokens.txt")
|
val tokens = File(modelsDir, "small-tokens.txt")
|
||||||
@@ -358,19 +377,21 @@ class PhoneApp : Application() {
|
|||||||
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
|
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val whisper = WhisperStt(
|
stt = WhisperStt(
|
||||||
encoderPath = encoder.absolutePath,
|
encoderPath = encoder.absolutePath,
|
||||||
decoderPath = decoder.absolutePath,
|
decoderPath = decoder.absolutePath,
|
||||||
tokensPath = tokens.absolutePath,
|
tokensPath = tokens.absolutePath,
|
||||||
numThreads = 4,
|
numThreads = 4,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) {
|
val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) {
|
||||||
File(filesDir, "stt_dump_phone.raw")
|
File(filesDir, "stt_dump_phone.raw")
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
log("stt", "бэкенд: $STT_BACKEND (hotwords: $STT_HOTWORDS)")
|
||||||
val s = SttStreamer(
|
val s = SttStreamer(
|
||||||
stt = whisper,
|
stt = stt,
|
||||||
onSilence30s = {
|
onSilence30s = {
|
||||||
log("stt", "тишина 30 с — автo-отмена")
|
log("stt", "тишина 30 с — автo-отмена")
|
||||||
scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) }
|
scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) }
|
||||||
@@ -385,7 +406,7 @@ class PhoneApp : Application() {
|
|||||||
)
|
)
|
||||||
sttStreamer = s
|
sttStreamer = s
|
||||||
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
|
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
|
||||||
log("stt", "SttStreamer готов (Whisper-small int8)")
|
log("stt", "SttStreamer готов ($STT_BACKEND)")
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -512,5 +533,11 @@ class PhoneApp : Application() {
|
|||||||
companion object {
|
companion object {
|
||||||
lateinit var instance: PhoneApp
|
lateinit var instance: PhoneApp
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** Бэкенд STT: "qwen3_asr" | "whisper" (откат). */
|
||||||
|
private const val STT_BACKEND = "qwen3_asr"
|
||||||
|
|
||||||
|
/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */
|
||||||
|
private const val STT_HOTWORDS = "тулсет,тулы,умеешь"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
import com.k2fsa.sherpa.onnx.OfflineModelConfig
|
||||||
|
import com.k2fsa.sherpa.onnx.OfflineQwen3AsrModelConfig
|
||||||
|
import com.k2fsa.sherpa.onnx.OfflineRecognizer
|
||||||
|
import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig
|
||||||
|
|
||||||
|
/** Офлайн-распознавание: sherpa-onnx + Qwen3-ASR-0.6B (int8), CPU. */
|
||||||
|
class Qwen3AsrStt(
|
||||||
|
private val convFrontendPath: String,
|
||||||
|
private val encoderPath: String,
|
||||||
|
private val decoderPath: String,
|
||||||
|
private val tokenizerDir: String,
|
||||||
|
private val numThreads: Int = 4,
|
||||||
|
private val hotwords: String = "",
|
||||||
|
) : PhraseRecognizer {
|
||||||
|
private val recognizer: OfflineRecognizer = OfflineRecognizer(
|
||||||
|
null,
|
||||||
|
OfflineRecognizerConfig(
|
||||||
|
modelConfig = OfflineModelConfig(
|
||||||
|
qwen3Asr = OfflineQwen3AsrModelConfig(
|
||||||
|
convFrontend = convFrontendPath,
|
||||||
|
encoder = encoderPath,
|
||||||
|
decoder = decoderPath,
|
||||||
|
tokenizer = tokenizerDir,
|
||||||
|
maxTotalLen = 512,
|
||||||
|
maxNewTokens = 128,
|
||||||
|
temperature = 1e-6f,
|
||||||
|
topP = 0.8f,
|
||||||
|
seed = 42,
|
||||||
|
hotwords = hotwords,
|
||||||
|
),
|
||||||
|
numThreads = numThreads,
|
||||||
|
provider = "cpu",
|
||||||
|
modelType = "qwen3_asr",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Распознать 16 кГц mono PCM. Возвращает текст. */
|
||||||
|
override fun recognize(samples: FloatArray): String {
|
||||||
|
val stream = recognizer.createStream()
|
||||||
|
try {
|
||||||
|
stream.acceptWaveform(samples, 16000)
|
||||||
|
recognizer.decode(stream)
|
||||||
|
return recognizer.getResult(stream).text.trim()
|
||||||
|
} finally {
|
||||||
|
stream.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,16 +23,24 @@ object SttDebug {
|
|||||||
// через: adb push /data/local/tmp/... + run-as pkg cp
|
// через: adb push /data/local/tmp/... + run-as pkg cp
|
||||||
val modelsDir = File(context.filesDir, "models")
|
val modelsDir = File(context.filesDir, "models")
|
||||||
val vadModel = File(modelsDir, "silero_vad.onnx").absolutePath
|
val vadModel = File(modelsDir, "silero_vad.onnx").absolutePath
|
||||||
val encoder = File(modelsDir, "small-encoder.int8.onnx").absolutePath
|
val convFrontend = File(modelsDir, "conv_frontend.onnx").absolutePath
|
||||||
val decoder = File(modelsDir, "small-decoder.int8.onnx").absolutePath
|
val encoder = File(modelsDir, "encoder.int8.onnx").absolutePath
|
||||||
val tokens = File(modelsDir, "small-tokens.txt").absolutePath
|
val decoder = File(modelsDir, "decoder.int8.onnx").absolutePath
|
||||||
|
val tokens = File(modelsDir, "tokenizer").absolutePath
|
||||||
|
|
||||||
log("[stt] создаём VAD...")
|
log("[stt] создаём VAD...")
|
||||||
val segments = VadSegmenter.vadSegments(wav.samples, vadModel)
|
val segments = VadSegmenter.vadSegments(wav.samples, vadModel)
|
||||||
log("[stt] фраз (VAD): ${segments.size}")
|
log("[stt] фраз (VAD): ${segments.size}")
|
||||||
|
|
||||||
log("[stt] создаём Whisper-small (int8, CPU)...")
|
log("[stt] создаём Qwen3-ASR-0.6B (int8, CPU)...")
|
||||||
val stt = WhisperStt(encoder, decoder, tokens, numThreads = 4)
|
val stt = Qwen3AsrStt(
|
||||||
|
convFrontendPath = convFrontend,
|
||||||
|
encoderPath = encoder,
|
||||||
|
decoderPath = decoder,
|
||||||
|
tokenizerDir = tokens,
|
||||||
|
numThreads = 4,
|
||||||
|
hotwords = "тулсет,тулы,умеешь",
|
||||||
|
)
|
||||||
val parts = ArrayList<String>()
|
val parts = ArrayList<String>()
|
||||||
for ((i, seg) in segments.withIndex()) {
|
for ((i, seg) in segments.withIndex()) {
|
||||||
val startMs = seg.first / 16
|
val startMs = seg.first / 16
|
||||||
|
|||||||
@@ -62,6 +62,113 @@ internal fun trimEdges(
|
|||||||
return samples.copyOfRange(start, stop)
|
return samples.copyOfRange(start, stop)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон).
|
||||||
|
* Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с).
|
||||||
|
* Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) —
|
||||||
|
* НЕ менять без перепроверки на тех же дампах.
|
||||||
|
*
|
||||||
|
* Окно [windowSize] сэмплов — ТИШИНА, если RMS < [threshold] (0.005, НЕ 0.01
|
||||||
|
* как в [trimEdges]: 0.01 режет тихие начала слов на обрубки). Пауза =
|
||||||
|
* подряд ≥ [minSilenceWindows] тихих окон (0.6 с) — она РАЗДЕЛЯЕТ фразы.
|
||||||
|
* Фраза = звуковые окна между паузами + отступ [padWindows] тихих окон с
|
||||||
|
* каждой стороны (clamp к границам массива). Фраза короче
|
||||||
|
* [minPhraseWindows] (1.5 с) объединяется с соседней (обрубок); единственная
|
||||||
|
* — остаётся. Фраза длиннее [maxPhraseSamples] режется на куски ≤ 30 с по
|
||||||
|
* границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы
|
||||||
|
* < 0.6 с не трогаются. Чистая функция — покрыта JVM-тестами.
|
||||||
|
*/
|
||||||
|
internal fun splitPhrases(
|
||||||
|
samples: FloatArray,
|
||||||
|
threshold: Float = 0.005f,
|
||||||
|
windowSize: Int = 320, // 20 мс @ 16 кГц
|
||||||
|
minSilenceWindows: Int = 30, // 30 окон × 20 мс = пауза 0.6 с
|
||||||
|
minPhraseWindows: Int = 75, // 1.5 с — короче объединять с соседней
|
||||||
|
maxPhraseSamples: Int = 30 * 16_000, // страховка whisper ≤ 30 с
|
||||||
|
padWindows: Int = 15, // 300 мс пре/пост-ролла тишины у фразы
|
||||||
|
): List<FloatArray> {
|
||||||
|
if (samples.isEmpty()) return emptyList()
|
||||||
|
val winCount = (samples.size + windowSize - 1) / windowSize
|
||||||
|
val isSound = BooleanArray(winCount)
|
||||||
|
var anySound = false
|
||||||
|
for (w in 0 until winCount) {
|
||||||
|
val s = w * windowSize
|
||||||
|
val e = minOf(s + windowSize, samples.size)
|
||||||
|
var sum = 0.0
|
||||||
|
for (k in s until e) sum += samples[k] * samples[k].toDouble()
|
||||||
|
if (sqrt(sum / (e - s)).toFloat() >= threshold) {
|
||||||
|
isSound[w] = true
|
||||||
|
anySound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anySound) return emptyList()
|
||||||
|
|
||||||
|
// Диапазоны звукового содержимого фраз как индексы окон [startWin, endWin): фразы
|
||||||
|
// разграничены паузами из ≥ minSilenceWindows подряд тихих окон.
|
||||||
|
class Phrase(var start: Int, var end: Int)
|
||||||
|
|
||||||
|
val phrases = mutableListOf<Phrase>()
|
||||||
|
var pos = 0
|
||||||
|
while (pos < winCount) {
|
||||||
|
var sil = 0
|
||||||
|
while (pos + sil < winCount && !isSound[pos + sil]) sil++
|
||||||
|
if (pos + sil >= winCount) break
|
||||||
|
val start = pos + sil
|
||||||
|
var end = start
|
||||||
|
var continuePhrase = true
|
||||||
|
while (continuePhrase) {
|
||||||
|
var run = 0
|
||||||
|
while (end + run < winCount && isSound[end + run]) run++
|
||||||
|
end += run
|
||||||
|
if (end >= winCount) { continuePhrase = false; break }
|
||||||
|
var sil2 = 0
|
||||||
|
while (end + sil2 < winCount && !isSound[end + sil2]) sil2++
|
||||||
|
if (sil2 >= minSilenceWindows) {
|
||||||
|
continuePhrase = false
|
||||||
|
} else {
|
||||||
|
end += sil2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
phrases.add(Phrase(start, end))
|
||||||
|
pos = end
|
||||||
|
}
|
||||||
|
|
||||||
|
// Короткая (< 1.5 с) фраза объединяется с соседней (ед. фраза — без изменений).
|
||||||
|
while (phrases.size > 1) {
|
||||||
|
val idx = phrases.indexOfFirst { it.end - it.start < minPhraseWindows }
|
||||||
|
if (idx < 0) break
|
||||||
|
val other = if (idx == 0) idx + 1 else idx - 1
|
||||||
|
val lo = minOf(idx, other)
|
||||||
|
val hi = maxOf(idx, other)
|
||||||
|
val merged = Phrase(
|
||||||
|
minOf(phrases[lo].start, phrases[hi].start),
|
||||||
|
maxOf(phrases[lo].end, phrases[hi].end),
|
||||||
|
)
|
||||||
|
phrases.removeAt(hi)
|
||||||
|
phrases.removeAt(lo)
|
||||||
|
phrases.add(lo, merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pad = padWindows * windowSize
|
||||||
|
val out = mutableListOf<FloatArray>()
|
||||||
|
for (p in phrases) {
|
||||||
|
val s = maxOf(0, p.start * windowSize - pad)
|
||||||
|
val e = minOf(samples.size, p.end * windowSize + pad)
|
||||||
|
if (e - s <= maxPhraseSamples) {
|
||||||
|
out += samples.copyOfRange(s, e)
|
||||||
|
} else {
|
||||||
|
val chunk = (maxPhraseSamples / windowSize) * windowSize
|
||||||
|
var cs = s
|
||||||
|
while (cs < e) {
|
||||||
|
val ce = minOf(e, cs + chunk)
|
||||||
|
out += samples.copyOfRange(cs, ce)
|
||||||
|
cs = ce
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз —
|
* Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз —
|
||||||
* распознаётся только ПОЛНЫЙ буфер сессии в [finish] (по клику) одним вызовом
|
* распознаётся только ПОЛНЫЙ буфер сессии в [finish] (по клику) одним вызовом
|
||||||
@@ -178,11 +285,13 @@ class SttStreamer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Распознать весь буфер сессии [sessionPcm] одним вызовом: целиком,
|
* Распознать весь буфер сессии [sessionPcm]: целиком, если ≤
|
||||||
* если ≤ [MAX_FULL_BUFFER_SAMPLES], иначе последние [MAX_FULL_BUFFER_SAMPLES]
|
* [MAX_FULL_BUFFER_SAMPLES], иначе последние [MAX_FULL_BUFFER_SAMPLES] сэмплов
|
||||||
* сэмплов (команда перед кликом точно влезает). Перед распознаванием
|
* (команда перед кликом точно влезает). Перед распознаванием применяется
|
||||||
* применяется [trimEdges]. Результат перезаписывает [full].
|
* [trimEdges], затем [splitPhrases] режет буфер на фразы по паузам — каждая
|
||||||
* Вызывать с удержанным [lock].
|
* фраза распознаётся отдельно (whisper принимает волны только ≤ 30 с),
|
||||||
|
* непустые результаты склеиваются через пробел и перезаписывают [full].
|
||||||
|
* Все фразы мусорные → [full] НЕ трогается. Вызывать с удержанным [lock].
|
||||||
*/
|
*/
|
||||||
private fun recognizeFullBufferLocked() {
|
private fun recognizeFullBufferLocked() {
|
||||||
val buffer = sessionPcm
|
val buffer = sessionPcm
|
||||||
@@ -192,22 +301,32 @@ class SttStreamer(
|
|||||||
} else buffer
|
} else buffer
|
||||||
val chunk = trimEdges(base)
|
val chunk = trimEdges(base)
|
||||||
if (chunk.isEmpty()) return
|
if (chunk.isEmpty()) return
|
||||||
|
val phrases = splitPhrases(chunk)
|
||||||
|
if (phrases.isEmpty()) return
|
||||||
val sessionId = session
|
val sessionId = session
|
||||||
|
val sizes = phrases.map { "%.1fс".format(it.size / SAMPLE_RATE.toFloat()) }.joinToString(", ")
|
||||||
val task = executor.submit {
|
val task = executor.submit {
|
||||||
if (sessionId != session) return@submit
|
if (sessionId != session) return@submit
|
||||||
val result = runCatching { stt.recognize(chunk) }
|
runCatching {
|
||||||
if (result.isSuccess) {
|
val parts = ArrayList<String>()
|
||||||
full = result.getOrThrow()
|
for (ph in phrases) {
|
||||||
log(
|
val text = stt.recognize(ph).trim()
|
||||||
"stt",
|
if (text.isNotBlank()) parts.add(text)
|
||||||
"полный буфер: ${chunk.size} сэмплов (${chunk.size / SAMPLE_RATE.toFloat()}с) → '$full'",
|
}
|
||||||
)
|
if (parts.isNotEmpty()) full = parts.joinToString(" ")
|
||||||
|
parts
|
||||||
|
}.onSuccess { parts ->
|
||||||
|
if (parts.isEmpty()) {
|
||||||
|
log("stt", "полный буфер: ${phrases.size} фраз ($sizes) — без текста (мусор, full не меняю)")
|
||||||
} else {
|
} else {
|
||||||
log("stt", "полный буфер: ошибка распознавания: ${result.exceptionOrNull()?.message}")
|
log("stt", "полный буфер: ${phrases.size} фраз ($sizes) → '$full'")
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
log("stt", "полный буфер: ошибка распознавания: ${it.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lastFuture = task
|
lastFuture = task
|
||||||
runCatching { task.get(30, TimeUnit.SECONDS) }
|
runCatching { task.get(60, TimeUnit.SECONDS) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */
|
/** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */
|
||||||
|
|||||||
@@ -79,7 +79,11 @@ class FullBufferSttTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 130 с > 120 с: finish → распознаётся ровно хвост 16000*120 сэмплов, текст не пуст. */
|
/**
|
||||||
|
* 130 с > 120 с: finish → распознаётся ровно хвост 16000*120 сэмплов (текст не пуст).
|
||||||
|
* Хвост — сплошной зубец без пауз → splitPhrases даёт одну длинную фразу (120 с),
|
||||||
|
* которая режется страховкой на куски ≤ 30 с: 4 вызова по 30 с.
|
||||||
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun longBufferPassesLastTwoMinutes() {
|
fun longBufferPassesLastTwoMinutes() {
|
||||||
val recognizer = RecordingRecognizer("ок")
|
val recognizer = RecordingRecognizer("ок")
|
||||||
@@ -92,13 +96,14 @@ class FullBufferSttTest {
|
|||||||
off += 1600
|
off += 1600
|
||||||
}
|
}
|
||||||
val full = streamer.finish()
|
val full = streamer.finish()
|
||||||
assertEquals(1, recognizer.calls.size)
|
assertEquals(4, recognizer.calls.size, "120 с непрерывной речи режется на 4 фразы ≤ 30 с")
|
||||||
val chunk = recognizer.calls[0]
|
val sumLen = recognizer.calls.sumOf { it.size }
|
||||||
assertEquals(16_000 * 120, chunk.size, "ровно хвост 120 с")
|
assertEquals(16_000 * 120, sumLen, "в сумме ровно хвост 120 с")
|
||||||
|
recognizer.calls.forEach { assertTrue(it.size <= 30 * 16_000, "каждая фраза ≤ 30 с") }
|
||||||
assertContentEquals(
|
assertContentEquals(
|
||||||
FloatArray(16_000 * 120) { expectedFloat(total - 16_000 * 120 + it) },
|
FloatArray(30 * 16_000) { expectedFloat(total - 16_000 * 120 + it) },
|
||||||
chunk,
|
recognizer.calls[0],
|
||||||
"хвост, а не начало буфера",
|
"первая фраза — начало хвоста, а не начало буфера",
|
||||||
)
|
)
|
||||||
assertTrue(full.isNotBlank(), "результат полного буфера не пуст")
|
assertTrue(full.isNotBlank(), "результат полного буфера не пуст")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -106,6 +111,28 @@ class FullBufferSttTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Две фразы (1.5 с зуба каждая), пауза 0.7 с нулей между ними → распознаны
|
||||||
|
* отдельно (calls.size == 2), склеены через пробел (полный буфер = "А Б").
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun twoPhrasesSplitByPauseRecognizedSeparately() {
|
||||||
|
val recognizer = RecordingRecognizer("А", "Б")
|
||||||
|
val streamer = newStreamer(recognizer)
|
||||||
|
try {
|
||||||
|
val phraseLen = 24_000 // 1.5 с = 75 окон (= minPhraseWindows, не обрубок)
|
||||||
|
val pauseLen = 11_200 // 0.7 с нулей → ≥ 0.6 с, разделяет фразы
|
||||||
|
streamer.accept(pcmChunk(0, phraseLen))
|
||||||
|
streamer.accept(ByteArray(pauseLen * 2))
|
||||||
|
streamer.accept(pcmChunk(phraseLen, phraseLen))
|
||||||
|
val full = streamer.finish()
|
||||||
|
assertEquals(2, recognizer.calls.size, "пауза 0.7 с разделила фразы")
|
||||||
|
assertEquals("А Б", full, "фразы склеены пробелом")
|
||||||
|
} finally {
|
||||||
|
streamer.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** reset() → finish(): распознаватель не вызывался, полный текст пуст. */
|
/** reset() → finish(): распознаватель не вызывался, полный текст пуст. */
|
||||||
@Test
|
@Test
|
||||||
fun resetThenFinishSkipsRecognizer() {
|
fun resetThenFinishSkipsRecognizer() {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package pw.binom.viewmate.phone.stt
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты чистой функции [splitPhrases] (TASK-phrase-split-stt): резать буфер на
|
||||||
|
* фразы по паузам тишины (≥ 0.6 с), чтобы каждая фраза (whisper ≤ 30 с)
|
||||||
|
* распознавалась отдельно.
|
||||||
|
*
|
||||||
|
* «Речь» — зубчатая волна [0,1) (RMS окна много выше порога 0.005), «тишина» —
|
||||||
|
* нули. Порог/пауза/окно по умолчанию: окно 320 = 20 мс @ 16 кГц, пауза 0.6 с
|
||||||
|
* (25…30 окон), объединение < 1.5 с, страховка ≤ 30 с.
|
||||||
|
*/
|
||||||
|
class PhraseSplitTest {
|
||||||
|
private val win = 320
|
||||||
|
|
||||||
|
/** Зубчатая волна [0,1): «речь» — каждое окно даёт RMS много выше порога. */
|
||||||
|
private fun saw(offset: Int, count: Int): FloatArray =
|
||||||
|
FloatArray(count) { ((offset + it) % 32768) / 32768f }
|
||||||
|
|
||||||
|
private fun silence(count: Int): FloatArray = FloatArray(count)
|
||||||
|
|
||||||
|
private fun concat(vararg parts: FloatArray): FloatArray {
|
||||||
|
var total = 0
|
||||||
|
for (p in parts) total += p.size
|
||||||
|
val out = FloatArray(total)
|
||||||
|
var i = 0
|
||||||
|
for (p in parts) {
|
||||||
|
p.copyInto(out, i)
|
||||||
|
i += p.size
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasSound(phrase: FloatArray): Boolean = phrase.any { it > 0.01f }
|
||||||
|
|
||||||
|
/** Две фразы (~2 с), пауза 0.7 с → разделяются; обе содержат речь. */
|
||||||
|
@Test
|
||||||
|
fun twoPhrasesSeparatedByLongPause() {
|
||||||
|
val a = saw(0, 32_000)
|
||||||
|
val pause = silence(11_200) // 0.7 с
|
||||||
|
val b = saw(32_000, 32_000)
|
||||||
|
val phrases = splitPhrases(concat(a, pause, b))
|
||||||
|
assertEquals(2, phrases.size)
|
||||||
|
phrases.forEach { assertTrue(hasSound(it), "фраза содержит звучный участок") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пауза 0.4 с (< 0.6 с) не разделяет → одна фраза. */
|
||||||
|
@Test
|
||||||
|
fun shortPauseDoesNotSplit() {
|
||||||
|
val a = saw(0, 16_000)
|
||||||
|
val pause = silence(6_400) // 0.4 с
|
||||||
|
val b = saw(16_000, 16_000)
|
||||||
|
val phrases = splitPhrases(concat(a, pause, b))
|
||||||
|
assertEquals(1, phrases.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Тихие начала слов не режутся: порог 0.005, низкоамплитудные окна не рвут фразу. */
|
||||||
|
@Test
|
||||||
|
fun quietWordStartIsKeptWhole() {
|
||||||
|
// 5 окон почти тишины (ниже порога) + резкий старт → содержимое остаётся одной фразой,
|
||||||
|
// тихие окна начала сохраняются в фразе (не выброшены).
|
||||||
|
val quietHead = silence(5 * win)
|
||||||
|
val loudStart = FloatArray(win) { 0.05f }
|
||||||
|
val speech = saw(0, win * 100) // 100 окон речи (2 с) — не объединяется как обрубок
|
||||||
|
val samples = concat(quietHead, loudStart, speech)
|
||||||
|
val phrases = splitPhrases(samples)
|
||||||
|
assertEquals(1, phrases.size)
|
||||||
|
assertTrue(phrases[0].size >= (5 + 1 + 100) * win, "тихие окна начала сохранены")
|
||||||
|
assertTrue(hasSound(phrases[0]), "фраза содержит речь")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Всё тишина → пустой список. */
|
||||||
|
@Test
|
||||||
|
fun allSilenceReturnsEmpty() {
|
||||||
|
assertEquals(0, splitPhrases(silence(48_000)).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пустой вход → пустой список. */
|
||||||
|
@Test
|
||||||
|
fun emptyInputReturnsEmpty() {
|
||||||
|
assertEquals(0, splitPhrases(FloatArray(0)).size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Фраза 31 с → разрезана на куски, каждый ≤ 30 с. */
|
||||||
|
@Test
|
||||||
|
fun phraseLongerThanThirtySecondsIsSplit() {
|
||||||
|
val long = saw(0, 31 * 16_000)
|
||||||
|
val phrases = splitPhrases(long)
|
||||||
|
assertEquals(2, phrases.size)
|
||||||
|
phrases.forEach { assertTrue(it.size <= 30 * 16_000, "кусок ≤ 30 с") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Две короткие фразы (0.8 с + 0.9 с, пауза 0.7 с) объединяются в одну. */
|
||||||
|
@Test
|
||||||
|
fun shortPhrasesAreMerged() {
|
||||||
|
val a = saw(0, 12_800) // 0.8 с = 40 окон (< 75)
|
||||||
|
val pause = silence(11_200)
|
||||||
|
val b = saw(12_800, 14_400) // 0.9 с = 45 окон (< 75)
|
||||||
|
val phrases = splitPhrases(concat(a, pause, b))
|
||||||
|
assertEquals(1, phrases.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user