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:
parent
ecda7b5715
commit
ca530f6d85
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue