diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt index ea6b6d3..c55e0ee 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt @@ -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 diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index bb7e76e..41e567b 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -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 } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt index 8873139..7aca193 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttStreamer.kt @@ -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) - } - } } diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt index 6b993b5..e624a9c 100644 --- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/GlassesServerTest.kt @@ -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) diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FullBufferSttTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FullBufferSttTest.kt index db6d3a4..8c103ba 100644 --- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FullBufferSttTest.kt +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/FullBufferSttTest.kt @@ -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() - 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>() - 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() - } - } } diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/TrimEdgesTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/TrimEdgesTest.kt new file mode 100644 index 0000000..8945908 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/stt/TrimEdgesTest.kt @@ -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 к границам, без выхода за массив") + } +}