diff --git a/kazeia-android/app/src/main/java/com/kazeia/tts/CosyVoiceTtsEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/tts/CosyVoiceTtsEngine.kt index a6c7b08..82ac850 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/tts/CosyVoiceTtsEngine.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/tts/CosyVoiceTtsEngine.kt @@ -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() @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() 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() 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'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( 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 { 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() }