fix(pipeline): régressions UI/STT post-bascule CosyVoice (orbe, texte, 1ère phrase)

Trois bugs introduits/exposés par la bascule CosyVoice (66e81f0) :

#3 orbe figé "réflexion" même fini — KazeiaService.finally : le reset du
   visualizer était conditionné à !_isListening → en écoute continue l'orbe ne
   quittait jamais Thinking. Reset VisualizerSignal.Idle désormais inconditionnel.

#2 texte non affiché pendant le TTS + #3 orbe non animé — cause commune : le
   callback onSegmentPlaying était accepté puis JETÉ par KazeiaPipeline.speakText
   (la branche streaming Qwen3 qui le câblait avait été retirée). Rebranché :
   - core.TtsEngine.synthesizeAndPlay(..., onSegment) ;
   - CosyVoiceTtsEngine émet onSegment(phrase, durée, enveloppe, spectro) par
     phrase au démarrage de sa lecture ;
   - nouvel util tts/AudioFeatures (enveloppe RMS + spectrogramme 12 bandes via
     FFT radix-2 maison, sans dépendance) remplaçant le calcul perdu du chemin
     Qwen3 ; le reveal mot-par-mot et l'orbe Speaking refonctionnent.

#1 1ère phrase ratée — boucle de capture KazeiaService :
   - pré-roll (~300 ms d'anneau sous-seuil préfixé au buffer) → l'attaque
     consonantique n'est plus clippée ;
   - tail buffer anti-écho 800→300 ms : 800 ms visait la queue MediaPlayer
     (disparue) ; CosyVoice draine via marker AudioTrack → 300 ms suffit et ne
     mange plus le début de la réponse après que Kazeia a parlé.

Build release vert + installé OPD2415 (vc15, données préservées). À valider à
l'usage : synchro texte, orbe, capture début de phrase, absence de larsen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-06-18 22:51:35 +02:00
parent ecda7b5715
commit ca530f6d85
5 changed files with 196 additions and 10 deletions

View File

@ -11,7 +11,17 @@ interface TtsEngine {
text: String,
language: String = "fr",
onStart: (() -> Unit)? = null,
onComplete: (() -> Unit)? = null
onComplete: (() -> Unit)? = null,
// Émis quand CHAQUE phrase commence à jouer, avec son texte, sa durée
// audio, et les features visuelles (enveloppe RMS + spectrogramme N
// bandes) calculées sur son PCM. Pilote l'affichage progressif de la
// bulle et l'orbe AudioVisualizerView. Null = pas de retour par phrase.
onSegment: ((
sentence: String,
durationMs: Long,
rmsEnvelope: FloatArray,
spectrogram: Array<FloatArray>
) -> Unit)? = null
)
fun stop()
fun release()

View File

@ -161,8 +161,12 @@ class KazeiaPipeline {
_pipelineState.value = PipelineState.Speaking
try {
// CosyVoice : lecture batch (synthèse N+1 pendant lecture N EN INTERNE).
// onSegment propage le retour par-phrase (texte + features visuelles)
// pour le reveal mot-par-mot et l'orbe — perdu lors de la bascule
// CosyVoice quand la branche streaming Qwen3 a été retirée (bug #2/#3).
ttsEngine.synthesizeAndPlay(text, context.language,
onComplete = { _pipelineState.value = PipelineState.Idle })
onComplete = { _pipelineState.value = PipelineState.Idle },
onSegment = onSegmentPlaying)
} catch (e: Exception) {
log("TTS error: ${e.message}")
}

View File

@ -940,6 +940,13 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
var wasMuted = false
var lastBusyMs = 0L
// Pré-roll : ~300 ms de frames sous le seuil gardées en anneau pour
// capturer l'attaque consonantique (« b », « p », « t »). Sans ça le
// buffer speech ne démarrait qu'APRÈS le franchissement du seuil RMS,
// mangeant le début de chaque phrase (bug #1, « 1ʳᵉ phrase ratée »).
val preRoll = ArrayDeque<ShortArray>()
val preRollMax = 3
while (_isListening.value) {
val read = recorder.read(frame, 0, frameSize)
if (read != frameSize) continue
@ -958,13 +965,16 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|| st is PipelineState.Transcribed
|| st is PipelineState.TokenGenerated
val nowMs = System.currentTimeMillis()
// Tail buffer : 800 ms après la fin du Speaking pour
// absorber la reverb / queue MediaPlayer qui peut continuer
// à émettre du son alors que pipelineState est passé Idle.
// Tail buffer : marge anti-écho après la fin du Speaking. CosyVoice
// joue via AudioTrack et suspend jusqu'au marker (drain RÉEL des
// échantillons) — il n'y a plus de queue MediaPlayer à absorber.
// 800 ms (calibré pour l'ancien MediaPlayer) mangeait le début de
// la réponse de l'usager juste après que Kazeia a fini de parler
// (bug #1). 300 ms suffit pour la latence HP/DAC résiduelle.
if (machineBusy) {
lastBusyMs = nowMs
}
val inTailBuffer = (nowMs - lastBusyMs) < 800
val inTailBuffer = (nowMs - lastBusyMs) < 300
if (machineBusy || inTailBuffer) {
if (isSpeechActive) {
@ -1010,6 +1020,12 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
if (isSpeech && !inWarmup) {
silenceFrameCount = 0
if (speechFrameCount == 0 && preRoll.isNotEmpty()) {
// Début d'un candidat speech : injecter l'attaque captée
// juste avant le franchissement du seuil (sinon clippée).
for (pf in preRoll) speechBuffer.add(pf)
preRoll.clear()
}
speechFrameCount++
speechBuffer.add(frame.copyOf())
@ -1115,6 +1131,13 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
// No speech, reset
speechBuffer.clear()
speechFrameCount = 0
// Alimente le pré-roll (hors warmup) : on garde les
// dernières frames calmes pour préfixer l'attaque de la
// prochaine phrase (bug #1).
if (!inWarmup) {
preRoll.addLast(frame.copyOf())
while (preRoll.size > preRollMax) preRoll.removeFirst()
}
}
}
@ -1641,9 +1664,11 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
try { typingJob.cancel() } catch (_: Exception) {}
_pipelineState.value = if (_isListening.value)
PipelineState.Listening else PipelineState.Idle
if (!_isListening.value) {
_visualizerSignal.value = VisualizerSignal.Idle
}
// L'orbe DOIT quitter Thinking/Speaking en fin de tour, y compris en
// écoute continue — sinon il reste figé coloré (« réfléchit » alors
// qu'il a fini). La boucle de capture réémettra Listening(micRms) au
// fil des frames si on écoute encore. (Bug #3, reset jadis conditionnel.)
_visualizerSignal.value = VisualizerSignal.Idle
}
}

View File

@ -0,0 +1,133 @@
package com.kazeia.tts
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.ln
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Calcule les features visuelles d'un buffer PCM float mono pour piloter l'orbe
* [com.kazeia.ui.AudioVisualizerView] en mode Speaking :
* - enveloppe RMS (1 valeur par frame, 0..1) pulsation du contour ;
* - spectrogramme [BANDS] bandes log-espacées (0..1) modes de Fourier du bord.
*
* Sans dépendance externe (FFT radix-2 maison). Coût négligeable face à la
* synthèse neuronale CosyVoice (~quelques centaines de FFT de 1024 par phrase).
*
* Remplace l'ancien calcul de l'engine Qwen3 (archivé lors de la bascule
* CosyVoice) ; le callback par-phrase qui l'alimentait avait été perdu, ce qui
* laissait l'orbe figé et masquait le texte (cf. fix bug #2/#3).
*/
object AudioFeatures {
/** Doit rester aligné sur AudioVisualizerView.SPECTRUM_BANDS. */
const val BANDS = 12
/** @return Pair(envelope[nFrames] 0..1, spectrogram[nFrames][BANDS] 0..1). */
fun analyze(
pcm: FloatArray,
sampleRate: Int,
windowMs: Int = 40,
hopMs: Int = 20
): Pair<FloatArray, Array<FloatArray>> {
if (pcm.isEmpty()) return FloatArray(0) to emptyArray()
val hop = (sampleRate * hopMs / 1000).coerceAtLeast(1)
var win = 1
val want = (sampleRate * windowMs / 1000).coerceAtLeast(2)
while (win < want) win = win shl 1
val half = win / 2
val nFrames = ((pcm.size - 1) / hop) + 1
if (nFrames <= 0) return FloatArray(0) to emptyArray()
// Bornes de bandes log-espacées sur les bins [1 .. half].
val edges = IntArray(BANDS + 1)
val lnLo = ln(1.0); val lnHi = ln(half.toDouble())
for (b in 0..BANDS) {
val frac = b.toFloat() / BANDS
edges[b] = Math.exp(lnLo + frac * (lnHi - lnLo)).toInt().coerceIn(1, half)
}
val hann = DoubleArray(win) { 0.5 - 0.5 * cos(2.0 * PI * it / (win - 1)) }
val re = DoubleArray(win)
val im = DoubleArray(win)
val envelope = FloatArray(nFrames)
val spectro = Array(nFrames) { FloatArray(BANDS) }
var envMax = 1e-6f
var bandMax = 1e-6f
for (f in 0 until nFrames) {
val start = f * hop
var sumSq = 0.0; var count = 0
for (i in 0 until win) {
val idx = start + i
val x = if (idx < pcm.size) pcm[idx].toDouble() else 0.0
re[i] = x * hann[i]; im[i] = 0.0
if (idx < pcm.size) { sumSq += x * x; count++ }
}
val rms = if (count > 0) sqrt(sumSq / count).toFloat() else 0f
envelope[f] = rms
if (rms > envMax) envMax = rms
fft(re, im)
val row = spectro[f]
for (b in 0 until BANDS) {
val k0 = edges[b]
val k1 = edges[b + 1].coerceAtLeast(k0 + 1)
var acc = 0.0; var n = 0
var k = k0
while (k < k1 && k <= half) {
acc += sqrt(re[k] * re[k] + im[k] * im[k]); n++; k++
}
val v = if (n > 0) (acc / n).toFloat() else 0f
row[b] = v
if (v > bandMax) bandMax = v
}
}
val envInv = 1f / envMax
val bInv = 1f / bandMax
for (f in 0 until nFrames) {
envelope[f] = (envelope[f] * envInv).coerceIn(0f, 1f)
val row = spectro[f]
for (b in 0 until BANDS) row[b] = (row[b] * bInv).coerceIn(0f, 1f)
}
return envelope to spectro
}
/** FFT radix-2 in-place (Cooley-Tukey), `re.size` doit être une puissance de 2. */
private fun fft(re: DoubleArray, im: DoubleArray) {
val n = re.size
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
j = j or bit
if (i < j) {
val tr = re[i]; re[i] = re[j]; re[j] = tr
val ti = im[i]; im[i] = im[j]; im[j] = ti
}
}
var len = 2
while (len <= n) {
val ang = -2.0 * PI / len
val wlr = cos(ang); val wli = sin(ang)
var i = 0
while (i < n) {
var wr = 1.0; var wi = 0.0
val halfLen = len / 2
for (k in 0 until halfLen) {
val ur = re[i + k]; val ui = im[i + k]
val a = re[i + k + halfLen]; val b = im[i + k + halfLen]
val vr = a * wr - b * wi
val vi = a * wi + b * wr
re[i + k] = ur + vr; im[i + k] = ui + vi
re[i + k + halfLen] = ur - vr; im[i + k + halfLen] = ui - vi
val nwr = wr * wlr - wi * wli
wi = wr * wli + wi * wlr; wr = nwr
}
i += len
}
len = len shl 1
}
}
}

