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].
*/
@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 к границам, без выхода за массив")
}
}