From ca530f6d855ad59db689b442abf3e920b2ad0d07 Mon Sep 17 00:00:00 2001 From: Kazeia Team Date: Thu, 18 Jun 2026 22:51:35 +0200 Subject: [PATCH] =?UTF-8?q?fix(pipeline):=20r=C3=A9gressions=20UI/STT=20po?= =?UTF-8?q?st-bascule=20CosyVoice=20(orbe,=20texte,=201=C3=A8re=20phrase)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../main/java/com/kazeia/core/TtsEngine.kt | 12 +- .../java/com/kazeia/service/KazeiaPipeline.kt | 6 +- .../java/com/kazeia/service/KazeiaService.kt | 39 ++++- .../main/java/com/kazeia/tts/AudioFeatures.kt | 133 ++++++++++++++++++ .../java/com/kazeia/tts/CosyVoiceTtsEngine.kt | 16 ++- 5 files changed, 196 insertions(+), 10 deletions(-) create mode 100644 kazeia-android/app/src/main/java/com/kazeia/tts/AudioFeatures.kt diff --git a/kazeia-android/app/src/main/java/com/kazeia/core/TtsEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/core/TtsEngine.kt index a4d2f60..6b44e21 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/core/TtsEngine.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/core/TtsEngine.kt @@ -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 + ) -> Unit)? = null ) fun stop() fun release() diff --git a/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaPipeline.kt b/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaPipeline.kt index 4668db8..fbe7990 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaPipeline.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaPipeline.kt @@ -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}") } diff --git a/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt b/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt index daa1f12..454a667 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/service/KazeiaService.kt @@ -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() + 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 } } diff --git a/kazeia-android/app/src/main/java/com/kazeia/tts/AudioFeatures.kt b/kazeia-android/app/src/main/java/com/kazeia/tts/AudioFeatures.kt new file mode 100644 index 0000000..f765215 --- /dev/null +++ b/kazeia-android/app/src/main/java/com/kazeia/tts/AudioFeatures.kt @@ -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> { + 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 + } + } +} diff --git a/kazeia-android/app/src/main/java/com/kazeia/tts/CosyVoiceTtsEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/tts/CosyVoiceTtsEngine.kt index b95d11e..3bb524f 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/tts/CosyVoiceTtsEngine.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/tts/CosyVoiceTtsEngine.kt @@ -125,7 +125,8 @@ class CosyVoiceTtsEngine( text: String, language: String, onStart: (() -> Unit)?, - onComplete: (() -> Unit)? + onComplete: (() -> Unit)?, + onSegment: ((String, Long, FloatArray, Array) -> 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) {