fix(tts): robustesse lecture CosyVoice + pré-chauffage du cache voix

Série de correctifs après diagnostic device (logs) :
- AudioTrack : garde STATE_INITIALIZED + retry à la construction (le HAL rend
  parfois un track non initialisé après interruption + réouverture micro).
- Interruption pendant la synthèse : abandon PROPRE (check identité du track +
  play() gardé) → plus de « play() on uninitialized AudioTrack » / tour avorté.
- Drain DÉTERMINISTE de fin de lecture : on ne se fie plus à onMarkerReached
  (exige un Looper, absent sur Dispatchers.Default → ne se déclenchait jamais →
  ~6-9 s morts après l'audio). On lit playbackHeadPosition et on attend EXACTEMENT
  le reste à jouer avant de libérer → fin au plus près de la dernière syllabe,
  sans queue morte NI troncature (le flush prématuré coupait la fin).
- Pré-chauffage : warmup() synthétise une phrase jetée au démarrage pour remplir
  le cache K/V du prompt voix → 1er vrai tour chaud (~3 s) au lieu de ~10 s
  (mesuré : 1er énoncé à froid 10→8→5 s en chauffant). Idempotent par voix.
- Mutex de synthèse : warmup et vraie synthèse ne tournent jamais en parallèle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-06-21 14:42:14 +02:00
parent 7fffd3d9df
commit 76ffbcb8fd
1 changed files with 93 additions and 31 deletions

View File

@ -19,7 +19,8 @@ import android.media.AudioTrack
import com.kazeia.core.TtsEngine import com.kazeia.core.TtsEngine
import com.kazeia.core.TtsResult import com.kazeia.core.TtsResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import kotlin.coroutines.resume import kotlin.coroutines.resume
@ -58,6 +59,10 @@ class CosyVoiceTtsEngine(
private val voices = HashMap<String, Long>() private val voices = HashMap<String, Long>()
@Volatile private var currentVoiceId: String = defaultVoiceId @Volatile private var currentVoiceId: String = defaultVoiceId
@Volatile private var audioTrack: AudioTrack? = null @Volatile private var audioTrack: AudioTrack? = null
// Sérialise nativeSynthesize : le pré-chauffage (warmup) et une vraie synthèse
// ne doivent jamais tourner en parallèle sur le même handle voix.
private val synthMutex = Mutex()
@Volatile private var warmedVoices = HashSet<String>()
private fun base(): String = com.kazeia.KazeiaApplication.MODELS_DIR private fun base(): String = com.kazeia.KazeiaApplication.MODELS_DIR
private fun cvpsPath(voiceId: String): String = "${base()}/cosyvoice/$voiceId.cvps" private fun cvpsPath(voiceId: String): String = "${base()}/cosyvoice/$voiceId.cvps"
@ -124,13 +129,67 @@ class CosyVoiceTtsEngine(
val v = voiceHandleOrThrow() val v = voiceHandleOrThrow()
val out = ArrayList<Float>() val out = ArrayList<Float>()
for (s in sentences(text)) { for (s in sentences(text)) {
val pcm = CosyVoiceJni.nativeSynthesize(v, s, 1.0f) ?: continue val pcm = synthMutex.withLock { CosyVoiceJni.nativeSynthesize(v, s, 1.0f) } ?: continue
pcm.forEach { out.add(it) } pcm.forEach { out.add(it) }
} }
val shorts = ShortArray(out.size) { floatToPcm16(out[it]) } val shorts = ShortArray(out.size) { floatToPcm16(out[it]) }
TtsResult(audioData = shorts, sampleRate = SR, durationMs = shorts.size * 1000L / SR) TtsResult(audioData = shorts, sampleRate = SR, durationMs = shorts.size * 1000L / SR)
} }
/**
* Pré-chauffe le cache K/V du prompt voix : la 1ʳᵉ synthèse d'une voix remplit
* ce cache (~70 % du coût flow), d' un 1er énoncé lent (RTF ~1.9, mesuré
* 1085 s à mesure que le cache chauffe), puis RTF ~1. En synthétisant une
* phrase courte JETÉE en tâche de fond au démarrage, le 1er VRAI tour part déjà
* chaud. Idempotent par voix ; sérialisé par synthMutex (pas de course avec une
* vraie synthèse). Best-effort : un échec ne casse rien.
*/
suspend fun warmup(voiceId: String? = null) {
if (handle == 0L) return
val vid = voiceId ?: currentVoiceId
if (vid in warmedVoices) return
val v = voices[vid] ?: ensureVoice(vid)
if (v == 0L) return
withContext(Dispatchers.Default) {
val t0 = System.currentTimeMillis()
val ok = synthMutex.withLock {
runCatching { CosyVoiceJni.nativeSynthesize(v, "Bonjour, je suis là.", 1.0f) }.isSuccess
}
if (ok) { warmedVoices.add(vid); log("warmup voix '$vid' OK (${System.currentTimeMillis() - t0}ms)") }
}
}
/** Construit un AudioTrack 24 kHz mono float MODE_STREAM. Renvoie null (et
* libère) si le HAL le rend NON initialisé, pour permettre un retry au lieu
* d'un play() qui lèverait. */
private fun buildTrackOrNull(): AudioTrack? {
val minBuf = AudioTrack.getMinBufferSize(
SR, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_FLOAT
).coerceAtLeast(SR * 4) // ~2 s de marge mono float
val t = try {
AudioTrack.Builder()
.setAudioAttributes(AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build())
.setAudioFormat(AudioFormat.Builder()
.setSampleRate(SR)
.setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build())
.setBufferSizeInBytes(minBuf)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
} catch (e: Throwable) {
log("AudioTrack build a échoué: ${e.message}"); return null
}
if (t.state != AudioTrack.STATE_INITIALIZED) {
try { t.release() } catch (_: Exception) {}
return null
}
return t
}
override suspend fun synthesizeAndPlay( override suspend fun synthesizeAndPlay(
text: String, text: String,
language: String, language: String,
@ -146,22 +205,18 @@ class CosyVoiceTtsEngine(
// la 1ʳᵉ phrase synthétisée pendant que la suivante se génère (RTF>1 -> le // la 1ʳᵉ phrase synthétisée pendant que la suivante se génère (RTF>1 -> le
// streaming masque la latence). Suspend jusqu'à la FIN réelle (marker) pour // streaming masque la latence). Suspend jusqu'à la FIN réelle (marker) pour
// que le micro reste coupé pendant toute la lecture (anti-écho, cf KazeiaTts). // que le micro reste coupé pendant toute la lecture (anti-écho, cf KazeiaTts).
val minBuf = AudioTrack.getMinBufferSize( // Le HAL audio peut rendre un AudioTrack NON initialisé quand on le crée
SR, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_FLOAT // juste après une interruption TTS + réouverture micro (sortie en
).coerceAtLeast(SR * 4) // ~2 s de marge mono float // transition) → play() lèverait « uninitialized AudioTrack » et la réponse
val track = AudioTrack.Builder() // serait muette. On valide STATE_INITIALIZED et on retente brièvement.
.setAudioAttributes(AudioAttributes.Builder() val track = buildTrackOrNull() ?: run {
.setUsage(AudioAttributes.USAGE_MEDIA) kotlinx.coroutines.delay(120)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) buildTrackOrNull()
.build()) }
.setAudioFormat(AudioFormat.Builder() if (track == null) {
.setSampleRate(SR) log("AudioTrack non initialisé (HAL occupé) après retry — réponse muette")
.setEncoding(AudioFormat.ENCODING_PCM_FLOAT) onComplete?.invoke(); return
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO) }
.build())
.setBufferSizeInBytes(minBuf)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
audioTrack = track audioTrack = track
var totalFrames = 0 var totalFrames = 0
@ -170,12 +225,20 @@ class CosyVoiceTtsEngine(
try { try {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
for ((i, s) in segs.withIndex()) { for ((i, s) in segs.withIndex()) {
val pcm = CosyVoiceJni.nativeSynthesize(v, s, 1.0f) // Interruption (l'usager re-tape le micro pendant la synthèse) :
// stop() libère le track et met audioTrack=null. On le détecte par
// identité et on abandonne PROPREMENT, au lieu d'appeler play()/write
// sur un track mort → « play() on uninitialized AudioTrack ».
if (audioTrack !== track) { log("TTS interrompu pendant la synthèse — abandon"); break }
val pcm = synthMutex.withLock { CosyVoiceJni.nativeSynthesize(v, s, 1.0f) }
if (pcm == null || pcm.isEmpty()) continue if (pcm == null || pcm.isEmpty()) continue
if (audioTrack !== track) { log("TTS interrompu — pas de lecture"); break }
if (!started) { if (!started) {
started = true started = true
onStart?.invoke() onStart?.invoke()
track.play() try { track.play() } catch (e: Throwable) {
log("play() impossible (interrompu ?): ${e.message}"); break
}
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 // Callback par-phrase : la phrase précédente a drainé (write
@ -207,17 +270,16 @@ class CosyVoiceTtsEngine(
if (!started || totalFrames == 0) { stopInternal(track); onComplete?.invoke(); return } if (!started || totalFrames == 0) { stopInternal(track); onComplete?.invoke(); return }
// Attend le drain réel des échantillons écrits avant de rendre la main. // Drain DÉTERMINISTE : à ce point tout le PCM est écrit, mais les writes
suspendCancellableCoroutine<Unit> { cont -> // bloquants laissent ~1 buffer (≈1 s) ENCORE non joué. On lit la position
cont.invokeOnCancellation { stopInternal(track) } // de lecture (sans Looper — onMarkerReached ne se déclenche pas sur
track.setNotificationMarkerPosition(totalFrames) // Dispatchers.Default) pour savoir combien il reste, et on attend EXACTEMENT
track.setPlaybackPositionUpdateListener(object : AudioTrack.OnPlaybackPositionUpdateListener { // cette durée avant de libérer. PAS de stop()/flush avant la fin (ça
override fun onMarkerReached(t: AudioTrack?) { if (cont.isActive) cont.resume(Unit) } // jetterait le buffer → phrase coupée). Au plus près de la dernière syllabe,
override fun onPeriodicNotification(t: AudioTrack?) {} // sans queue morte ni troncature.
}) val played = try { track.playbackHeadPosition } catch (_: Exception) { totalFrames }
// MODE_STREAM : stop() laisse drainer ce qui est déjà écrit puis atteint le marker. val remainMs = ((totalFrames - played).coerceAtLeast(0)) * 1000L / SR
try { track.stop() } catch (_: Exception) {} if (remainMs > 0) kotlinx.coroutines.delay(remainMs + 150L) // laisse jouer le reste
}
stopInternal(track) stopInternal(track)
onComplete?.invoke() onComplete?.invoke()
} }