TTS: keep BigVGAN on CPU after GPU regression; LLM filter strips more tags

#2 BigVGAN GPU experiment: ORT-QNN GPU EP loaded the v2_decoder_conv ONNX
model successfully (session creation 463 ms, no fallback warnings) but
per-phrase inference jumped to ~3.5 s vs ~2 s on CPU 8-thread. The GPU/CPU
memory transfer cost dominates for this conv-heavy decoder, and the
optimization went the wrong way. Comment block updated to record both the
HTP and GPU paths as tried-and-rejected so future passes don't re-walk the
same ground.

LLM streaming filter: extend the lookahead-based <think>…</think>
suppressor to also strip singleton special tokens (<|im_start|>,
<|im_end|>, <|endoftext|>). Previously the closing <|im_end|> at end of
the assistant's turn leaked into the SentenceStreamer and ended up as a
spurious sentence at the end of the TTS output. Same lookahead-buffer
trick handles split tokens.

Validated end-to-end: 'Bonjour, comment vas-tu ?' → "Bonjour ! Je vais
bien, merci. Comment vas-tu ?" → seg 0 "Bonjour !", seg 1 "Je vais bien,
merci." (no <|im_end|>), BigVGAN back to 1.8 s/phrase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-04-14 13:48:37 +02:00
parent f4b15a72a7
commit a41619ed67
2 changed files with 27 additions and 8 deletions

View File

@ -115,29 +115,43 @@ class ExecuTorchLlmEngine(
var inThink = false
val tokenScan = StringBuilder() // small lookahead to spot tag boundaries
// Singleton special tokens that should never reach the TTS streamer
// (they leak when the model wraps its reply or signals end-of-turn).
val stripTokens = listOf("<|im_start|>", "<|im_end|>", "<|endoftext|>")
val maxTagLen = listOf("<think>", "</think>", "<|im_start|>", "<|im_end|>", "<|endoftext|>")
.maxOf { it.length }
val cb = object : LlmCallback {
override fun onResult(result: String) {
if (firstTokenMs < 0) firstTokenMs = System.currentTimeMillis() - startTime
responseBuilder.append(result)
// Forward to caller only outside <think> blocks. We accumulate
// a tiny lookahead buffer so tag tokens that arrive split
// ("<thi", "nk>") still match.
// Forward to caller only outside <think> blocks, and strip
// singleton special tokens. We accumulate a tiny lookahead buffer
// so tag tokens that arrive split ("<thi", "nk>") still match.
tokenScan.append(result)
while (true) {
if (!inThink) {
val open = tokenScan.indexOf("<think>")
if (open < 0) {
// No tag pending — flush everything up to a safe point
// (length minus 7 for the longest tag we look for).
val safe = tokenScan.length - "<think>".length
// No <think> open pending — strip any singleton tokens
// that fully landed in the buffer, then flush prose
// up to a safe point preserving lookahead.
for (tok in stripTokens) {
var idx = tokenScan.indexOf(tok)
while (idx >= 0) {
tokenScan.delete(idx, idx + tok.length)
idx = tokenScan.indexOf(tok)
}
}
val safe = tokenScan.length - maxTagLen
if (safe > 0) {
onToken?.invoke(tokenScan.substring(0, safe))
tokenScan.delete(0, safe)
}
break
}
// Flush the prose before the tag, then enter think mode.
// Flush the prose before the <think> tag, then enter think mode.
if (open > 0) onToken?.invoke(tokenScan.substring(0, open))
tokenScan.delete(0, open + "<think>".length)
inThink = true

View File

@ -243,7 +243,12 @@ class Qwen3TtsEngine(
return session
}
// Speech decoder V2 on CPU (HTP tested: BigVGAN convolutions too slow to compile)
// Speech decoder V2 on CPU. Two paths tried, both worse than CPU:
// - HTP: BigVGAN convolutions too slow to compile (timeout)
// - GPU Adreno via QNN GPU EP: model loads but per-phrase
// inference is ~3.5 s vs ~2 s on CPU (GPU/CPU memory transfer
// overhead dominates for this conv-heavy model)
// CPU 8-thread stays the practical optimum.
val v2Path = "$path/v2_pre_conv"
if (File("$v2Path/model.onnx").exists()) {
nlog("Loading V2 speech decoder (CPU ONNX)...")