stt(phone): VAD удалён (резал рваный микрофон очков) — полный буфер + trimEdges + хвост 120с + авто-финиш по тишине 30с (opencode по ТЗ)
This commit is contained in:
@@ -99,7 +99,7 @@ class GlassesHub(
|
||||
|
||||
/**
|
||||
* Стриминговое распознавание речи с очков. Лениво создаётся через
|
||||
* [sttFactory] (модели во filesDir/models); колбэки onPhrase/onSilence30s
|
||||
* [sttFactory] (модели во filesDir/models); onSilence30s/onAutoFinished
|
||||
* рассылаются через [broadcast].
|
||||
*/
|
||||
@Volatile
|
||||
@@ -107,8 +107,8 @@ class GlassesHub(
|
||||
|
||||
/**
|
||||
* Единственный путь текста в LLM: вызывает PhoneApp (assistantChannel.send)
|
||||
* на StopStt(cancel=false) с непустым текстом. VAD-фразы в LLM НЕ уходят —
|
||||
* только повторный клик пользователя.
|
||||
* на StopStt(cancel=false) с непустым текстом. Промежуточные VAD-фразы
|
||||
* отсутствуют — только полный текст (по клику или по тишине 30 с).
|
||||
*/
|
||||
@Volatile
|
||||
var onStopFullText: ((String) -> Unit)? = null
|
||||
|
||||
@@ -27,7 +27,7 @@ import pw.binom.viewmate.core.protocol.ChatHistoryItem
|
||||
import pw.binom.viewmate.core.protocol.ChatHistoryMsg
|
||||
import pw.binom.viewmate.core.protocol.ShowText
|
||||
import pw.binom.viewmate.core.protocol.SttCancel
|
||||
import pw.binom.viewmate.core.protocol.SttPhrase
|
||||
import pw.binom.viewmate.core.protocol.SttDone
|
||||
import pw.binom.viewmate.phone.agent.AgentToolkit
|
||||
import pw.binom.viewmate.phone.agent.Assistant
|
||||
import pw.binom.viewmate.phone.agent.ChatDao
|
||||
@@ -46,7 +46,6 @@ import pw.binom.viewmate.phone.agent.RemoteLlmClient
|
||||
import pw.binom.viewmate.phone.agent.SkillRegistry
|
||||
import pw.binom.viewmate.phone.agent.SkillRepository
|
||||
import pw.binom.viewmate.phone.agent.ToolsetRegistry
|
||||
import pw.binom.viewmate.phone.stt.SherpaStreamingVad
|
||||
import pw.binom.viewmate.phone.stt.SttStreamer
|
||||
import pw.binom.viewmate.phone.stt.WhisperStt
|
||||
|
||||
@@ -352,11 +351,10 @@ class PhoneApp : Application() {
|
||||
synchronized(sttLock) {
|
||||
sttStreamer?.let { return it }
|
||||
val modelsDir = File(filesDir, "models")
|
||||
val vadModel = File(modelsDir, "silero_vad.onnx")
|
||||
val encoder = File(modelsDir, "small-encoder.int8.onnx")
|
||||
val decoder = File(modelsDir, "small-decoder.int8.onnx")
|
||||
val tokens = File(modelsDir, "small-tokens.txt")
|
||||
if (!vadModel.isFile || !encoder.isFile || !decoder.isFile || !tokens.isFile) {
|
||||
if (!encoder.isFile || !decoder.isFile || !tokens.isFile) {
|
||||
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
|
||||
return null
|
||||
}
|
||||
@@ -373,21 +371,21 @@ class PhoneApp : Application() {
|
||||
}
|
||||
val s = SttStreamer(
|
||||
stt = whisper,
|
||||
vad = SherpaStreamingVad(vadModel.absolutePath),
|
||||
onPhrase = { phrase, full ->
|
||||
log("stt", "фраза: $phrase")
|
||||
// Только показ на очках — в LLM фраза НЕ уходит (только по клику).
|
||||
scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) }
|
||||
},
|
||||
onSilence30s = {
|
||||
log("stt", "тишина 30 с — автo-отмена")
|
||||
scope.launch { server.hub.broadcast(SttCancel(reason = "timeout")) }
|
||||
},
|
||||
onAutoFinished = { full ->
|
||||
log("stt", "тишина 30 с — ВЕСЬ ТЕКСТ: $full")
|
||||
// Тот же путь, что и результат по клику: в очки SttDone + в LLM.
|
||||
scope.launch { server.hub.broadcast(SttDone(full)) }
|
||||
scope.launch { assistantChannel.send(full.trim()) }
|
||||
},
|
||||
dumpPcm = dumpPcm,
|
||||
)
|
||||
sttStreamer = s
|
||||
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
|
||||
log("stt", "SttStreamer готов (Whisper-small int8, VAD fp32)")
|
||||
log("stt", "SttStreamer готов (Whisper-small int8)")
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ package pw.binom.viewmate.phone.stt
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlin.math.sqrt
|
||||
import pw.binom.viewmate.phone.log
|
||||
|
||||
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
|
||||
@@ -26,36 +26,70 @@ internal fun s16leToFloat(pcm: ByteArray): FloatArray {
|
||||
}
|
||||
|
||||
/**
|
||||
* Стриминговое распознавание: куски PCM → VAD-фразы (Silero, окна по 512) →
|
||||
* Whisper (в отдельном потоке) → колбэк [onPhrase].
|
||||
* Обрезает краевую тишину в начале и конце [samples], сохраняя внутренние паузы.
|
||||
* Окно [windowSize] сэмплов (320 = 20 мс @ 16 кГц) — звучным считается окно,
|
||||
* чей RMS >= [threshold]. Возвращает срез от первого до последнего звучного окна
|
||||
* с отступом [padWindows] окон (15 = 300 мс) в обе стороны (clamp к границам).
|
||||
*
|
||||
* Весь PCM сессии дополнительно накапливается без зависимости от VAD и в
|
||||
* [finish] (по клику) распознаётся одним вызовом (хвост 30 с, если больше),
|
||||
* перезаписывая VAD-текст; VAD-фразы остаются только промежуточными
|
||||
* ([onPhrase]).
|
||||
* Если звучных окон нет — пустой массив. Внутренние тишины НЕ трогаются.
|
||||
* Чистая функция (без Android) — покрыта JVM-тестами.
|
||||
*/
|
||||
internal fun trimEdges(
|
||||
samples: FloatArray,
|
||||
threshold: Float = 0.01f,
|
||||
windowSize: Int = 320,
|
||||
padWindows: Int = 15,
|
||||
): FloatArray {
|
||||
if (samples.isEmpty()) return samples
|
||||
var first = -1
|
||||
var lastEnd = -1
|
||||
var idx = 0
|
||||
while (idx < samples.size) {
|
||||
val end = minOf(idx + windowSize, samples.size)
|
||||
var sum = 0.0
|
||||
for (k in idx until end) sum += samples[k] * samples[k].toDouble()
|
||||
val rms = sqrt(sum / (end - idx)).toFloat()
|
||||
if (rms >= threshold) {
|
||||
if (first == -1) first = idx
|
||||
lastEnd = end
|
||||
}
|
||||
idx = end
|
||||
}
|
||||
if (first == -1) return FloatArray(0)
|
||||
val pad = padWindows * windowSize
|
||||
val start = maxOf(0, first - pad)
|
||||
val stop = minOf(samples.size, lastEnd + pad)
|
||||
return samples.copyOfRange(start, stop)
|
||||
}
|
||||
|
||||
/**
|
||||
* Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз —
|
||||
* распознаётся только ПОЛНЫЙ буфер сессии в [finish] (по клику) одним вызовом
|
||||
* [PhraseRecognizer] с предварительным обрезанием краевой тишины ([trimEdges]).
|
||||
*
|
||||
* Потокобезопасен: [accept]/[finish]/[reset] синхронизированы; распознавание
|
||||
* фраз идёт в однопоточном executor-е (не блокирует приём аудио).
|
||||
* идёт в однопоточном executor-е (не блокирует приём аудио).
|
||||
*
|
||||
* Тишина 30 с (с момента первого [accept] либо последней фразы) → [onSilence30s]
|
||||
* (автo-отмена, вызывается один раз за сессию).
|
||||
* Тишина [SILENCE_TIMEOUT_MS] (с момента первого [accept]) → автo-финиш:
|
||||
* накопленный буфер распознаётся (как [finish]) и результат отдаётся в
|
||||
* [onAutoFinished], затем сессия сбрасывается и вызывается [onSilence30s]
|
||||
* (автo-отмена, один раз за сессию).
|
||||
*/
|
||||
class SttStreamer(
|
||||
private val stt: PhraseRecognizer,
|
||||
private val vad: StreamingVad,
|
||||
private val onPhrase: (phrase: String, full: String) -> Unit,
|
||||
private val onSilence30s: () -> Unit,
|
||||
/** Полный текст сессии, распознанный автo-финишем по тишине (см. [onSilence30s]). */
|
||||
private val onAutoFinished: (full: String) -> Unit,
|
||||
private val dumpPcm: File? = null,
|
||||
) {
|
||||
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
|
||||
/** Максимум PCM для распознавания: 120 с (дольше — последние 120 с). */
|
||||
private val MAX_FULL_BUFFER_SAMPLES = SAMPLE_RATE * 120
|
||||
}
|
||||
|
||||
private val lock = java.util.concurrent.locks.ReentrantLock()
|
||||
@@ -87,28 +121,28 @@ class SttStreamer(
|
||||
}.onFailure { log("stt", "PCM-дампа: ${it.message}") }.getOrNull()
|
||||
}
|
||||
|
||||
/** Недозаполненные сэмплы (< 512) для следующего окна VAD. */
|
||||
private var pending = FloatArray(0)
|
||||
|
||||
/**
|
||||
* Весь PCM сессии (FloatArray @16 кГц) — не зависит от VAD-сегментации:
|
||||
* [finish] прогоняет его через распознаватель одним вызовом.
|
||||
*/
|
||||
/** Весь PCM сессии (FloatArray @16 кГц): [finish] прогоняет его через распознаватель одним вызовом. */
|
||||
private var sessionPcm: FloatArray = FloatArray(0)
|
||||
|
||||
/** Последняя поставленная в executor задача — finish ждёт её завершения. */
|
||||
private var lastFuture: Future<*>? = null
|
||||
private var lastFuture: java.util.concurrent.Future<*>? = null
|
||||
|
||||
private val watchdog = thread(name = "stt-watchdog", isDaemon = true) {
|
||||
while (true) {
|
||||
val deadline = deadlineMs
|
||||
if (deadline > 0 && System.currentTimeMillis() > deadline) {
|
||||
lock.withLock {
|
||||
if (deadlineMs == deadline) {
|
||||
deadlineMs = 0
|
||||
resetLocked()
|
||||
onSilence30s()
|
||||
}
|
||||
// Снимаем срок и освобождаем lock до распознавания — watchdog не держит
|
||||
// lock при вызове finish (иначе блокировка-в-блокировке c accept).
|
||||
val expired = lock.withLock {
|
||||
if (deadlineMs != deadline) return@withLock false
|
||||
deadlineMs = 0
|
||||
true
|
||||
}
|
||||
if (expired) {
|
||||
val fullText = finish()
|
||||
if (fullText.isNotBlank()) onAutoFinished(fullText)
|
||||
lock.withLock { resetLocked() }
|
||||
onSilence30s()
|
||||
}
|
||||
}
|
||||
runCatching { Thread.sleep(1_000) }.onFailure { return@thread }
|
||||
@@ -121,27 +155,6 @@ class SttStreamer(
|
||||
if (deadlineMs == 0L) deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
|
||||
val floats = s16leToFloat(pcm)
|
||||
sessionPcm = sessionPcm + floats
|
||||
var i = 0
|
||||
|
||||
if (pending.isNotEmpty()) {
|
||||
val need = WINDOW_SIZE - pending.size
|
||||
val take = minOf(need, floats.size)
|
||||
val combined = pending + floats.copyOfRange(0, take)
|
||||
i += take
|
||||
if (combined.size == WINDOW_SIZE) {
|
||||
feedWindow(combined)
|
||||
pending = FloatArray(0)
|
||||
} else {
|
||||
pending = combined
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
while (i + WINDOW_SIZE <= floats.size) {
|
||||
feedWindow(floats.copyOfRange(i, i + WINDOW_SIZE))
|
||||
i += WINDOW_SIZE
|
||||
}
|
||||
if (i < floats.size) pending = floats.copyOfRange(i, floats.size)
|
||||
}
|
||||
val out = dumpOut
|
||||
if (out != null) {
|
||||
@@ -151,24 +164,14 @@ class SttStreamer(
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершить сессию (клик): выбросить остаток буфера, дождаться всех
|
||||
* распознаваний VAD, затем распознать весь буфер сессии одним вызовом
|
||||
* (хвост 30 с, если длиннее) — [full] перезаписывается результатом
|
||||
* (не конкатенация с VAD-фразами). Вернуть полный текст.
|
||||
* Буферы НЕ сбрасывает — после вызова нужно [reset] (состояние
|
||||
* сессии снимает вызывающий).
|
||||
* Завершить сессию (клик): распознать весь буфер сессии одним вызовом
|
||||
* (хвост [MAX_FULL_BUFFER_SAMPLES], если длиннее) с обрезанием краевой тишины
|
||||
* — [full] перезаписывается результатом. Вернуть полный текст.
|
||||
* Буферы НЕ сбрасывает — после вызова нужен [reset] (состояние сессии
|
||||
* снимает вызывающий).
|
||||
*/
|
||||
fun finish(): String {
|
||||
lock.withLock {
|
||||
vad.flush()
|
||||
var seg = vad.popSegment()
|
||||
while (seg != null) {
|
||||
// Копия: массив может переиспользоваться после pop.
|
||||
recognizeSegment(seg.samples.copyOf())
|
||||
seg = vad.popSegment()
|
||||
}
|
||||
val last = lastFuture
|
||||
if (last != null) runCatching { last.get(30, TimeUnit.SECONDS) }
|
||||
recognizeFullBufferLocked()
|
||||
return full
|
||||
}
|
||||
@@ -176,16 +179,19 @@ class SttStreamer(
|
||||
|
||||
/**
|
||||
* Распознать весь буфер сессии [sessionPcm] одним вызовом: целиком,
|
||||
* если ≤ 30 с, иначе последние 30 с (команда перед кликом точно влезает).
|
||||
* Результат перезаписывает [full] — VAD-фразы идут только в [onPhrase].
|
||||
* если ≤ [MAX_FULL_BUFFER_SAMPLES], иначе последние [MAX_FULL_BUFFER_SAMPLES]
|
||||
* сэмплов (команда перед кликом точно влезает). Перед распознаванием
|
||||
* применяется [trimEdges]. Результат перезаписывает [full].
|
||||
* Вызывать с удержанным [lock].
|
||||
*/
|
||||
private fun recognizeFullBufferLocked() {
|
||||
val buffer = sessionPcm
|
||||
if (buffer.isEmpty()) return
|
||||
val chunk = if (buffer.size > MAX_FULL_BUFFER_SAMPLES) {
|
||||
val base = if (buffer.size > MAX_FULL_BUFFER_SAMPLES) {
|
||||
buffer.copyOfRange(buffer.size - MAX_FULL_BUFFER_SAMPLES, buffer.size)
|
||||
} else buffer
|
||||
val chunk = trimEdges(base)
|
||||
if (chunk.isEmpty()) return
|
||||
val sessionId = session
|
||||
val task = executor.submit {
|
||||
if (sessionId != session) return@submit
|
||||
@@ -203,17 +209,15 @@ class SttStreamer(
|
||||
lastFuture = task
|
||||
runCatching { task.get(30, TimeUnit.SECONDS) }
|
||||
}
|
||||
|
||||
/** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */
|
||||
fun reset() {
|
||||
lock.withLock { resetLocked() }
|
||||
}
|
||||
|
||||
/** Освободить нативные ресурсы и сторожевой поток (при выключении приложения). */
|
||||
/** Освободить ресурсы и сторожевой поток (при выключении приложения). */
|
||||
fun close() {
|
||||
lock.withLock {
|
||||
deadlineMs = 0
|
||||
vad.close()
|
||||
}
|
||||
lock.withLock { deadlineMs = 0 }
|
||||
runCatching { dumpOut?.close() }
|
||||
.onFailure { log("stt", "PCM-дампа: ${it.message}") }
|
||||
executor.shutdownNow()
|
||||
@@ -221,42 +225,9 @@ class SttStreamer(
|
||||
}
|
||||
|
||||
private fun resetLocked() {
|
||||
// Выбросить остаток VAD-буфера прошлой сессии, чтобы он не склеился
|
||||
// с началом следующей (cancel отбрасывает текст, flush не признаётся).
|
||||
runCatching { vad.flush() }
|
||||
var seg = vad.popSegment()
|
||||
while (seg != null) {
|
||||
seg = vad.popSegment()
|
||||
}
|
||||
session++
|
||||
full = ""
|
||||
pending = FloatArray(0)
|
||||
sessionPcm = FloatArray(0)
|
||||
deadlineMs = 0
|
||||
}
|
||||
|
||||
/** Подать окно ровно по 512 сэмплов в VAD и забрать готовые сегменты. */
|
||||
private fun feedWindow(window: FloatArray) {
|
||||
vad.acceptWindow(window)
|
||||
var seg = vad.popSegment()
|
||||
while (seg != null) {
|
||||
// Копия: массив может переиспользоваться после pop.
|
||||
recognizeSegment(seg.samples.copyOf())
|
||||
seg = vad.popSegment()
|
||||
}
|
||||
}
|
||||
|
||||
/** Распознать сегмент в executor-е: обновить full и позвать onPhrase. */
|
||||
private fun recognizeSegment(samples: FloatArray) {
|
||||
val sessionId = session
|
||||
lastFuture = executor.submit {
|
||||
if (sessionId != session) return@submit
|
||||
val phrase = runCatching { stt.recognize(samples) }.getOrDefault("")
|
||||
if (phrase.isBlank()) return@submit
|
||||
val newFull = if (full.isEmpty()) phrase else "$full $phrase"
|
||||
full = newFull
|
||||
deadlineMs = System.currentTimeMillis() + SILENCE_TIMEOUT_MS
|
||||
onPhrase(phrase, newFull)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import pw.binom.viewmate.core.protocol.SttDone
|
||||
import pw.binom.viewmate.core.protocol.Welcome
|
||||
import pw.binom.viewmate.core.protocol.protocolJson
|
||||
import pw.binom.viewmate.phone.stt.FakePhraseRecognizer
|
||||
import pw.binom.viewmate.phone.stt.SileroStreamingVad
|
||||
import pw.binom.viewmate.phone.stt.SttStreamer
|
||||
import pw.binom.viewmate.phone.stt.WavPcm
|
||||
import kotlin.test.Test
|
||||
@@ -202,18 +201,17 @@ class GlassesServerTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная фраза (StopStt(cancel=false) с текстом → SttDone): реальный Silero VAD
|
||||
* (SileroStreamingVad, JVM/ONNX) сегментирует фикстуру, фейк-распознаватель
|
||||
* даёт «привет» на каждый сегмент → очки получают SttDone с непустым текстом.
|
||||
* Полная фраза (StopStt(cancel=false) с текстом → SttDone): весь принятый PCM
|
||||
* распознаётся одним вызовом на буфер сессии, фейк-распознаватель
|
||||
* даёт «привет» → очки получают SttDone с непустым текстом.
|
||||
*/
|
||||
@Test
|
||||
fun stopSttFullTextSendsSttDone() = runServer { port, hub ->
|
||||
hub.sttFactory = {
|
||||
SttStreamer(
|
||||
stt = FakePhraseRecognizer(),
|
||||
vad = SileroStreamingVad(),
|
||||
onPhrase = { _, _ -> },
|
||||
onSilence30s = { },
|
||||
onAutoFinished = { },
|
||||
)
|
||||
}
|
||||
val pcm = WavPcm.loadPcm(WavPcm.fixture().absolutePath)
|
||||
|
||||
@@ -6,42 +6,12 @@ import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Полный текст по клику (TASK-full-buffer-stt.md): [SttStreamer] накапливает
|
||||
* весь PCM сессии независимо от VAD и [SttStreamer.finish] прогоняет буфер
|
||||
* (или хвост 30 с) одним вызовом [PhraseRecognizer], перезаписывая text.
|
||||
* VAD-путь (промежуточные фразы в [SttStreamer.onPhrase]-колбэк) не тронут.
|
||||
* Полный текст по клику (TZ-STT): [SttStreamer] накапливает весь PCM сессии
|
||||
* (без VAD) и [SttStreamer.finish] прогоняет буфер (или хвост 120 с) одним
|
||||
* вызовом [PhraseRecognizer] с обрезанием краевой тишины, перезаписывая text.
|
||||
*/
|
||||
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] читается
|
||||
@@ -77,6 +47,12 @@ class FullBufferSttTest {
|
||||
private fun expectedFloat(globalIndex: Int): Float =
|
||||
(globalIndex % 32768) / 32768f
|
||||
|
||||
private fun newStreamer(recognizer: RecordingRecognizer) = SttStreamer(
|
||||
stt = recognizer,
|
||||
onSilence30s = { },
|
||||
onAutoFinished = { },
|
||||
)
|
||||
|
||||
/** 3 чанка по 3200 Б (100 мс @ 16 кГц s16le mono) = 0.3 с. */
|
||||
private fun acceptShortSession(streamer: SttStreamer) {
|
||||
var off = 0
|
||||
@@ -90,12 +66,7 @@ class FullBufferSttTest {
|
||||
@Test
|
||||
fun shortBufferIsRecognizedInOneCall() {
|
||||
val recognizer = RecordingRecognizer("привет")
|
||||
val streamer = SttStreamer(
|
||||
stt = recognizer,
|
||||
vad = FakeVad(),
|
||||
onPhrase = { _, _ -> },
|
||||
onSilence30s = { },
|
||||
)
|
||||
val streamer = newStreamer(recognizer)
|
||||
try {
|
||||
acceptShortSession(streamer)
|
||||
val full = streamer.finish()
|
||||
@@ -108,18 +79,13 @@ class FullBufferSttTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** 40 с > 30 с: finish → распознаётся ровно хвост 16000*30 сэмплов, текст не пуст. */
|
||||
/** 130 с > 120 с: finish → распознаётся ровно хвост 16000*120 сэмплов, текст не пуст. */
|
||||
@Test
|
||||
fun longBufferPassesLastThirtySeconds() {
|
||||
fun longBufferPassesLastTwoMinutes() {
|
||||
val recognizer = RecordingRecognizer("ок")
|
||||
val streamer = SttStreamer(
|
||||
stt = recognizer,
|
||||
vad = FakeVad(),
|
||||
onPhrase = { _, _ -> },
|
||||
onSilence30s = { },
|
||||
)
|
||||
val streamer = newStreamer(recognizer)
|
||||
try {
|
||||
val total = 40 * 16_000
|
||||
val total = 130 * 16_000
|
||||
var off = 0
|
||||
while (off < total) {
|
||||
streamer.accept(pcmChunk(off, 1600))
|
||||
@@ -128,9 +94,9 @@ class FullBufferSttTest {
|
||||
val full = streamer.finish()
|
||||
assertEquals(1, recognizer.calls.size)
|
||||
val chunk = recognizer.calls[0]
|
||||
assertEquals(16_000 * 30, chunk.size, "ровно хвост 30 с")
|
||||
assertEquals(16_000 * 120, chunk.size, "ровно хвост 120 с")
|
||||
assertContentEquals(
|
||||
FloatArray(16_000 * 30) { expectedFloat(total - 16_000 * 30 + it) },
|
||||
FloatArray(16_000 * 120) { expectedFloat(total - 16_000 * 120 + it) },
|
||||
chunk,
|
||||
"хвост, а не начало буфера",
|
||||
)
|
||||
@@ -144,12 +110,7 @@ class FullBufferSttTest {
|
||||
@Test
|
||||
fun resetThenFinishSkipsRecognizer() {
|
||||
val recognizer = RecordingRecognizer("привет")
|
||||
val streamer = SttStreamer(
|
||||
stt = recognizer,
|
||||
vad = FakeVad(),
|
||||
onPhrase = { _, _ -> },
|
||||
onSilence30s = { },
|
||||
)
|
||||
val streamer = newStreamer(recognizer)
|
||||
try {
|
||||
acceptShortSession(streamer)
|
||||
streamer.reset()
|
||||
@@ -160,34 +121,4 @@ class FullBufferSttTest {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package pw.binom.viewmate.phone.stt
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Тесты чистой функции [trimEdges] (TZ-STT): краевая тишина убирается,
|
||||
* внутренние паузы сохраняются, отступ не выходит за границы.
|
||||
*
|
||||
* Окно 320 сэмплов = 20 мс @ 16 кГц; отступ [padWindows]=15 → 4800 сэмплов = 300 мс.
|
||||
* «Речь» — сэмпл 0.5f (RMS окна 0.5 ≥ порог 0.01), «тишина» — 0.005f (ниже порога).
|
||||
*/
|
||||
class TrimEdgesTest {
|
||||
private val win = 320
|
||||
private val pad = 15 * 320
|
||||
|
||||
/** Массив из [count] сэмплов заданного уровня. */
|
||||
private fun fill(count: Int, level: Float): FloatArray = FloatArray(count) { level }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** Краевая тишина (100 окон до и после речи в 3 окна) убирается, речь сохраняется. */
|
||||
@Test
|
||||
fun edgeSilenceIsTrimmed() {
|
||||
val lead = fill(100 * win, 0.005f)
|
||||
val voice = fill(3 * win, 0.5f)
|
||||
val tail = fill(100 * win, 0.005f)
|
||||
val samples = concat(lead, voice, tail)
|
||||
|
||||
val res = trimEdges(samples)
|
||||
|
||||
val start = 100 * win - pad
|
||||
val stop = 103 * win + pad
|
||||
assertContentEquals(samples.copyOfRange(start, stop), res, "краевая тишина обрезана, речь сохранена")
|
||||
}
|
||||
|
||||
/** Внутренняя пауза (197 окон тишины между двумя речевыми блоками) полностью сохраняется. */
|
||||
@Test
|
||||
fun internalPauseIsPreserved() {
|
||||
val lead = fill(100 * win, 0.005f)
|
||||
val voice1 = fill(3 * win, 0.5f)
|
||||
val pause = fill(197 * win, 0.005f)
|
||||
val voice2 = fill(3 * win, 0.5f)
|
||||
val tail = fill(100 * win, 0.005f)
|
||||
val samples = concat(lead, voice1, pause, voice2, tail)
|
||||
|
||||
val res = trimEdges(samples)
|
||||
|
||||
val start = 100 * win - pad
|
||||
val stop = 303 * win + pad
|
||||
assertContentEquals(samples.copyOfRange(start, stop), res, "внутренняя пауза не выброшена")
|
||||
}
|
||||
|
||||
/** Пустой вход → пустой выход (без ошибок). */
|
||||
@Test
|
||||
fun emptyInputReturnsEmpty() {
|
||||
assertEquals(0, trimEdges(FloatArray(0)).size)
|
||||
}
|
||||
|
||||
/** Всё-тишина (ниже порога) → пустой массив. */
|
||||
@Test
|
||||
fun allSilenceReturnsEmpty() {
|
||||
val res = trimEdges(FloatArray(100 * win) { 0.005f })
|
||||
assertEquals(0, res.size, "звучных окон нет — результат пуст")
|
||||
}
|
||||
|
||||
/** Отступ не выходит за границы: речь у краёв короткого буфера → весь массив без исключений. */
|
||||
@Test
|
||||
fun paddingClampsToBounds() {
|
||||
val size = 2000
|
||||
val samples = FloatArray(size)
|
||||
for (i in 0 until win) samples[i] = 0.5f
|
||||
for (i in size - win until size) samples[i] = 0.5f
|
||||
|
||||
val res = trimEdges(samples)
|
||||
|
||||
assertContentEquals(samples.copyOfRange(0, size), res, "clamp к границам, без выхода за массив")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user