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:
parent
7fffd3d9df
commit
76ffbcb8fd
|
|
@ -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'où un 1er énoncé lent (RTF ~1.9, mesuré
|
||||||
|
* 10→8→5 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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue