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,
|
text: String,
|
||||||
language: String = "fr",
|
language: String = "fr",
|
||||||
onStart: (() -> Unit)? = null,
|
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 stop()
|
||||||
fun release()
|
fun release()
|
||||||
|
|
|
||||||
|
|
@ -161,8 +161,12 @@ class KazeiaPipeline {
|
||||||
_pipelineState.value = PipelineState.Speaking
|
_pipelineState.value = PipelineState.Speaking
|
||||||
try {
|
try {
|
||||||
// CosyVoice : lecture batch (synthèse N+1 pendant lecture N EN INTERNE).
|
// 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,
|
ttsEngine.synthesizeAndPlay(text, context.language,
|
||||||
onComplete = { _pipelineState.value = PipelineState.Idle })
|
onComplete = { _pipelineState.value = PipelineState.Idle },
|
||||||
|
onSegment = onSegmentPlaying)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log("TTS error: ${e.message}")
|
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 wasMuted = false
|
||||||
var lastBusyMs = 0L
|
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) {
|
while (_isListening.value) {
|
||||||
val read = recorder.read(frame, 0, frameSize)
|
val read = recorder.read(frame, 0, frameSize)
|
||||||
if (read != frameSize) continue
|
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.Transcribed
|
||||||
|| st is PipelineState.TokenGenerated
|
|| st is PipelineState.TokenGenerated
|
||||||
val nowMs = System.currentTimeMillis()
|
val nowMs = System.currentTimeMillis()
|
||||||
// Tail buffer : 800 ms après la fin du Speaking pour
|
// Tail buffer : marge anti-écho après la fin du Speaking. CosyVoice
|
||||||
// absorber la reverb / queue MediaPlayer qui peut continuer
|
// joue via AudioTrack et suspend jusqu'au marker (drain RÉEL des
|
||||||
// à émettre du son alors que pipelineState est passé Idle.
|
// é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) {
|
if (machineBusy) {
|
||||||
lastBusyMs = nowMs
|
lastBusyMs = nowMs
|
||||||
}
|
}
|
||||||
val inTailBuffer = (nowMs - lastBusyMs) < 800
|
val inTailBuffer = (nowMs - lastBusyMs) < 300
|
||||||
|
|
||||||
if (machineBusy || inTailBuffer) {
|
if (machineBusy || inTailBuffer) {
|
||||||
if (isSpeechActive) {
|
if (isSpeechActive) {
|
||||||
|
|
@ -1010,6 +1020,12 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
||||||
|
|
||||||
if (isSpeech && !inWarmup) {
|
if (isSpeech && !inWarmup) {
|
||||||
silenceFrameCount = 0
|
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++
|
speechFrameCount++
|
||||||
speechBuffer.add(frame.copyOf())
|
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
|
// No speech, reset
|
||||||
speechBuffer.clear()
|
speechBuffer.clear()
|
||||||
speechFrameCount = 0
|
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,11 +1664,13 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
||||||
try { typingJob.cancel() } catch (_: Exception) {}
|
try { typingJob.cancel() } catch (_: Exception) {}
|
||||||
_pipelineState.value = if (_isListening.value)
|
_pipelineState.value = if (_isListening.value)
|
||||||
PipelineState.Listening else PipelineState.Idle
|
PipelineState.Listening else PipelineState.Idle
|
||||||
if (!_isListening.value) {
|
// 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
|
_visualizerSignal.value = VisualizerSignal.Idle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun addMessage(message: ChatMessage) {
|
private fun addMessage(message: ChatMessage) {
|
||||||
_messages.value = _messages.value + message
|
_messages.value = _messages.value + message
|
||||||
|
|
|
||||||
|
|
@ -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,
|
text: String,
|
||||||
language: String,
|
language: String,
|
||||||
onStart: (() -> Unit)?,
|
onStart: (() -> Unit)?,
|
||||||
onComplete: (() -> Unit)?
|
onComplete: (() -> Unit)?,
|
||||||
|
onSegment: ((String, Long, FloatArray, Array<FloatArray>) -> Unit)?
|
||||||
) {
|
) {
|
||||||
val v = voiceHandleOrThrow()
|
val v = voiceHandleOrThrow()
|
||||||
val segs = sentences(text)
|
val segs = sentences(text)
|
||||||
|
|
@ -167,6 +168,19 @@ class CosyVoiceTtsEngine(
|
||||||
track.play()
|
track.play()
|
||||||
log("1er son à ${System.currentTimeMillis() - t0}ms (phrase 1/${segs.size})")
|
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.
|
// write bloquant (MODE_STREAM) : régule le débit sur la lecture.
|
||||||
var off = 0
|
var off = 0
|
||||||
while (off < pcm.size) {
|
while (off < pcm.size) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue