STT: очки распознают речь локально и шлют готовый текст (SttText) в телефон

- glasses: локальный ASR через общий pw.binom.asr (GlassesAsr/GlassesSttSession/SttModelProvider)
- protocol: SttText — очки присылают распознанный текст в телефон; телефон кидает его в LLM
  (старый SttAudio/StopStt оставлен фолбэком для старых очей)
- phone: SttStreamer делегирует общему ASR; GlassesServer роутит SttText в LLM
- lib-core: AudioPrep общий
This commit is contained in:
2026-09-04 03:08:26 +03:00
parent 0b3f9ff975
commit 5c86ed00b4
19 changed files with 834 additions and 195 deletions
@@ -28,6 +28,7 @@ import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SttAudio
import pw.binom.viewmate.core.protocol.SttCancel
import pw.binom.viewmate.core.protocol.SttDone
import pw.binom.viewmate.core.protocol.SttText
import pw.binom.viewmate.core.protocol.StopStt
import pw.binom.viewmate.core.protocol.Welcome
import pw.binom.viewmate.core.protocol.protocolJson
@@ -235,6 +236,19 @@ class GlassesHub(
}
}
}
// Новый путь: очки распознали речь локально и прислали готовый текст —
// в LLM тем же каналом, что и StopStt(cancel=false). Old SttAudio/StopStt
// остаются фолбэком для старых очей.
is SttText -> {
val full = msg.text.trim()
if (full.isNotBlank()) {
log("stt", "STT-текст от очков (локально): $full → LLM")
onStopFullText?.invoke(full)
} else {
log("stt", "STT-текст от очков пуст — ignore")
}
}
}
}
@@ -6,169 +6,11 @@ import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
import kotlin.concurrent.withLock
import kotlin.math.sqrt
import pw.binom.viewmate.core.stt.s16leToFloat
import pw.binom.viewmate.core.stt.splitPhrases
import pw.binom.viewmate.core.stt.trimEdges
import pw.binom.viewmate.phone.log
/** s16le (16 кГц, mono) → FloatArray [-1, 1]. Чистая функция — покрыта тестами. */
internal fun s16leToFloat(pcm: ByteArray): FloatArray {
val n = pcm.size / 2
val out = FloatArray(n)
var i = 0
for (j in 0 until n) {
val lo = pcm[i].toInt() and 0xff
val hi = pcm[i + 1].toInt() and 0xff
i += 2
var s = lo or (hi shl 8)
if (s >= 0x8000) s -= 0x10000
out[j] = (s / 32768f).coerceIn(-1f, 1f)
}
return out
}
/**
* Обрезает краевую тишину в начале и конце [samples], сохраняя внутренние паузы.
* Окно [windowSize] сэмплов (320 = 20 мс @ 16 кГц) — звучным считается окно,
* чей RMS >= [threshold]. Возвращает срез от первого до последнего звучного окна
* с отступом [padWindows] окон (15 = 300 мс) в обе стороны (clamp к границам).
*
* Если звучных окон нет — пустой массив. Внутренние тишины НЕ трогаются.
* Чистая функция (без 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)
}
/**
* Режет [samples] на фразы по паузам тишины (≥ [minSilenceWindows] окон).
* Каждая фраза гарантированно ≤ [maxPhraseSamples] (whisper-лимит 30 с).
* Числа проверены на дампах микрофона очков (усиленный ×3 поток, 16 кГц) —
* НЕ менять без перепроверки на тех же дампах.
*
* Окно [windowSize] сэмплов — ТИШИНА, если RMS < [threshold] (0.005, НЕ 0.01
* как в [trimEdges]: 0.01 режет тихие начала слов на обрубки). Пауза =
* подряд ≥ [minSilenceWindows] тихих окон (0.6 с) — она РАЗДЕЛЯЕТ фразы.
* Фраза = звуковые окна между паузами + отступ [padWindows] тихих окон с
* каждой стороны (clamp к границам массива). Фраза короче
* [minPhraseWindows] (1.5 с) объединяется с соседней (обрубок); единственная
* — остаётся. Фраза длиннее [maxPhraseSamples] режется на куски ≤ 30 с по
* границе окна. Пустой вход / всё тишина → пустой список. Внутренние паузы
* &lt; 0.6 с не трогаются. Чистая функция — покрыта JVM-тестами.
*/
internal fun splitPhrases(
samples: FloatArray,
threshold: Float = 0.005f,
windowSize: Int = 320, // 20 мс @ 16 кГц
minSilenceWindows: Int = 30, // 30 окон × 20 мс = пауза 0.6 с
minPhraseWindows: Int = 75, // 1.5 с — короче объединять с соседней
maxPhraseSamples: Int = 30 * 16_000, // страховка whisper ≤ 30 с
padWindows: Int = 15, // 300 мс пре/пост-ролла тишины у фразы
): List<FloatArray> {
if (samples.isEmpty()) return emptyList()
val winCount = (samples.size + windowSize - 1) / windowSize
val isSound = BooleanArray(winCount)
var anySound = false
for (w in 0 until winCount) {
val s = w * windowSize
val e = minOf(s + windowSize, samples.size)
var sum = 0.0
for (k in s until e) sum += samples[k] * samples[k].toDouble()
if (sqrt(sum / (e - s)).toFloat() >= threshold) {
isSound[w] = true
anySound = true
}
}
if (!anySound) return emptyList()
// Диапазоны звукового содержимого фраз как индексы окон [startWin, endWin): фразы
// разграничены паузами из ≥ minSilenceWindows подряд тихих окон.
class Phrase(var start: Int, var end: Int)
val phrases = mutableListOf<Phrase>()
var pos = 0
while (pos < winCount) {
var sil = 0
while (pos + sil < winCount && !isSound[pos + sil]) sil++
if (pos + sil >= winCount) break
val start = pos + sil
var end = start
var continuePhrase = true
while (continuePhrase) {
var run = 0
while (end + run < winCount && isSound[end + run]) run++
end += run
if (end >= winCount) { continuePhrase = false; break }
var sil2 = 0
while (end + sil2 < winCount && !isSound[end + sil2]) sil2++
if (sil2 >= minSilenceWindows) {
continuePhrase = false
} else {
end += sil2
}
}
phrases.add(Phrase(start, end))
pos = end
}
// Короткая (< 1.5 с) фраза объединяется с соседней (ед. фраза — без изменений).
while (phrases.size > 1) {
val idx = phrases.indexOfFirst { it.end - it.start < minPhraseWindows }
if (idx < 0) break
val other = if (idx == 0) idx + 1 else idx - 1
val lo = minOf(idx, other)
val hi = maxOf(idx, other)
val merged = Phrase(
minOf(phrases[lo].start, phrases[hi].start),
maxOf(phrases[lo].end, phrases[hi].end),
)
phrases.removeAt(hi)
phrases.removeAt(lo)
phrases.add(lo, merged)
}
val pad = padWindows * windowSize
val out = mutableListOf<FloatArray>()
for (p in phrases) {
val s = maxOf(0, p.start * windowSize - pad)
val e = minOf(samples.size, p.end * windowSize + pad)
if (e - s <= maxPhraseSamples) {
out += samples.copyOfRange(s, e)
} else {
val chunk = (maxPhraseSamples / windowSize) * windowSize
var cs = s
while (cs < e) {
val ce = minOf(e, cs + chunk)
out += samples.copyOfRange(cs, ce)
cs = ce
}
}
}
return out
}
/**
* Стриминговое распознавание: куски PCM накапливаются без каких-либо VAD-фраз —
* распознаётся только ПОЛНЫЙ буфер сессии в [finish] (по клику) одним вызовом