stt(phone): VAD удалён (резал рваный микрофон очков) — полный буфер + trimEdges + хвост 120с + авто-финиш по тишине 30с (opencode по ТЗ)

This commit is contained in:
2026-08-25 03:45:42 +03:00
parent 4e60e11d24
commit ac56e70283
6 changed files with 200 additions and 212 deletions
@@ -99,7 +99,7 @@ class GlassesHub(
/** /**
* Стриминговое распознавание речи с очков. Лениво создаётся через * Стриминговое распознавание речи с очков. Лениво создаётся через
* [sttFactory] (модели во filesDir/models); колбэки onPhrase/onSilence30s * [sttFactory] (модели во filesDir/models); onSilence30s/onAutoFinished
* рассылаются через [broadcast]. * рассылаются через [broadcast].
*/ */
@Volatile @Volatile
@@ -107,8 +107,8 @@ class GlassesHub(
/** /**
* Единственный путь текста в LLM: вызывает PhoneApp (assistantChannel.send) * Единственный путь текста в LLM: вызывает PhoneApp (assistantChannel.send)
* на StopStt(cancel=false) с непустым текстом. VAD-фразы в LLM НЕ уходят — * на StopStt(cancel=false) с непустым текстом. Промежуточные VAD-фразы
* только повторный клик пользователя. * отсутствуют — только полный текст (по клику или по тишине 30 с).
*/ */
@Volatile @Volatile
var onStopFullText: ((String) -> Unit)? = null 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.ChatHistoryMsg
import pw.binom.viewmate.core.protocol.ShowText import pw.binom.viewmate.core.protocol.ShowText
import pw.binom.viewmate.core.protocol.SttCancel 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.AgentToolkit
import pw.binom.viewmate.phone.agent.Assistant import pw.binom.viewmate.phone.agent.Assistant
import pw.binom.viewmate.phone.agent.ChatDao 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.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.SherpaStreamingVad
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
@@ -352,11 +351,10 @@ 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 vadModel = File(modelsDir, "silero_vad.onnx")
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")
if (!vadModel.isFile || !encoder.isFile || !decoder.isFile || !tokens.isFile) { if (!encoder.isFile || !decoder.isFile || !tokens.isFile) {
log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен") log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
return null return null
} }
@@ -373,21 +371,21 @@ class PhoneApp : Application() {
} }
val s = SttStreamer( val s = SttStreamer(
stt = whisper, stt = whisper,
vad = SherpaStreamingVad(vadModel.absolutePath),
onPhrase = { phrase, full ->
log("stt", "фраза: $phrase")
// Только показ на очках — в LLM фраза НЕ уходит (только по клику).
scope.launch { server.hub.broadcast(SttPhrase(phrase, full)) }
},
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")) }
}, },
onAutoFinished = { full ->
log("stt", "тишина 30 с — ВЕСЬ ТЕКСТ: $full")
// Тот же путь, что и результат по клику: в очки SttDone + в LLM.
scope.launch { server.hub.broadcast(SttDone(full)) }
scope.launch { assistantChannel.send(full.trim()) }
},
dumpPcm = dumpPcm, dumpPcm = dumpPcm,
) )
sttStreamer = s sttStreamer = s
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}") log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
log("stt", "SttStreamer готов (Whisper-small int8, VAD fp32)") log("stt", "SttStreamer готов (Whisper-small int8)")
return s return s
} }
} }
@@ -3,10 +3,10 @@ package pw.binom.viewmate.phone.stt
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlin.concurrent.withLock import kotlin.concurrent.withLock
import kotlin.math.sqrt
import pw.binom.viewmate.phone.log import pw.binom.viewmate.phone.log
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */ /** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
@@ -26,36 +26,70 @@ internal fun s16leToFloat(pcm: ByteArray): FloatArray {
} }
/** /**
* Стриминговое распознавание: куски PCM → VAD-фразы (Silero, окна по 512) → * Обрезает краевую тишину в начале и конце [samples], сохраняя внутренние паузы.
* Whisper (в отдельном потоке) → колбэк [onPhrase]. * Окно [windowSize] сэмплов (320 = 20 мс @ 16 кГц) — звучным считается окно,
* чей RMS >= [threshold]. Возвращает срез от первого до последнего звучного окна
* с отступом [padWindows] окон (15 = 300 мс) в обе стороны (clamp к границам).
* *
* Весь PCM сессии дополнительно накапливается без зависимости от VAD и в * Если звучных окон нет — пустой массив. Внутренние тишины НЕ трогаются.
* [finish] (по клику) распознаётся одним вызовом (хвост 30 с, если больше), * Чистая функция (без Android) — покрыта JVM-тестами.
* перезаписывая VAD-текст; VAD-фразы остаются только промежуточными */
* ([onPhrase]). 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] синхронизированы; распознавание * Потокобезопасен: [accept]/[finish]/[reset] синхронизированы; распознавание
* фраз идёт в однопоточном executor-е (не блокирует приём аудио). * идёт в однопоточном executor-е (не блокирует приём аудио).
* *
* Тишина 30 с (с момента первого [accept] либо последней фразы) → [onSilence30s] * Тишина [SILENCE_TIMEOUT_MS] (с момента первого [accept]) → автo-финиш:
* (автo-отмена, вызывается один раз за сессию). * накопленный буфер распознаётся (как [finish]) и результат отдаётся в
* [onAutoFinished], затем сессия сбрасывается и вызывается [onSilence30s]
* (автo-отмена, один раз за сессию).
*/ */
class SttStreamer( class SttStreamer(
private val stt: PhraseRecognizer, private val stt: PhraseRecognizer,
private val vad: StreamingVad,
private val onPhrase: (phrase: String, full: String) -> Unit,
private val onSilence30s: () -> Unit, private val onSilence30s: () -> Unit,
/** Полный текст сессии, распознанный автo-финишем по тишине (см. [onSilence30s]). */
private val onAutoFinished: (full: String) -> Unit,
private val dumpPcm: File? = null, private val dumpPcm: File? = null,
) { ) {
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
/** Частота сэмплирования PCM: 16 кГц, s16le, mono. */ /** Частота сэмплирования PCM: 16 кГц, s16le, mono. */
private const val SAMPLE_RATE = 16_000 private const val SAMPLE_RATE = 16_000
/** Максимум PCM для распознания по клику: 30 с (дольше — последние 30 с). */ /** Максимум PCM для распознавания: 120 с (дольше — последние 120 с). */
private val MAX_FULL_BUFFER_SAMPLES = SAMPLE_RATE * 30 private val MAX_FULL_BUFFER_SAMPLES = SAMPLE_RATE * 120
} }
private val lock = java.util.concurrent.locks.ReentrantLock() private val lock = java.util.concurrent.locks.ReentrantLock()
@@ -87,28 +121,28 @@ class SttStreamer(
}.onFailure { log("stt", "PCM-дампа: ${it.message}") }.getOrNull() }.onFailure { log("stt", "PCM-дампа: ${it.message}") }.getOrNull()
} }
/** Недозаполненные сэмплы (< 512) для следующего окна VAD. */ /** Весь PCM сессии (FloatArray @16 кГц): [finish] прогоняет его через распознаватель одним вызовом. */
private var pending = FloatArray(0)
/**
* Весь PCM сессии (FloatArray @16 кГц) — не зависит от VAD-сегментации:
* [finish] прогоняет его через распознаватель одним вызовом.
*/
private var sessionPcm: FloatArray = FloatArray(0) private var sessionPcm: FloatArray = FloatArray(0)
/** Последняя поставленная в executor задача — finish ждёт её завершения. */ /** Последняя поставленная в executor задача — finish ждёт её завершения. */
private var lastFuture: Future<*>? = null private var lastFuture: java.util.concurrent.Future<*>? = null
private val watchdog = thread(name = "stt-watchdog", isDaemon = true) { private val watchdog = thread(name = "stt-watchdog", isDaemon = true) {
while (true) { while (true) {
val deadline = deadlineMs val deadline = deadlineMs
if (deadline > 0 && System.currentTimeMillis() > deadline) { if (deadline > 0 && System.currentTimeMillis() > deadline) {
lock.withLock { // Снимаем срок и освобождаем lock до распознавания — watchdog не держит
if (deadlineMs == deadline) { // lock при вызове finish (иначе блокировка-в-блокировке c accept).
val expired = lock.withLock {
if (deadlineMs != deadline) return@withLock false
deadlineMs = 0 deadlineMs = 0
resetLocked() true
onSilence30s()
} }
if (expired) {
val fullText = finish()
if (fullText.isNotBlank()) onAutoFinished(fullText)
lock.withLock { resetLocked() }
onSilence30s()
} }
} }
runCatching { Thread.sleep(1_000) }.onFailure { return@thread } runCatching { Thread.sleep(1_000) }.onFailure { return@thread }
@@ -121,27 +155,6 @@ class SttStreamer(
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 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 val out = dumpOut
if (out != null) { if (out != null) {
@@ -151,24 +164,14 @@ class SttStreamer(
} }
/** /**
* Завершить сессию (клик): выбросить остаток буфера, дождаться всех * Завершить сессию (клик): распознать весь буфер сессии одним вызовом
* распознаваний VAD, затем распознать весь буфер сессии одним вызовом * (хвост [MAX_FULL_BUFFER_SAMPLES], если длиннее) с обрезанием краевой тишины
* (хвост 30 с, если длиннее) — [full] перезаписывается результатом * — [full] перезаписывается результатом. Вернуть полный текст.
* (не конкатенация с VAD-фразами). Вернуть полный текст. * Буферы НЕ сбрасывает — после вызова нужен [reset] (состояние сессии
* Буферы НЕ сбрасывает — после вызова нужно [reset] (состояние * снимает вызывающий).
* сессии снимает вызывающий).
*/ */
fun finish(): String { fun finish(): String {
lock.withLock { 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() recognizeFullBufferLocked()
return full return full
} }
@@ -176,16 +179,19 @@ class SttStreamer(
/** /**
* Распознать весь буфер сессии [sessionPcm] одним вызовом: целиком, * Распознать весь буфер сессии [sessionPcm] одним вызовом: целиком,
* если ≤ 30 с, иначе последние 30 с (команда перед кликом точно влезает). * если ≤ [MAX_FULL_BUFFER_SAMPLES], иначе последние [MAX_FULL_BUFFER_SAMPLES]
* Результат перезаписывает [full] — VAD-фразы идут только в [onPhrase]. * сэмплов (команда перед кликом точно влезает). Перед распознаванием
* применяется [trimEdges]. Результат перезаписывает [full].
* Вызывать с удержанным [lock]. * Вызывать с удержанным [lock].
*/ */
private fun recognizeFullBufferLocked() { private fun recognizeFullBufferLocked() {
val buffer = sessionPcm val buffer = sessionPcm
if (buffer.isEmpty()) return 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) buffer.copyOfRange(buffer.size - MAX_FULL_BUFFER_SAMPLES, buffer.size)
} else buffer } else buffer
val chunk = trimEdges(base)
if (chunk.isEmpty()) return
val sessionId = session val sessionId = session
val task = executor.submit { val task = executor.submit {
if (sessionId != session) return@submit if (sessionId != session) return@submit
@@ -203,17 +209,15 @@ class SttStreamer(
lastFuture = task lastFuture = task
runCatching { task.get(30, TimeUnit.SECONDS) } runCatching { task.get(30, TimeUnit.SECONDS) }
} }
/** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */ /** Отмена/сброс сессии: текст не выводится, таймер тишины снимается. */
fun reset() { fun reset() {
lock.withLock { resetLocked() } lock.withLock { resetLocked() }
} }
/** Освободить нативные ресурсы и сторожевой поток (при выключении приложения). */ /** Освободить ресурсы и сторожевой поток (при выключении приложения). */
fun close() { fun close() {
lock.withLock { lock.withLock { deadlineMs = 0 }
deadlineMs = 0
vad.close()
}
runCatching { dumpOut?.close() } runCatching { dumpOut?.close() }
.onFailure { log("stt", "PCM-дампа: ${it.message}") } .onFailure { log("stt", "PCM-дампа: ${it.message}") }
executor.shutdownNow() executor.shutdownNow()
@@ -221,42 +225,9 @@ class SttStreamer(
} }
private fun resetLocked() { private fun resetLocked() {
// Выбросить остаток VAD-буфера прошлой сессии, чтобы он не склеился
// с началом следующей (cancel отбрасывает текст, flush не признаётся).
runCatching { vad.flush() }
var seg = vad.popSegment()
while (seg != null) {
seg = vad.popSegment()
}
session++ session++
full = "" full = ""
pending = FloatArray(0)
sessionPcm = FloatArray(0) sessionPcm = FloatArray(0)
deadlineMs = 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.Welcome
import pw.binom.viewmate.core.protocol.protocolJson import pw.binom.viewmate.core.protocol.protocolJson
import pw.binom.viewmate.phone.stt.FakePhraseRecognizer 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.SttStreamer
import pw.binom.viewmate.phone.stt.WavPcm import pw.binom.viewmate.phone.stt.WavPcm
import kotlin.test.Test import kotlin.test.Test
@@ -202,18 +201,17 @@ class GlassesServerTest {
} }
/** /**
* Полная фраза (StopStt(cancel=false) с текстом → SttDone): реальный Silero VAD * Полная фраза (StopStt(cancel=false) с текстом → SttDone): весь принятый PCM
* (SileroStreamingVad, JVM/ONNX) сегментирует фикстуру, фейк-распознаватель * распознаётся одним вызовом на буфер сессии, фейк-распознаватель
* даёт «привет» на каждый сегмент → очки получают SttDone с непустым текстом. * даёт «привет» → очки получают SttDone с непустым текстом.
*/ */
@Test @Test
fun stopSttFullTextSendsSttDone() = runServer { port, hub -> fun stopSttFullTextSendsSttDone() = runServer { port, hub ->
hub.sttFactory = { hub.sttFactory = {
SttStreamer( SttStreamer(
stt = FakePhraseRecognizer(), stt = FakePhraseRecognizer(),
vad = SileroStreamingVad(),
onPhrase = { _, _ -> },
onSilence30s = { }, onSilence30s = { },
onAutoFinished = { },
) )
} }
val pcm = WavPcm.loadPcm(WavPcm.fixture().absolutePath) val pcm = WavPcm.loadPcm(WavPcm.fixture().absolutePath)
@@ -6,42 +6,12 @@ import kotlin.test.assertContentEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
/** /**
* Полный текст по клику (TASK-full-buffer-stt.md): [SttStreamer] накапливает * Полный текст по клику (TZ-STT): [SttStreamer] накапливает весь PCM сессии
* весь PCM сессии независимо от VAD и [SttStreamer.finish] прогоняет буфер * (без VAD) и [SttStreamer.finish] прогоняет буфер (или хвост 120 с) одним
* (или хвост 30 с) одним вызовом [PhraseRecognizer], перезаписывая text. * вызовом [PhraseRecognizer] с обрезанием краевой тишины, перезаписывая text.
* VAD-путь (промежуточные фразы в [SttStreamer.onPhrase]-колбэк) не тронут.
*/ */
class FullBufferSttTest { 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]: записывает полученные массивы, ответы — * Фейковый [PhraseRecognizer]: записывает полученные массивы, ответы —
* по одному на вызов (последний — на все лишние). [calls] читается * по одному на вызов (последний — на все лишние). [calls] читается
@@ -77,6 +47,12 @@ class FullBufferSttTest {
private fun expectedFloat(globalIndex: Int): Float = private fun expectedFloat(globalIndex: Int): Float =
(globalIndex % 32768) / 32768f (globalIndex % 32768) / 32768f
private fun newStreamer(recognizer: RecordingRecognizer) = SttStreamer(
stt = recognizer,
onSilence30s = { },
onAutoFinished = { },
)
/** 3 чанка по 3200 Б (100 мс @ 16 кГц s16le mono) = 0.3 с. */ /** 3 чанка по 3200 Б (100 мс @ 16 кГц s16le mono) = 0.3 с. */
private fun acceptShortSession(streamer: SttStreamer) { private fun acceptShortSession(streamer: SttStreamer) {
var off = 0 var off = 0
@@ -90,12 +66,7 @@ class FullBufferSttTest {
@Test @Test
fun shortBufferIsRecognizedInOneCall() { fun shortBufferIsRecognizedInOneCall() {
val recognizer = RecordingRecognizer("привет") val recognizer = RecordingRecognizer("привет")
val streamer = SttStreamer( val streamer = newStreamer(recognizer)
stt = recognizer,
vad = FakeVad(),
onPhrase = { _, _ -> },
onSilence30s = { },
)
try { try {
acceptShortSession(streamer) acceptShortSession(streamer)
val full = streamer.finish() val full = streamer.finish()
@@ -108,18 +79,13 @@ class FullBufferSttTest {
} }
} }
/** 40 с > 30 с: finish → распознаётся ровно хвост 16000*30 сэмплов, текст не пуст. */ /** 130 с > 120 с: finish → распознаётся ровно хвост 16000*120 сэмплов, текст не пуст. */
@Test @Test
fun longBufferPassesLastThirtySeconds() { fun longBufferPassesLastTwoMinutes() {
val recognizer = RecordingRecognizer("ок") val recognizer = RecordingRecognizer("ок")
val streamer = SttStreamer( val streamer = newStreamer(recognizer)
stt = recognizer,
vad = FakeVad(),
onPhrase = { _, _ -> },
onSilence30s = { },
)
try { try {
val total = 40 * 16_000 val total = 130 * 16_000
var off = 0 var off = 0
while (off < total) { while (off < total) {
streamer.accept(pcmChunk(off, 1600)) streamer.accept(pcmChunk(off, 1600))
@@ -128,9 +94,9 @@ class FullBufferSttTest {
val full = streamer.finish() val full = streamer.finish()
assertEquals(1, recognizer.calls.size) assertEquals(1, recognizer.calls.size)
val chunk = recognizer.calls[0] val chunk = recognizer.calls[0]
assertEquals(16_000 * 30, chunk.size, "ровно хвост 30 с") assertEquals(16_000 * 120, chunk.size, "ровно хвост 120 с")
assertContentEquals( assertContentEquals(
FloatArray(16_000 * 30) { expectedFloat(total - 16_000 * 30 + it) }, FloatArray(16_000 * 120) { expectedFloat(total - 16_000 * 120 + it) },
chunk, chunk,
"хвост, а не начало буфера", "хвост, а не начало буфера",
) )
@@ -144,12 +110,7 @@ class FullBufferSttTest {
@Test @Test
fun resetThenFinishSkipsRecognizer() { fun resetThenFinishSkipsRecognizer() {
val recognizer = RecordingRecognizer("привет") val recognizer = RecordingRecognizer("привет")
val streamer = SttStreamer( val streamer = newStreamer(recognizer)
stt = recognizer,
vad = FakeVad(),
onPhrase = { _, _ -> },
onSilence30s = { },
)
try { try {
acceptShortSession(streamer) acceptShortSession(streamer)
streamer.reset() streamer.reset()
@@ -160,34 +121,4 @@ class FullBufferSttTest {
streamer.close() 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 к границам, без выхода за массив")
}
}