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.TtsResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.coroutines.resume
@ -58,6 +59,10 @@ class CosyVoiceTtsEngine(
private val voices = HashMap<String, Long>()
@Volatile private var currentVoiceId: String = defaultVoiceId
@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 cvpsPath(voiceId: String): String = "${base()}/cosyvoice/$voiceId.cvps"
@ -124,13 +129,67 @@ class CosyVoiceTtsEngine(
val v = voiceHandleOrThrow()
val out = ArrayList<Float>()
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) }
}
val shorts = ShortArray(out.size) { floatToPcm16(out[it]) }
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(
text: 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
// 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).
val minBuf = AudioTrack.getMinBufferSize(
SR, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_FLOAT
).coerceAtLeast(SR * 4) // ~2 s de marge mono float
val track = 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()
// Le HAL audio peut rendre un AudioTrack NON initialisé quand on le crée
// juste après une interruption TTS + réouverture micro (sortie en
// transition) → play() lèverait « uninitialized AudioTrack » et la réponse
// serait muette. On valide STATE_INITIALIZED et on retente brièvement.
val track = buildTrackOrNull() ?: run {
kotlinx.coroutines.delay(120)
buildTrackOrNull()
}
if (track == null) {
log("AudioTrack non initialisé (HAL occupé) après retry — réponse muette")
onComplete?.invoke(); return
}
audioTrack = track
var totalFrames = 0
@ -170,12 +225,20 @@ class CosyVoiceTtsEngine(
try {
withContext(Dispatchers.Default) {
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 (audioTrack !== track) { log("TTS interrompu — pas de lecture"); break }
if (!started) {
started = true
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})")
}
// 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 }
// Attend le drain réel des échantillons écrits avant de rendre la main.
suspendCancellableCoroutine<Unit> { cont ->
cont.invokeOnCancellation { stopInternal(track) }
track.setNotificationMarkerPosition(totalFrames)
track.setPlaybackPositionUpdateListener(object : AudioTrack.OnPlaybackPositionUpdateListener {
override fun onMarkerReached(t: AudioTrack?) { if (cont.isActive) cont.resume(Unit) }
override fun onPeriodicNotification(t: AudioTrack?) {}
})
// MODE_STREAM : stop() laisse drainer ce qui est déjà écrit puis atteint le marker.
try { track.stop() } catch (_: Exception) {}
}
// Drain DÉTERMINISTE : à ce point tout le PCM est écrit, mais les writes
// bloquants laissent ~1 buffer (≈1 s) ENCORE non joué. On lit la position
// de lecture (sans Looper — onMarkerReached ne se déclenche pas sur
// Dispatchers.Default) pour savoir combien il reste, et on attend EXACTEMENT
// cette durée avant de libérer. PAS de stop()/flush avant la fin (ça
// jetterait le buffer → phrase coupée). Au plus près de la dernière syllabe,
// sans queue morte ni troncature.
val played = try { track.playbackHeadPosition } catch (_: Exception) { totalFrames }
val remainMs = ((totalFrames - played).coerceAtLeast(0)) * 1000L / SR
if (remainMs > 0) kotlinx.coroutines.delay(remainMs + 150L) // laisse jouer le reste
stopInternal(track)
onComplete?.invoke()
}