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)
}
}
}