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].
|
||||
*/
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user