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].
|
* рассылаются через [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).
|
||||||
deadlineMs = 0
|
val expired = lock.withLock {
|
||||||
resetLocked()
|
if (deadlineMs != deadline) return@withLock false
|
||||||
onSilence30s()
|
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 }
|
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 к границам, без выхода за массив")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user