View File

@ -125,7 +125,8 @@ class CosyVoiceTtsEngine(
text: String,
language: String,
onStart: (() -> Unit)?,
onComplete: (() -> Unit)?
onComplete: (() -> Unit)?,
onSegment: ((String, Long, FloatArray, Array<FloatArray>) -> Unit)?
) {
val v = voiceHandleOrThrow()
val segs = sentences(text)
@ -167,6 +168,19 @@ class CosyVoiceTtsEngine(
track.play()
log("1er son à ${System.currentTimeMillis() - t0}ms (phrase 1/${segs.size})")
}
// Callback par-phrase : la phrase précédente a drainé (write
// bloquant ci-dessous), donc on est ~au démarrage audio de
// celle-ci. Pilote le reveal texte + l'orbe. Best-effort :
// une erreur de features ne casse pas la lecture.
onSegment?.let { cb ->
val durMs = pcm.size * 1000L / SR
val (env, spec) = try {
AudioFeatures.analyze(pcm, SR)
} catch (e: Throwable) {
log("features err: ${e.message}"); FloatArray(0) to emptyArray()
}
if (env.isNotEmpty() && spec.isNotEmpty()) cb(s, durMs, env, spec)
}
// write bloquant (MODE_STREAM) : régule le débit sur la lecture.
var off = 0
while (off < pcm.size) {