refactor(tts): bascule CosyVoice = seul TTS + purge legacy (≈322 Mo libs)

Bascule définitive vers CosyVoice (clonage vocal 9 langues, CPU-only) :
- ConfigStore défaut ttsEngine = "cosyvoice" ; KazeiaService dispatch CosyVoice
  uniquement ; chemins TTS-B/streaming Qwen3 retirés (CosyVoice = batch
  speakText qui streame en interne) ; KazeiaPipeline.speakText simplifié.

Purge du superseded (archivé /opt/Kazeia/archive/2026-06-18_cosyvoice-bascule/) :
- Kotlin tts/ : Qwen3TtsEngine, KazeiaTtsEngine, TtsEngine(lib), TtsPipeline,
  Qwen3TtsGgmlDecoder, TtsTalkerCpuJni, TtsCpCpuJni, NeonOps, BenchTtsHarness,
  Qwen3BpeTokenizer. (Gardés : CosyVoiceTtsEngine, SentenceStreamer, core.TtsEngine.)
- v2/ (rewrite abandonné) + entrées manifest ChatActivityV2/KazeiaServiceV2.
- Handlers d'intents dev Qwen3 (run_pipeline/stream_*/full_pipeline/decode_codes).
- jniLibs (-322 Mo) : libexecutorch_jni(185M), libexecutorch, libtts_pipeline(51M),
  libQnnGpu*(3), libkazeia_tts, libfbjni, libllama-common(74M).
- CMake : neon_ops/tts_talker_cpu/tts_cp_cpu/tts_decoder_ggml + ggml/llama IMPORTED
  (gardé : mel_extractor pour STT).
- Gradle : executorch.jar + fbjni + soloader.
- Manifest jniLibs re-figé (27→18 libs).

Survivants prod : LLM GGUF (kazeia_engine+llama+ggml) + LLM .pte (kazeia_pte+QnnHtp)
+ STT (kazeia_stt+ORT+mel_extractor) + TTS (cosyvoice). Build debug+release vert.

Bump 0.2.2 / versionCode 14.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-06-18 09:07:41 +02:00
parent 3d002318d5
commit 66e81f0668
29 changed files with 40 additions and 8450 deletions

View File

@ -44,8 +44,8 @@ android {
applicationId = "com.kazeia" applicationId = "com.kazeia"
minSdk = 28 minSdk = 28
targetSdk = 36 targetSdk = 36
versionCode = 13 versionCode = 14
versionName = "0.2.1" versionName = "0.2.2"
// WebDAV de distribution — injecté depuis local.properties. // WebDAV de distribution — injecté depuis local.properties.
buildConfigField("String", "WEBDAV_BASE", "\"${localProp("webdav.base")}\"") buildConfigField("String", "WEBDAV_BASE", "\"${localProp("webdav.base")}\"")
@ -157,10 +157,9 @@ dependencies {
implementation("com.qualcomm.qti:qnn-litert-delegate:2.44.0") implementation("com.qualcomm.qti:qnn-litert-delegate:2.44.0")
implementation("com.qualcomm.qti:qnn-runtime:2.44.0") implementation("com.qualcomm.qti:qnn-runtime:2.44.0")
// ExecuTorch JNI dependencies (for TTS CP on NPU) // (ExecuTorch jar + fbjni + soloader retirés 2026-06-18 : seul le TTS legacy
implementation("com.facebook.fbjni:fbjni:0.7.0") // Qwen3 les utilisait, supprimé avec la bascule CosyVoice. Le LLM .pte passe
implementation("com.facebook.soloader:nativeloader:0.10.5") // par libkazeia_pte autonome, pas par org.pytorch.executorch.)
implementation(files("libs/executorch.jar"))
// Tokeniseur HF (DJL) — pour PteLlmEngine (.pte NPU) : tokenise le ChatML Qwen // Tokeniseur HF (DJL) — pour PteLlmEngine (.pte NPU) : tokenise le ChatML Qwen
// côté Kotlin puis passe les IDs au natif (tokeniseur natif de l'engine cassé pour Qwen). // côté Kotlin puis passe les IDs au natif (tokeniseur natif de l'engine cassé pour Qwen).

View File

@ -74,24 +74,6 @@
android:value="AI inference service for emotional support chatbot" /> android:value="AI inference service for emotional support chatbot" />
</service> </service>
<!-- v2 rewrite : ChatActivityV2 test page + KazeiaServiceV2 (Phase 1 STT) -->
<activity
android:name=".v2.ChatActivityV2"
android:exported="true"
android:label="Kazeia v2"
android:screenOrientation="unspecified"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<service
android:name=".v2.KazeiaServiceV2"
android:foregroundServiceType="microphone|mediaPlayback|specialUse"
android:exported="false" />
<!-- WorkManager : déclare le type FGS dataSync sur son service foreground <!-- WorkManager : déclare le type FGS dataSync sur son service foreground
(requis API 34+ pour le worker de téléchargement modèles). --> (requis API 34+ pour le worker de téléchargement modèles). -->
<service <service

View File

@ -66,13 +66,6 @@ class KazeiaApplication : Application() {
trigLlm.delete() trigLlm.delete()
Thread { com.kazeia.llm.BenchLlmHarness.runBench(this@KazeiaApplication) }.start() Thread { com.kazeia.llm.BenchLlmHarness.runBench(this@KazeiaApplication) }.start()
} }
// Bench TTS (lib CPU-only) : `touch <files>/bench_tts.trigger`, relance app.
// Charge libkazeia_tts + 1 synthèse FR vers WAV.
val trigTts = java.io.File(getExternalFilesDir(null), "bench_tts.trigger")
if (trigTts.exists()) {
trigTts.delete()
Thread { com.kazeia.tts.BenchTtsHarness.runBench(this@KazeiaApplication) }.start()
}
} }
private fun createNotificationChannel() { private fun createNotificationChannel() {

View File

@ -165,7 +165,7 @@ class ConfigStore private constructor(private val file: File) {
ttsEnabled = true, ttsEnabled = true,
sttEngine = "prod", sttEngine = "prod",
llmEngine = "prod", llmEngine = "prod",
ttsEngine = "lib" // legacy "prod" = SIGSEGV avec le libllama fork ql embarqué ttsEngine = "cosyvoice" // bascule 2026-06-18 : CosyVoice = seul TTS prod
) )
} }

View File

@ -160,26 +160,9 @@ class KazeiaPipeline {
val ttsEngine = tts ?: return val ttsEngine = tts ?: return
_pipelineState.value = PipelineState.Speaking _pipelineState.value = PipelineState.Speaking
try { try {
val qwen = ttsEngine as? com.kazeia.tts.Qwen3TtsEngine // CosyVoice : lecture batch (synthèse N+1 pendant lecture N EN INTERNE).
if (qwen != null) { ttsEngine.synthesizeAndPlay(text, context.language,
qwen.onSegmentPlaying = onSegmentPlaying onComplete = { _pipelineState.value = PipelineState.Idle })
qwen.startStreamingSession()
val streamer = com.kazeia.tts.SentenceStreamer { raw ->
// Strip emoji / non-speakable pictographs before TTS
// so a standalone "😊" doesn't become its own noisy
// segment. The chat bubble keeps the original text —
// only the audio path sees the cleaned version.
val spoken = stripNonSpeakable(raw).trim()
if (spoken.isNotEmpty()) qwen.enqueueSentence(spoken)
}
streamer.append(text)
streamer.flush()
qwen.endStreamingSession()
} else {
ttsEngine.synthesizeAndPlay(text, context.language,
onComplete = { _pipelineState.value = PipelineState.Idle }
)
}
} catch (e: Exception) { } catch (e: Exception) {
log("TTS error: ${e.message}") log("TTS error: ${e.message}")
} }

View File

@ -277,290 +277,6 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
override fun onBind(intent: Intent?): IBinder = binder override fun onBind(intent: Intent?): IBinder = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Auto-pipeline trigger via: am startservice -n com.kazeia/.service.KazeiaService --ez run_pipeline true
if (intent?.getBooleanExtra("run_pipeline", false) == true) {
log("Auto-pipeline triggered via intent")
// Cancel any running pipeline to avoid blocking
currentPipelineJob?.cancel()
currentPipelineJob = serviceScope.launch(Dispatchers.IO) {
// Wait for TTS to be loaded
while (!::tts.isInitialized || tts !is com.kazeia.tts.Qwen3TtsEngine) {
kotlinx.coroutines.delay(500)
}
val embedsPath = "/data/local/tmp/kazeia/models/qwen3-tts-npu/full_pipeline_embeds.bin"
val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine ?: return@launch
val audio = qwenTts.generateFromEmbeds(embedsPath)
log("Pipeline done: ${audio.size} samples (${audio.size/24000f}s)")
// Audio is played by the TTS engine internally
}
}
intent?.getStringExtra("stream_llm_mock")?.let { text ->
// Stage 3 validation path without a live LLM: feed the given text
// to the SentenceStreamer word-by-word with a short artificial
// inter-token pause so we can observe the "sentence N plays
// while sentence N+1 is generating" behaviour end-to-end. Used
// in debug builds where the LLM is disabled (echo mode).
// Optional companion extra `tts_voice` selects voice prefix file
// before generation (used by chantier ggml-cpu reference dump).
val voiceOverride = intent.getStringExtra("tts_voice")
log("Stream LLM mock: '${text.take(60)}${if (text.length>60) "..." else ""}'${voiceOverride?.let { " voice=$it" } ?: ""}")
serviceScope.launch {
try {
val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine
if (qwenTts == null) {
// Backend non-Qwen3 (ex. KazeiaTtsEngine lib) : pas de
// streaming par phrase, on synthétise le texte complet
// via l'interface et on joue. Sert au test E2E du wiring.
if (!::tts.isInitialized) { log("Stream mock: TTS not ready"); return@launch }
_pipelineState.value = PipelineState.Speaking
val tS = System.currentTimeMillis()
tts.synthesizeAndPlay(text, "fr",
onComplete = {
log("Stream mock (lib) done at ${System.currentTimeMillis() - tS}ms")
_pipelineState.value = PipelineState.Idle
})
return@launch
}
if (voiceOverride != null) {
qwenTts.setVoice(voiceOverride)
log("Voice set to: $voiceOverride")
}
qwenTts.startStreamingSession()
val tStart = System.currentTimeMillis()
var firstSentenceLogged = false
val streamer = com.kazeia.tts.SentenceStreamer { sentence ->
if (!firstSentenceLogged) {
log("First sentence at ${System.currentTimeMillis() - tStart}ms: '${sentence.take(60)}'")
firstSentenceLogged = true
}
qwenTts.enqueueSentence(sentence)
}
// Fake LLM: word by word, 50 ms per word. Real LLM on
// device emits ~15-25 tokens/s which is similar.
for (word in text.split(" ")) {
streamer.append("$word ")
kotlinx.coroutines.delay(50)
}
streamer.flush()
qwenTts.endStreamingSession()
log("Stream LLM mock done at ${System.currentTimeMillis() - tStart}ms")
} catch (e: Exception) {
log("Stream LLM mock error: ${e.message}")
e.printStackTrace()
}
}
}
intent?.getStringExtra("stream_llm")?.let { prompt ->
// Stage 3 end-to-end: LLM generates tokens → SentenceStreamer
// splits at terminal punctuation → each sentence is enqueued
// to the TTS streaming session → audio plays while the LLM
// keeps generating the next sentence. Removes the old "wait
// for full response, then start TTS" pattern which gave a
// long silence after the user stopped speaking.
log("Stream LLM: '${prompt.take(60)}${if (prompt.length>60) "..." else ""}'")
serviceScope.launch {
try {
val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine ?: run {
log("Stream LLM: TTS is not Qwen3 engine, abort"); return@launch
}
if (!::llm.isInitialized || !llm.isLoaded()) {
log("Stream LLM: LLM not ready"); return@launch
}
// Set pipeline state to Speaking so the continuous-
// listening mic loop (line ~824) drops frames during
// TTS playback. Without this, the mic picks up the
// tablet speaker and feeds our own TTS back into STT,
// creating an infinite loop.
_pipelineState.value = PipelineState.Speaking
qwenTts.startStreamingSession()
val tStart = System.currentTimeMillis()
var firstSentenceLogged = false
val streamer = com.kazeia.tts.SentenceStreamer { sentence ->
if (!firstSentenceLogged) {
log("First sentence at ${System.currentTimeMillis() - tStart}ms: '${sentence.take(60)}'")
firstSentenceLogged = true
}
qwenTts.enqueueSentence(sentence)
}
val result = llm.generate(
prompt = prompt,
params = com.kazeia.core.SamplingParams(maxNewTokens = 120, temperature = 0.7f),
onToken = { token ->
streamer.append(token)
true // keep generating
}
)
streamer.flush()
log("LLM done: ${result.tokenCount} tokens in ${result.timeMs}ms")
qwenTts.endStreamingSession()
log("Stream LLM total: ${System.currentTimeMillis() - tStart}ms")
} catch (e: Exception) {
log("Stream LLM error: ${e.message}")
e.printStackTrace()
} finally {
// Back to Idle so the next mic frame is accepted.
_pipelineState.value = PipelineState.Idle
}
}
}
intent?.getStringExtra("stream_text")?.let { text ->
// Stage 2 streaming from arbitrary text: BPE tokenize on-device,
// look up embeds in the full Qwen3 vocab, run the existing
// interleaved Hexagon generation loop, and play each segment
// as soon as it's decoded. No PC-side prep required.
log("Stream text: '${text.take(60)}${if (text.length>60) "..." else ""}'")
serviceScope.launch {
try {
val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine ?: return@launch
val sr = 24000
val track = android.media.AudioTrack.Builder()
.setAudioAttributes(android.media.AudioAttributes.Builder()
.setUsage(android.media.AudioAttributes.USAGE_MEDIA)
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SPEECH)
.build())
.setAudioFormat(android.media.AudioFormat.Builder()
.setEncoding(android.media.AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sr)
.setChannelMask(android.media.AudioFormat.CHANNEL_OUT_MONO)
.build())
.setBufferSizeInBytes(sr * 4)
.setTransferMode(android.media.AudioTrack.MODE_STREAM)
.build()
track.play()
val tStart = System.currentTimeMillis()
var firstLogged = false
qwenTts.synthesizeTextStreaming(text) { segIdx, audio ->
if (!firstLogged) {
log("First audio out at ${System.currentTimeMillis() - tStart}ms (seg ${segIdx+1})")
firstLogged = true
}
track.write(audio, 0, audio.size)
}
track.stop(); track.release()
log("Stream text done at ${System.currentTimeMillis() - tStart}ms")
} catch (e: Exception) {
log("Stream text error: ${e.message}")
e.printStackTrace()
}
}
}
intent?.getStringExtra("stream_pipeline")?.let { embedsPath ->
// Stage 1 streaming pipeline: generate segment-by-segment and play each
// segment the moment it's ready via an AudioTrack MODE_STREAM. First audio
// arrives ~5s after request (time to generate 1 segment) instead of ~15s
// for the whole phrase. Per-segment WAVs + concatenated full WAV are
// written to /data/local/tmp/kazeia/kazeia_stream_seg*.wav and _full.wav
// by the engine itself — the service only handles playback.
log("Stream pipeline from pre-computed embeds: $embedsPath")
serviceScope.launch {
try {
val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine ?: return@launch
val sr = 24000
val track = android.media.AudioTrack.Builder()
.setAudioAttributes(android.media.AudioAttributes.Builder()
.setUsage(android.media.AudioAttributes.USAGE_MEDIA)
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SPEECH)
.build())
.setAudioFormat(android.media.AudioFormat.Builder()
.setEncoding(android.media.AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sr)
.setChannelMask(android.media.AudioFormat.CHANNEL_OUT_MONO)
.build())
.setBufferSizeInBytes(sr * 4) // 2s mono pcm16 buffer, plenty for seg handoff
.setTransferMode(android.media.AudioTrack.MODE_STREAM)
.build()
track.play()
val tStart = System.currentTimeMillis()
var firstAudioLogged = false
qwenTts.generateFromEmbedsHexagonStreaming(embedsPath) { segIdx, audio ->
if (!firstAudioLogged) {
log("First audio out at ${System.currentTimeMillis() - tStart}ms (seg ${segIdx+1})")
firstAudioLogged = true
}
track.write(audio, 0, audio.size)
}
// Let AudioTrack drain the written samples before releasing.
track.stop()
track.release()
log("Stream pipeline done at ${System.currentTimeMillis() - tStart}ms")
} catch (e: Exception) {
log("Stream pipeline error: ${e.message}")
e.printStackTrace()
}
}
}
intent?.getStringExtra("full_pipeline")?.let { embedsPath ->
val savePath = intent.getStringExtra("save_wav") ?: "/data/local/tmp/kazeia/tts_output.wav"
log("Full pipeline from pre-computed embeds: $embedsPath")
serviceScope.launch {
try {
val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine
if (qwenTts != null) {
val audio = qwenTts.generateFromEmbeds(embedsPath)
if (audio.isNotEmpty()) {
val f = java.io.File(savePath)
java.io.DataOutputStream(java.io.BufferedOutputStream(java.io.FileOutputStream(f))).use { out ->
val dataSize = audio.size * 2
out.writeBytes("RIFF"); out.writeInt(Integer.reverseBytes(36 + dataSize))
out.writeBytes("WAVEfmt ")
out.writeInt(Integer.reverseBytes(16))
out.writeShort(java.lang.Short.reverseBytes(1).toInt())
out.writeShort(java.lang.Short.reverseBytes(1).toInt())
out.writeInt(Integer.reverseBytes(24000))
out.writeInt(Integer.reverseBytes(48000))
out.writeShort(java.lang.Short.reverseBytes(2).toInt())
out.writeShort(java.lang.Short.reverseBytes(16).toInt())
out.writeBytes("data"); out.writeInt(Integer.reverseBytes(dataSize))
val bb = java.nio.ByteBuffer.allocate(dataSize).order(java.nio.ByteOrder.LITTLE_ENDIAN)
for (s in audio) bb.putShort(s)
out.write(bb.array())
}
log("Full pipeline WAV: $savePath (${audio.size} samples, ${audio.size/24000f}s)")
} else {
log("Full pipeline: empty audio")
}
}
} catch (e: Exception) {
log("Full pipeline error: ${e.message}")
e.printStackTrace()
}
}
}
intent?.getStringExtra("decode_codes")?.let { codesPath ->
val savePath = intent.getStringExtra("save_wav") ?: "/data/local/tmp/kazeia/tts_output.wav"
val realTokens = intent.getIntExtra("real_tokens", 58)
log("Decoding precomputed codes: $codesPath$savePath ($realTokens tokens)")
serviceScope.launch {
try {
val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine
if (qwenTts != null) {
val audio = qwenTts.testWithPrecomputedCodes(codesPath, realTokens)
log("Decoded: ${audio.size} samples, ${audio.size * 1000L / 24000}ms")
// Write WAV
val f = java.io.File(savePath)
java.io.DataOutputStream(java.io.BufferedOutputStream(java.io.FileOutputStream(f))).use { out ->
val dataSize = audio.size * 2
out.writeBytes("RIFF"); out.writeInt(Integer.reverseBytes(36 + dataSize))
out.writeBytes("WAVEfmt ")
out.writeInt(Integer.reverseBytes(16))
out.writeShort(java.lang.Short.reverseBytes(1).toInt())
out.writeShort(java.lang.Short.reverseBytes(1).toInt())
out.writeInt(Integer.reverseBytes(24000))
out.writeInt(Integer.reverseBytes(48000))
out.writeShort(java.lang.Short.reverseBytes(2).toInt())
out.writeShort(java.lang.Short.reverseBytes(16).toInt())
out.writeBytes("data"); out.writeInt(Integer.reverseBytes(dataSize))
val bb = java.nio.ByteBuffer.allocate(dataSize).order(java.nio.ByteOrder.LITTLE_ENDIAN)
for (s in audio) bb.putShort(s)
out.write(bb.array())
}
log("WAV saved: $savePath (${audio.size} samples)")
}
} catch (e: Exception) {
log("Decode error: ${e.message}")
}
}
}
intent?.getStringExtra("tts_text")?.let { text -> intent?.getStringExtra("tts_text")?.let { text ->
val savePath = intent.getStringExtra("save_wav") val savePath = intent.getStringExtra("save_wav")
if (savePath != null) { if (savePath != null) {
@ -751,38 +467,15 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
val nativeLibDir = applicationInfo.nativeLibraryDir val nativeLibDir = applicationInfo.nativeLibraryDir
// Dispatch backend TTS via ttsEngine. "prod" = Qwen3TtsEngine legacy // TTS = CosyVoice (seul moteur depuis la bascule 2026-06-18 ; les
// (incompat ABI avec libllama fork-ql -> SIGSEGV sur install fraîche, // moteurs legacy Qwen3-TTS/lib ont été retirés). Clonage vocal 9
// cf feedback_tts_engine_default_lib). "lib" = KazeiaTtsEngine // langues, CPU-only, RTF ~1.0.
// (libkazeia_tts CPU). "cosyvoice" = CosyVoiceTtsEngine (clonage vocal _loadingState.value = LoadingState(15, "TTS CosyVoice…")
// 9 langues, CPU-only) — NON validé device tant que la collision val cv = com.kazeia.tts.CosyVoiceTtsEngine(log = { msg -> log("[TTS] $msg") })
// libggml n'est pas levée côté libs engine (cf CosyVoiceTtsEngine). cv.load()
when (runtimeConfig.ttsEngine) { if (!cv.isLoaded()) throw IllegalStateException("CosyVoice load failed")
"cosyvoice" -> { tts = cv
_loadingState.value = LoadingState(15, "TTS CosyVoice…") log("TTS: CosyVoiceTtsEngine loaded (clonage vocal CPU-only)")
val cv = com.kazeia.tts.CosyVoiceTtsEngine(log = { msg -> log("[TTS] $msg") })
cv.load()
if (!cv.isLoaded()) throw IllegalStateException("CosyVoice load failed")
tts = cv
log("TTS: CosyVoiceTtsEngine loaded (clonage vocal CPU-only)")
}
"lib" -> {
_loadingState.value = LoadingState(15, "TTS engine…")
val libTts = com.kazeia.tts.KazeiaTtsEngine(this@KazeiaService) { msg -> log("[TTS] $msg") }
libTts.load()
if (!libTts.isLoaded()) throw IllegalStateException("KazeiaTtsEngine load failed")
tts = libTts
log("TTS: KazeiaTtsEngine loaded (libkazeia_tts CPU-only)")
}
else -> {
_loadingState.value = LoadingState(15, "TTS Qwen3…")
val qwenTts = com.kazeia.tts.Qwen3TtsEngine(nativeLibDir, this@KazeiaService) { msg -> log("[TTS] $msg") }
qwenTts.load("$modelsDir/qwen3-tts-npu")
if (!qwenTts.isLoaded()) throw IllegalStateException("Qwen3-TTS load failed (no fallback in frozen stack)")
tts = qwenTts
log("TTS: Qwen3-TTS loaded (Hexagon NPU + ggml-cpu)")
}
}
_loadingState.value = LoadingState(20, "TTS OK") _loadingState.value = LoadingState(20, "TTS OK")
// STT = Whisper-Small NPU (HfWhisper Qualcomm) — voie validée // STT = Whisper-Small NPU (HfWhisper Qualcomm) — voie validée
@ -1009,8 +702,6 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
val wavPath = "${KazeiaApplication.MODELS_DIR}/../voix/$voiceId.wav" val wavPath = "${KazeiaApplication.MODELS_DIR}/../voix/$voiceId.wav"
when (val e = tts) { when (val e = tts) {
is com.kazeia.tts.CosyVoiceTtsEngine -> { e.setVoice(voiceId); log("Voix (cosyvoice): $voiceId") } is com.kazeia.tts.CosyVoiceTtsEngine -> { e.setVoice(voiceId); log("Voix (cosyvoice): $voiceId") }
is com.kazeia.tts.Qwen3TtsEngine -> { e.setVoice(wavPath); log("Voix (qwen3): $voiceId") }
is com.kazeia.tts.KazeiaTtsEngine -> { e.setVoice(wavPath); log("Voix (lib): $voiceId") }
else -> log("setVoiceId: moteur TTS inconnu — voix '$voiceId' ignorée") else -> log("setVoiceId: moteur TTS inconnu — voix '$voiceId' ignorée")
} }
} }
@ -1633,41 +1324,6 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
metrics.begin("text", text) metrics.begin("text", text)
} }
serviceScope.launch { serviceScope.launch {
// Special test commands — cancel previous pipeline first
if (text.trim().lowercase().let { it.startsWith("pipeline") || it.startsWith("!pipeline") || it.startsWith("\\!pipeline") || it == "go" }) {
currentPipelineJob?.cancel()
currentPipelineJob = kotlin.coroutines.coroutineContext[kotlinx.coroutines.Job]
val embedsPath = "/data/local/tmp/kazeia/models/qwen3-tts-npu/full_pipeline_embeds.bin"
val wavPath = "/data/local/tmp/kazeia/tts_output.wav"
addMessage(ChatMessage(role = ChatMessage.Role.SYSTEM, text = "Running full pipeline..."))
try {
val qwenTts = tts as? com.kazeia.tts.Qwen3TtsEngine
val audio = qwenTts?.generateFromEmbeds(embedsPath) ?: ShortArray(0)
if (audio.isNotEmpty()) {
val f = java.io.File(wavPath)
java.io.DataOutputStream(java.io.BufferedOutputStream(java.io.FileOutputStream(f))).use { out ->
val dataSize = audio.size * 2
out.writeBytes("RIFF"); out.writeInt(Integer.reverseBytes(36 + dataSize))
out.writeBytes("WAVEfmt "); out.writeInt(Integer.reverseBytes(16))
out.writeShort(java.lang.Short.reverseBytes(1).toInt())
out.writeShort(java.lang.Short.reverseBytes(1).toInt())
out.writeInt(Integer.reverseBytes(24000)); out.writeInt(Integer.reverseBytes(48000))
out.writeShort(java.lang.Short.reverseBytes(2).toInt())
out.writeShort(java.lang.Short.reverseBytes(16).toInt())
out.writeBytes("data"); out.writeInt(Integer.reverseBytes(dataSize))
val bb = java.nio.ByteBuffer.allocate(dataSize).order(java.nio.ByteOrder.LITTLE_ENDIAN)
for (s in audio) bb.putShort(s)
out.write(bb.array())
}
addMessage(ChatMessage(role = ChatMessage.Role.SYSTEM, text = "WAV: $wavPath (${audio.size/24000f}s)"))
} else {
addMessage(ChatMessage(role = ChatMessage.Role.SYSTEM, text = "Pipeline: empty audio"))
}
} catch (e: Exception) {
addMessage(ChatMessage(role = ChatMessage.Role.SYSTEM, text = "Pipeline error: ${e.message}"))
}
return@launch
}
// Check voice commands before processing // Check voice commands before processing
if (handleVoiceCommand(text)) return@launch if (handleVoiceCommand(text)) return@launch
@ -1806,19 +1462,10 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
} catch (e: Exception) { log("[RAG] retrieve échec: ${e.message}") } } catch (e: Exception) { log("[RAG] retrieve échec: ${e.message}") }
} }
// === Opt TTS-B: the TTS streaming session opens BEFORE llm.generate, // TTS = CosyVoice (CPU) : PAS de streaming LLM→TTS. LLM decode et synthèse
// so the first complete sentence emitted by the LLM (on terminal // CosyVoice sont tous deux CPU-bound (saturent les cœurs) ; les chevaucher
// punctuation) is enqueued for synthesis while the LLM is still // étrangle le LLM (mesuré : 17→0,55 tok/s, tour 1,1s→41s). On laisse le LLM
// decoding the rest of the response. Saves ~LLM-wall-time of TTFA // finir vite (session ~1,1s) PUIS pipeline.speakText (batch) qui streame déjà
// when the first sentence finishes before the LLM does (common for
// short conversational answers). Non-Qwen TTS backends that lack
// the enqueueSentence plumbing fall through to the legacy post-LLM
// pipeline.speakText batch call.
val qwen = tts as? com.kazeia.tts.Qwen3TtsEngine
// NB CosyVoice : PAS de streaming LLM→TTS. LLM decode et synthèse CosyVoice
// sont tous deux CPU-bound (saturent les cœurs) ; les chevaucher étrangle le
// LLM (mesuré : 17→0,55 tok/s, tour 1,1s→41s). On laisse le LLM finir vite
// (session ~1,1s) PUIS synthesizeAndPlay (chemin legacy) qui streame déjà
// phrase-par-phrase EN INTERNE (synthèse N+1 pendant lecture N, sans contention). // phrase-par-phrase EN INTERNE (synthèse N+1 pendant lecture N, sans contention).
// Bulle vide + signal Thinking sur le visualizer : la couleur de l'orbe // Bulle vide + signal Thinking sur le visualizer : la couleur de l'orbe
// pulse pendant que le LLM réfléchit, plus de "..." textuels parasites. // pulse pendant que le LLM réfléchit, plus de "..." textuels parasites.
@ -1884,18 +1531,10 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
// se remplit token par token dans onToken (voir plus bas). // se remplit token par token dans onToken (voir plus bas).
// Streaming LLM→TTS réservé à Qwen3 (NPU/Hexagon : pas de contention CPU avec // Streaming LLM→TTS réservé à Qwen3 (NPU/Hexagon : pas de contention CPU avec
// le LLM). CosyVoice (CPU) passe par le chemin batch (voir note plus haut). // le LLM). CosyVoice (CPU) passe par le chemin batch (voir note plus haut).
val ttsStreamer: com.kazeia.tts.SentenceStreamer? = // CosyVoice (CPU) : pas de streaming LLM→TTS (contention CPU). Le LLM décode
if (qwen != null && !llmOnly) { // vite, puis synthèse batch via pipeline.speakText (qui streame déjà
qwen.onSegmentPlaying = onSegPlay // phrase-par-phrase EN INTERNE). ttsStreamer reste donc null.
qwen.startStreamingSession() val ttsStreamer: com.kazeia.tts.SentenceStreamer? = null
com.kazeia.tts.SentenceStreamer { raw ->
val sIdx = sentenceCounter.getAndIncrement()
val dt = System.currentTimeMillis() - llmStreamStart
val spoken = pipeline.stripNonSpeakable(raw).trim()
log("[LLM.sentence] #$sIdx +${dt}ms → '$spoken'")
if (spoken.isNotEmpty()) qwen.enqueueSentence(spoken)
}
} else null
// In LLM_ONLY_MODE, kill the typing dots as soon as the first token // In LLM_ONLY_MODE, kill the typing dots as soon as the first token
// arrives so the bubble starts revealing text immediately. Outside // arrives so the bubble starts revealing text immediately. Outside
@ -1981,15 +1620,8 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
// onToken ; on finalise avec la réponse nettoyée. // onToken ; on finalise avec la réponse nettoyée.
updateMessageText(bubble.id, responseText) updateMessageText(bubble.id, responseText)
metrics.end("llm_only") metrics.end("llm_only")
} else if (ttsStreamer != null && qwen != null) {
// Streaming path: tail fragment (if any) + drain playback.
ttsStreamer.flush()
qwen.endStreamingSession()
revealJobs.forEach { try { it.join() } catch (_: Exception) {} }
updateMessageText(bubble.id, responseText)
metrics.end("ok")
} else { } else {
// Legacy batch path for non-Qwen TTS engines. // CosyVoice : synthèse batch (speakText streame en interne).
_pipelineState.value = PipelineState.Speaking _pipelineState.value = PipelineState.Speaking
pipeline.speakText(responseText) { sentence, durationMs, envelope, spectrogram -> pipeline.speakText(responseText) { sentence, durationMs, envelope, spectrogram ->
onSegPlay(sentence, durationMs, envelope, spectrogram) onSegPlay(sentence, durationMs, envelope, spectrogram)
@ -2006,9 +1638,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
totalMs = result.timeMs totalMs = result.timeMs
) )
} else { } else {
// Empty response — close any open streaming session cleanly // Empty response — drop the placeholder bubble.
// and drop the placeholder bubble.
try { qwen?.endStreamingSession() } catch (_: Exception) {}
_messages.value = _messages.value.filter { it.id != bubble.id } _messages.value = _messages.value.filter { it.id != bubble.id }
metrics.end("empty_response") metrics.end("empty_response")
} }
@ -2017,7 +1647,6 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
log("ERROR: LLM generation error: ${e.message}") log("ERROR: LLM generation error: ${e.message}")
metrics.mark("LLM.error", "msg=\"${e.message?.take(80) ?: ""}\"") metrics.mark("LLM.error", "msg=\"${e.message?.take(80) ?: ""}\"")
metrics.end("error") metrics.end("error")
try { qwen?.endStreamingSession() } catch (_: Exception) {}
_pipelineState.value = PipelineState.Error(e.message ?: "Erreur LLM") _pipelineState.value = PipelineState.Error(e.message ?: "Erreur LLM")
addMessage(ChatMessage( addMessage(ChatMessage(
role = ChatMessage.Role.SYSTEM, role = ChatMessage.Role.SYSTEM,

View File

@ -1,56 +0,0 @@
// Harness TTS ADB-pilotable, mirror du LLM bench.
// Déclenchement : `touch <files>/bench_tts.trigger`, relance app.
// Charge libkazeia_tts CPU-only + 1 synthèse FR → fichier WAV.
package com.kazeia.tts
import android.content.Context
import android.util.Log
object BenchTtsHarness {
private const val TAG = "BenchTts"
fun runBench(ctx: Context) {
val talker = "/data/local/tmp/kz-engine/talker_f32.gguf"
val vocab = "/data/local/tmp/kz-engine/Qwen3-4B-Q4_0.gguf"
val dump = "/data/local/tmp/kz-engine/tts_dump"
val outWav = "${ctx.getExternalFilesDir(null)}/tts_bench_out.wav"
Log.i(TAG, "=== load libkazeia_tts CPU-only ===")
val t0 = System.currentTimeMillis()
val jni = TtsJni()
val h = try {
jni.nativeLoad(talker, vocab, dump, /*useHtp=*/false, /*nThreads=*/6, /*cpUseCache=*/true)
} catch (e: Throwable) {
Log.e(TAG, "load THROW: ${e.message}"); return
}
if (h == 0L) { Log.e(TAG, "load FAIL (handle=0). talker=$talker vocab=$vocab dump=$dump"); return }
Log.i(TAG, "load OK in ${System.currentTimeMillis()-t0}ms, handle=$h")
val text = "Bonsoir Pierre, comment vous sentez-vous aujourd'hui ?"
Log.i(TAG, "=== synthèse : '$text' → $outWav ===")
val ts = System.currentTimeMillis()
val r = try {
jni.nativeSynthesize(
h, text, outWav,
/*maxSteps=*/256, /*seed=*/42,
0.9f, 50, 0.95f, 1.10f, // cp sampling
0.9f, 50, 0.95f, 1.10f // talker sampling
)
} catch (e: Throwable) {
Log.e(TAG, "synth THROW: ${e.message}"); jni.nativeFree(h); return
}
val wall = System.currentTimeMillis() - ts
// IntArray = [err, frames, total_ms, prefill_ms, talker_ms, cp_ms, decoder_ms]
if (r == null || r.size < 7) { Log.e(TAG, "synth bad result: ${r?.toList()}"); jni.nativeFree(h); return }
val err = r[0]; val frames = r[1]; val totalMs = r[2]
val prefillMs = r[3]; val talkerMs = r[4]; val cpMs = r[5]; val decoderMs = r[6]
val audioMs = frames * 80 // 12.5 fps => 80 ms/frame
val rtf = if (audioMs > 0) totalMs.toFloat() / audioMs else 0f
Log.i(TAG, "synth err=$err frames=$frames audio=${audioMs}ms total=${totalMs}ms (wall=${wall}ms)")
Log.i(TAG, " prefill=$prefillMs talker=$talkerMs cp=$cpMs decoder=$decoderMs RTF=${"%.2f".format(rtf)}")
Log.i(TAG, " WAV écrit : $outWav (taille=${java.io.File(outWav).length()} octets)")
jni.nativeFree(h)
Log.i(TAG, "=== Bench end ===")
}
}

View File

@ -1,199 +0,0 @@
// Adapter core.TtsEngine -> libkazeia_tts (engine CPU-only unifié).
// Remplace Qwen3TtsEngine quand ttsEngine="lib". Synthèse vers WAV temp via
// TtsJni, lecture PCM, playback AudioTrack (MODE_STATIC, ColorOS-safe).
//
// Assets requis (résolus sous baseDir) : talker_f32.gguf, Qwen3-4B-Q4_0.gguf,
// tts_dump/ (text_embed.bin, tp_*, cp_*.gguf, qwen3tts_decoder.gguf, ...).
package com.kazeia.tts
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFormat
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.withContext
import java.io.File
import kotlin.coroutines.resume
import java.nio.ByteBuffer
import java.nio.ByteOrder
class KazeiaTtsEngine(
private val context: Context,
private val log: (String) -> Unit = {}
) : TtsEngine {
companion object {
private const val SR = 24000
private const val MAX_STEPS = 256
private const val SEED = 42
// En dessous de ce |sample|, une frame de 80 ms est considérée silence.
private const val SILENCE_THRESHOLD = 200
private const val STAGING_DIR = "/data/local/tmp/kz-engine"
}
private val jni = TtsJni()
private var handle: Long = 0L
private var audioTrack: AudioTrack? = null
private val sampling = TtsSampling()
// Voix demandée (chemin WAV). Réappliquée après chaque load (le x_vector est
// mémorisé natif par handle, et le handle change au reload).
@Volatile private var pendingVoiceWav: String? = null
private fun baseDir(): String {
val models = com.kazeia.KazeiaApplication.MODELS_DIR
return if (File("$models/tts_dump/qwen3tts_decoder.gguf").exists()) models else STAGING_DIR
}
override suspend fun load(modelPath: String?, voiceId: String?) {
if (handle != 0L) return
withContext(Dispatchers.Default) {
val base = baseDir()
val talker = "$base/talker_f32.gguf"
val vocab = "$base/Qwen3-4B-Q4_0.gguf"
val dump = "$base/tts_dump"
log("load: base=$base")
val t0 = System.currentTimeMillis()
handle = jni.nativeLoad(talker, vocab, dump, /*useHtp=*/false, /*nThreads=*/6, /*cpUseCache=*/true)
if (handle == 0L) {
log("load FAIL (handle=0). talker=$talker vocab=$vocab dump=$dump")
error("KazeiaTtsEngine load failed (handle=0)")
}
log("load OK in ${System.currentTimeMillis() - t0}ms")
// Réapplique la voix demandée avant le load (sinon le x_vector natif,
// indexé par handle, est perdu au reload et on retombe sur damien).
pendingVoiceWav?.let { applyVoice(it) }
}
}
override fun isLoaded(): Boolean = handle != 0L
/**
* Hot-swap de voix "basé sur le WAV" : le natif dérive le x_vector du fichier
* WAV (downmix + resample 24k embarqués) via le speaker encoder, et l'utilise
* pour tous les synth suivants. [voiceWavPath] = chemin vers un .wav de voix
* (ex: .../voix/elodie.wav). Effet au prochain segment synthétisé.
*/
fun setVoice(voiceWavPath: String) {
pendingVoiceWav = voiceWavPath
if (handle != 0L) applyVoice(voiceWavPath)
}
private fun applyVoice(voiceWavPath: String) {
if (!File(voiceWavPath).exists()) {
log("setVoice: WAV introuvable: $voiceWavPath — voix inchangée")
return
}
val ok = try { jni.nativeSetVoiceWav(handle, voiceWavPath) } catch (e: Exception) {
log("setVoice: nativeSetVoiceWav exception: ${e.message}"); false
}
log(if (ok) "Voix basculée sur ${File(voiceWavPath).nameWithoutExtension}"
else "setVoice: encodage échoué pour $voiceWavPath (speaker encoder absent ?) — voix inchangée")
}
override suspend fun synthesize(text: String, language: String): TtsResult =
withContext(Dispatchers.Default) {
require(handle != 0L) { "KazeiaTtsEngine: not loaded" }
val outWav = File(context.cacheDir, "kz_tts_${System.nanoTime()}.wav")
val r = jni.nativeSynthesize(
handle, text, outWav.absolutePath, MAX_STEPS, SEED,
sampling.cpTemp, sampling.cpTopK, sampling.cpTopP, sampling.cpRepPenalty,
sampling.talkerTemp, sampling.talkerTopK, sampling.talkerTopP, sampling.talkerRepPenalty
)
if (r == null || r.size < 7 || r[0] != 0) {
outWav.delete()
error("KazeiaTtsEngine.synthesize err=${r?.getOrNull(0)} text=\"$text\"")
}
log("synth frames=${r[1]} total=${r[2]}ms (talker=${r[4]} cp=${r[5]} dec=${r[6]})")
val pcm = readWavPcm(outWav)
outWav.delete()
val trimmed = trimTrailingSilence(pcm)
TtsResult(
audioData = trimmed,
sampleRate = SR,
durationMs = (trimmed.size * 1000L / SR)
)
}
override suspend fun synthesizeAndPlay(
text: String,
language: String,
onStart: (() -> Unit)?,
onComplete: (() -> Unit)?
) {
val result = synthesize(text, language)
if (result.audioData.isEmpty()) { onComplete?.invoke(); return }
onStart?.invoke()
// Suspend jusqu'à la FIN réelle de la lecture (marker AudioTrack), pas
// seulement le démarrage. Sans ça, l'appelant (pipeline.speakText)
// repasse l'état Speaking→Idle dès track.play(), le micro se démute
// et capture le WAV qui joue encore → écho/boucle.
suspendCancellableCoroutine<Unit> { cont ->
cont.invokeOnCancellation { stop() }
playAudio(result.audioData) { if (cont.isActive) cont.resume(Unit) }
}
onComplete?.invoke()
}
private fun playAudio(audioData: ShortArray, onComplete: () -> Unit) {
stop()
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_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build())
.setBufferSizeInBytes(audioData.size * 2)
.setTransferMode(AudioTrack.MODE_STATIC)
.build()
audioTrack = track
track.write(audioData, 0, audioData.size)
track.setNotificationMarkerPosition(audioData.size)
track.setPlaybackPositionUpdateListener(object : AudioTrack.OnPlaybackPositionUpdateListener {
override fun onMarkerReached(t: AudioTrack?) { onComplete() }
override fun onPeriodicNotification(t: AudioTrack?) {}
})
track.play()
}
private fun readWavPcm(wav: File): ShortArray {
val bytes = wav.readBytes()
if (bytes.size <= 44) return ShortArray(0)
// En-tête PCM canonique 44 octets (écrit par le JNI).
val dataBytes = bytes.size - 44
val n = dataBytes / 2
val bb = ByteBuffer.wrap(bytes, 44, dataBytes).order(ByteOrder.LITTLE_ENDIAN)
return ShortArray(n) { bb.short }
}
// Coupe le silence de fin (le talker pad jusqu'à MAX_STEPS si EOS n'est pas
// émis -> sans ça la lecture/orbe traînerait plusieurs secondes de blanc).
private fun trimTrailingSilence(pcm: ShortArray): ShortArray {
var end = pcm.size
while (end > 0 && kotlin.math.abs(pcm[end - 1].toInt()) < SILENCE_THRESHOLD) end--
if (end == pcm.size) return pcm
// Garde ~120 ms de queue pour ne pas couper sec.
val tail = (SR * 120 / 1000)
val keep = minOf(pcm.size, end + tail)
return pcm.copyOfRange(0, keep)
}
override fun stop() {
audioTrack?.let {
try { it.pause(); it.flush(); it.stop() } catch (_: Exception) {}
try { it.release() } catch (_: Exception) {}
}
audioTrack = null
}
override fun release() {
stop()
if (handle != 0L) { try { jni.nativeFree(handle) } catch (_: Exception) {}; handle = 0L }
}
}

View File

@ -1,15 +0,0 @@
package com.kazeia.tts
/** NEON SIMD optimized operations for TTS head argmax. */
object NeonOps {
init { System.loadLibrary("neon_ops") }
/** Argmax of hidden @ headWeights.T for one head. */
external fun headArgmax(hidden: FloatArray, headWeights: FloatArray, vocab: Int, dim: Int): Int
/** Batch argmax for all heads at once (avoids JNI overhead per head). */
external fun headArgmaxBatch(hidden: FloatArray, allHeads: FloatArray, numHeads: Int, vocab: Int, dim: Int): IntArray
/** Argmax with offset into a large weight buffer (avoids array copy). */
external fun headArgmaxOffset(hidden: FloatArray, allHeads: FloatArray, offset: Int, vocab: Int, dim: Int): Int
}

View File

@ -1,219 +0,0 @@
package com.kazeia.tts
import android.util.Log
import org.json.JSONObject
import java.io.File
import java.util.regex.Pattern
/**
* Byte-level BPE tokenizer compatible with Qwen2/Qwen3 tokenizer.
*
* Why this file exists:
* The app needs to tokenize arbitrary LLM-generated French text on-device
* for the TTS pipeline (the reduced 1050-token table shipped previously
* couldn't handle free-form text). The reference is the HuggingFace
* Qwen2TokenizerFast a GPT-2-style byte-level BPE. We reimplement it
* in Kotlin rather than linking libtokenizers.so so there's no new
* native dependency and the numbers are easy to audit.
*
* Algorithm (identical to GPT-2 / Qwen2):
* 1. Pre-tokenize the input with a regex that groups contractions,
* words, numbers, and whitespace into "word chunks" that BPE never
* merges across.
* 2. UTF-8 encode each chunk, then map each byte (0..255) to one of
* 256 printable Unicode code points via a fixed "byte encoder" table
* this is the trick that lets a byte-level vocab fit inside JSON
* without invalid or control characters.
* 3. Apply BPE: repeatedly find the pair with the lowest rank in the
* merges list and merge it, until no more merges apply. Look up each
* resulting super-token in vocab.json to get the final token IDs.
*
* Bit-perfect compatibility notes:
* - The pre-tokenize regex below MUST match the one in tokenizer_config.json.
* Qwen2 uses the GPT-2 pattern with a couple of Unicode property
* extensions; Java regex supports these directly.
* - Byte encoder is canonical (see bytesToUnicode()).
* - Merges are rank-ordered: lower line number = higher priority, matching
* HuggingFace's `merges.txt` file ordering.
* - We do NOT add BOS/EOS or chat-template specials the TTS prefill
* prepends its own 9-embed voice prefix that already handles role tokens.
*/
class Qwen3BpeTokenizer private constructor(
private val vocab: HashMap<String, Int>,
private val merges: HashMap<Pair<String, String>, Int>,
private val byteEncoder: IntArray,
) {
companion object {
private const val TAG = "Qwen3BPE"
// Qwen2/Qwen3 pre-tokenization regex. This is the exact pattern used
// by the HuggingFace Qwen2Tokenizer (adapted from GPT-2). Matches
// contractions ('s|'d|'ll|'ve|'re|'t), word chunks with a leading
// optional non-word char + letters, runs of digits, runs of
// punctuation, and whitespace runs with boundary semantics.
//
// Note on the character classes: Android's Pattern does NOT support
// UNICODE_CHARACTER_CLASS — so plain \\p{L} / \\p{N} would collapse
// to ASCII-only and break French accents ("é" → wrong token). Use
// \\p{IsAlphabetic} and \\p{IsDigit} instead; those ARE Unicode-aware
// out of the box (they map to ICU's IsAlphabetic / IsDigit properties
// in both JDK and Android runtimes). Output matches Python's Qwen2
// tokenizer on French text.
private val PRE_TOKENIZE_PATTERN: Pattern = Pattern.compile(
"'s|'d|'ll|'ve|'re|'t" +
"|[^\\r\\n\\p{IsAlphabetic}\\p{IsDigit}]?\\p{IsAlphabetic}+" +
"|\\p{IsDigit}{1,3}" +
"| ?[^\\s\\p{IsAlphabetic}\\p{IsDigit}]+[\\r\\n]*" +
"|\\s*[\\r\\n]+" +
"|\\s+(?!\\S)" +
"|\\s+"
)
/**
* GPT-2 byte encoder: maps 0..255 a printable Unicode codepoint.
* Ensures every possible byte has a visible, JSON-safe representation
* so a byte-level vocab can be stored as strings in vocab.json.
*/
private fun bytesToUnicode(): IntArray {
val bs = mutableListOf<Int>()
// Printable ASCII and common Latin blocks.
bs.addAll(('!'.code..'~'.code).toList())
bs.addAll(('¡'.code..'¬'.code).toList())
bs.addAll(('®'.code..'ÿ'.code).toList())
val cs = bs.toMutableList()
// Every byte not in bs gets mapped to a code point past 255 so no
// existing character collides with it.
var n = 0
val map = IntArray(256)
for (b in 0..255) {
if (b in bs) {
map[b] = b
} else {
map[b] = 256 + n
bs.add(b) // placeholder only, we've already recorded cs from the frozen snapshot
cs.add(256 + n)
n += 1
}
}
return map
}
fun load(modelDir: String): Qwen3BpeTokenizer {
val t0 = System.currentTimeMillis()
val vocabFile = File("$modelDir/vocab.json")
val mergesFile = File("$modelDir/merges.txt")
require(vocabFile.exists()) { "vocab.json missing at $modelDir" }
require(mergesFile.exists()) { "merges.txt missing at $modelDir" }
val vocabJson = JSONObject(vocabFile.readText())
val vocab = HashMap<String, Int>(vocabJson.length())
val keys = vocabJson.keys()
while (keys.hasNext()) {
val k = keys.next()
vocab[k] = vocabJson.getInt(k)
}
val merges = HashMap<Pair<String, String>, Int>()
var rank = 0
mergesFile.useLines { lines ->
for (line in lines) {
// Skip header / blanks. Qwen's merges.txt starts with
// "#version" which we simply filter out.
if (line.isBlank() || line.startsWith("#")) continue
val sp = line.indexOf(' ')
if (sp < 0) continue
merges[Pair(line.substring(0, sp), line.substring(sp + 1))] = rank
rank++
}
}
Log.i(TAG, "Loaded vocab=${vocab.size} merges=${merges.size} in ${System.currentTimeMillis()-t0}ms")
return Qwen3BpeTokenizer(vocab, merges, bytesToUnicode())
}
}
/**
* Convert a single pre-tokenized word (UTF-8 bytes encoded via the byte
* encoder into a string) into token IDs via BPE merges. Caches results so
* repeated common words (spaces, punctuation) only BPE once.
*/
private val bpeCache = HashMap<String, IntArray>()
private fun bpeEncode(byteEncodedWord: String): IntArray {
bpeCache[byteEncodedWord]?.let { return it }
// Start with one "sub-token" per Unicode code point (code points,
// not chars — surrogate pairs are handled automatically since the
// byte encoder only produces BMP codepoints by construction).
val parts = ArrayList<String>(byteEncodedWord.length)
var i = 0
while (i < byteEncodedWord.length) {
val cp = byteEncodedWord.codePointAt(i)
parts.add(String(Character.toChars(cp)))
i += Character.charCount(cp)
}
if (parts.size < 2) {
val id = vocab[parts.getOrElse(0) { "" }] ?: vocab["<unk>"] ?: 0
val out = intArrayOf(id)
bpeCache[byteEncodedWord] = out
return out
}
// Greedy lowest-rank merge, classic BPE. We scan for the pair with
// the smallest rank, merge ALL its occurrences, then repeat. This
// matches HF's reference implementation.
while (parts.size > 1) {
var bestRank = Int.MAX_VALUE
var bestIdx = -1
for (k in 0 until parts.size - 1) {
val r = merges[Pair(parts[k], parts[k + 1])] ?: continue
if (r < bestRank) { bestRank = r; bestIdx = k }
}
if (bestIdx < 0) break
// Merge all non-overlapping occurrences of that exact pair.
val a = parts[bestIdx]; val b = parts[bestIdx + 1]
val merged = a + b
var k = 0
val out = ArrayList<String>(parts.size - 1)
while (k < parts.size) {
if (k < parts.size - 1 && parts[k] == a && parts[k + 1] == b) {
out.add(merged); k += 2
} else {
out.add(parts[k]); k += 1
}
}
parts.clear(); parts.addAll(out)
}
val ids = IntArray(parts.size)
for (k in parts.indices) {
ids[k] = vocab[parts[k]] ?: vocab["<unk>"] ?: 0
}
bpeCache[byteEncodedWord] = ids
return ids
}
/**
* Encode text token IDs using the full Qwen3 vocabulary. Does NOT
* prepend BOS/EOS callers can add specials themselves. Unicode
* characters outside ASCII (e.g. French accents) are UTF-8 encoded and
* go through the byte encoder, so "é" and "ï" tokenize the same way as
* they do in Python.
*/
fun encode(text: String): IntArray {
val all = ArrayList<Int>(text.length / 2 + 4)
val matcher = PRE_TOKENIZE_PATTERN.matcher(text)
while (matcher.find()) {
val chunk = matcher.group()
// UTF-8 encode the chunk, then map each raw byte to its Unicode
// "byte-encoded" character. This produces the exact string that
// BPE merges operate on.
val bytes = chunk.toByteArray(Charsets.UTF_8)
val sb = StringBuilder(bytes.size)
for (b in bytes) sb.appendCodePoint(byteEncoder[b.toInt() and 0xff])
val ids = bpeEncode(sb.toString())
for (id in ids) all.add(id)
}
return all.toIntArray()
}
}

View File

@ -1,91 +0,0 @@
package com.kazeia.tts
import android.util.Log
/**
* Décodeur Qwen3-TTS pur ggml C++. Remplace la voie .pte ExecuTorch.
*
* Pipeline complet codes [16, T] long wav [T*1920] PCM16 fait en C++ :
* Quantizer + pre_conv + pre_transformer + 2× upsample + BigVGAN.
*
* Validé bit-close vs PyTorch reference (max_abs_diff 2.1e-3, RMSE 1.0e-4)
* sur Damien FR. Compute ~4.85s pour 4.64s audio sur OnePlus Pad 3 8 threads
* (RTF 1.05) équivalent à ExecuTorch CPU XNNPACK / 2 grâce aux optims
* ARM features (i8mm, dotprod, fp16) + F16 conv weights + layout uniforme.
*/
class Qwen3TtsGgmlDecoder(private val ggufPath: String) {
companion object {
private const val TAG = "TtsDecoderGgml"
init {
try {
System.loadLibrary("tts_decoder_ggml")
Log.i(TAG, "loaded libtts_decoder_ggml.so")
} catch (e: UnsatisfiedLinkError) {
Log.e(TAG, "loadLibrary failed: ${e.message}")
}
}
}
private var handle: Long = 0L
fun load(): Boolean {
if (handle != 0L) return true
// Test 2026-05-03 : GGML_NUM_THREADS=4 ne réduisait PAS le pic
// (ggml fait du work-sharing sur tensors uniques, pas de réplication
// par thread). Reverté → laisse default 8 (plus rapide).
handle = nativeCreate(ggufPath)
if (handle == 0L) {
Log.e(TAG, "nativeCreate returned 0 (load failed)")
return false
}
Log.i(TAG, "decoder loaded from $ggufPath, handle=0x${java.lang.Long.toHexString(handle)}")
return true
}
/**
* Décode codes [n_q=16][T] (codebooks par layer × timesteps) PCM16 [T*1920].
*
* @param codebooks Tableau IntArray par codebook, chaque codebook contient T indices [0, 2047].
* @param numTokens Nombre de timesteps réels T (= taille de chaque codebook).
* @return ShortArray de T*1920 samples PCM16 à 24 kHz, ou null si échec.
*/
fun decode(codebooks: Array<IntArray>, numTokens: Int): ShortArray? {
if (handle == 0L) {
Log.e(TAG, "decode called before load()")
return null
}
val nQ = codebooks.size
if (nQ != 16) {
Log.e(TAG, "Expected 16 codebooks, got $nQ")
return null
}
// Aplatir en codes_flat[n_q * T] avec layout codes_flat[layer*T + t]
val flat = IntArray(nQ * numTokens)
for (layer in 0 until nQ) {
for (t in 0 until numTokens) {
flat[layer * numTokens + t] = codebooks[layer][t]
}
}
val t0 = System.currentTimeMillis()
val pcm = nativeDecode(handle, flat, numTokens)
if (pcm == null) {
Log.e(TAG, "nativeDecode returned null")
return null
}
val dt = System.currentTimeMillis() - t0
Log.i(TAG, "decode T=$numTokens${pcm.size} samples in ${dt}ms (RTF ${"%.2f".format(dt / (pcm.size / 24.0))})")
return pcm
}
fun close() {
if (handle != 0L) {
nativeDestroy(handle)
handle = 0L
}
}
private external fun nativeCreate(ggufPath: String): Long
private external fun nativeDecode(handle: Long, codes: IntArray, T: Int): ShortArray?
private external fun nativeDestroy(handle: Long)
}

View File

@ -1,51 +0,0 @@
package com.kazeia.tts
/**
* JNI bridge to the in-process CP CPU runner (libtts_cp_cpu.so).
*
* Replaces the .pte ExecuTorch CP path with a llama.cpp-based forward that
* runs cp_f16.gguf in CPU NEON mode (n_gpu_layers=0).
*
* Heads + codec embeddings (15 × 2048 × 1024 fp32, ~125 MB each) loaded
* separately via [nativeInit]. They live in /data/local/tmp/kazeia/models/
* (cp_heads.bin, cp_codec_embs.bin) and are mmap-friendly fp32 row-major.
*
* Per Talker step : [nativeForward] runs 17 forward decodes + 15 NEON
* dot_argmax classifications returns 15 codebook codes (cb1..cb15).
*
* KV is RECREATED per call (matches tts-cp-runner.cpp ref to avoid Hexagon
* NaN TODO optim Phase 4 : try memory_clear instead since CPU doesn't
* have the NaN issue, save the recreate cost).
*/
object TtsCpCpuJni {
init {
// Order matters : ggml deps before llama. Talker JNI loads them
// first, but if CP loads alone we ensure them too.
System.loadLibrary("ggml-base")
System.loadLibrary("ggml-cpu")
System.loadLibrary("ggml")
System.loadLibrary("llama")
System.loadLibrary("tts_cp_cpu")
}
/** Load cp_f16.gguf + heads + codec embeddings.
* - modelPath : path to cp_f16.gguf (~166 MB)
* - headsPath : path to cp_heads.bin (15 × 2048 × 1024 fp32 = 125 MB)
* - embsPath : path to cp_codec_embs.bin (125 MB)
* Returns native handle, 0 on failure. */
@JvmStatic external fun nativeInit(modelPath: String, headsPath: String, embsPath: String): Long
/** Run one CP step.
* - hidden : Talker hidden state output [1024] fp32
* - cb0Emb : codec embedding for predicted cb0 [1024] fp32
* Returns 15 int32 codes (cb1..cb15), null on failure. */
@JvmStatic external fun nativeForward(handle: Long, hidden: FloatArray, cb0Emb: FloatArray): IntArray?
/** Reset KV cache. Each forward recreates ctx anyway, but explicit
* reset between sentences is supported. */
@JvmStatic external fun nativeReset(handle: Long)
/** Free model + ctx + heads + embs buffers. */
@JvmStatic external fun nativeFree(handle: Long)
}

View File

@ -1,94 +0,0 @@
package com.kazeia.tts
// Engine TTS Qwen3 in-process (libkazeia_tts.so). Load une fois, synthesize N fois.
// Le bridge natif est dans dist/jni/kazeia_tts_jni.cpp ; build via dist/build_kazeia_tts.sh
// ou dist/CMakeLists.txt.
//
// Empreinte RAM typique : ~3.5 GB (talker f32 ~1.6 GB + CP f16 mixte ~250 MB +
// decoder ~325 MB + vocab Qwen3 ~50 MB + fixtures text_embed/tp_* ~1.2 GB).
class TtsJni {
external fun nativeLoad(
talkerGguf: String,
vocabGguf: String,
dumpDir: String,
useHtp: Boolean,
nThreads: Int,
cpUseCache: Boolean
): Long
external fun nativeSynthesize(
handle: Long,
text: String,
outWavPath: String,
maxSteps: Int,
seed: Int,
cpTemp: Float, cpTopK: Int, cpTopP: Float, cpRepPenalty: Float,
talkerTemp: Float, talkerTopK: Int, talkerTopP: Float, talkerRepPenalty: Float
): IntArray // [err, frames, total_ms, prefill_ms, talker_ms, cp_ms, decoder_ms]
// Dérive un x_vector depuis un WAV (downmix + resample à 24k côté natif) et
// le mémorise comme voix courante pour ce handle. Effet au prochain synth.
external fun nativeSetVoiceWav(handle: Long, wavPath: String): Boolean
external fun nativeFree(handle: Long)
companion object { init { System.loadLibrary("kazeia_tts") } }
}
data class TtsResult(
val frames: Int,
val audioMs: Int,
val totalMs: Int,
val prefillMs: Int,
val talkerMs: Int,
val cpMs: Int,
val decoderMs: Int
)
// Configuration sampling. Défauts validés sur fixture FR (cf project_tts_session_28may).
data class TtsSampling(
// Défauts validés à l'écoute (top_p=0.95 + rep=1.10 moins robotique vs 1.0/1.05).
val cpTemp: Float = 0.9f, val cpTopK: Int = 50, val cpTopP: Float = 0.95f, val cpRepPenalty: Float = 1.10f,
val talkerTemp: Float = 0.9f, val talkerTopK: Int = 50, val talkerTopP: Float = 0.95f, val talkerRepPenalty: Float = 1.10f
)
class TtsEngine(
talkerGguf: String,
vocabGguf: String,
dumpDir: String,
useHtp: Boolean = false,
nThreads: Int = 6,
cpUseCache: Boolean = true
) {
private val jni = TtsJni()
private val h = jni.nativeLoad(talkerGguf, vocabGguf, dumpDir, useHtp, nThreads, cpUseCache)
init { require(h != 0L) { "TtsEngine: load FAILED (talker=$talkerGguf, vocab=$vocabGguf, dump=$dumpDir)" } }
@Throws(IllegalStateException::class)
fun synthesize(
text: String,
outWavPath: String,
maxSteps: Int = 256,
seed: Int = 42,
sampling: TtsSampling = TtsSampling()
): TtsResult {
val r = jni.nativeSynthesize(
h, text, outWavPath, maxSteps, seed,
sampling.cpTemp, sampling.cpTopK, sampling.cpTopP, sampling.cpRepPenalty,
sampling.talkerTemp, sampling.talkerTopK, sampling.talkerTopP, sampling.talkerRepPenalty
)
if (r[0] != 0) error("TtsEngine.synthesize err=${r[0]} for text=\"$text\"")
val frames = r[1]
return TtsResult(
frames = frames,
audioMs = frames * 1000 / 12, // 12 frames/s = 80 ms par frame
totalMs = r[2],
prefillMs = r[3],
talkerMs = r[4],
cpMs = r[5],
decoderMs = r[6]
)
}
fun release() { jni.nativeFree(h) }
}

View File

@ -1,28 +0,0 @@
package com.kazeia.tts
/** Native C++ TTS pipeline: talker + CP loop with zero Java overhead. */
object TtsPipeline {
init { System.loadLibrary("tts_pipeline") }
/** Load and warmup both .pte models. Returns true on success. */
external fun nativeInit(talkerPath: String, cpPath: String): Boolean
/** Release native resources. */
external fun nativeDestroy()
/**
* Run full pipeline. Returns flat int array: [numTokens × 16] codebook codes.
* Each group of 16 ints = [CB0, CB1, ..., CB15] for one time step.
*/
external fun nativeRun(
prefillEmbeds: FloatArray, nPrefill: Int,
trailingEmbeds: FloatArray?, nTrailing: Int,
codecEmbedding: FloatArray,
cpEmbeddings: FloatArray,
cpHeads: FloatArray,
talkerCos: FloatArray, talkerSin: FloatArray,
cpCos: FloatArray, cpSin: FloatArray,
eosEmbed: FloatArray, padEmbed: FloatArray,
maxTokens: Int
): IntArray?
}

View File

@ -1,40 +0,0 @@
package com.kazeia.tts
/**
* JNI bridge to the in-process Talker CPU runner (libtts_talker_cpu.so).
*
* Replaces the .pte ExecuTorch path with a llama.cpp-based forward that
* runs talker_f16.gguf in CPU NEON mode (n_gpu_layers=0).
*
* Lifecycle: [init] once at boot [forward] per autoregressive step
* [reset] between sentences [free] at TTS engine release.
*
* Mesured POC (2026-04-29) :
* ms/step CPU NEON 17 (vs 43 .pte NPU) ×2.5 plus rapide
* Cible RTF projeté < 1 "beg beg beg" éliminé structurellement
*/
object TtsTalkerCpuJni {
init {
// Order matters : ggml deps before llama
System.loadLibrary("ggml-base")
System.loadLibrary("ggml-cpu")
System.loadLibrary("ggml")
System.loadLibrary("llama")
System.loadLibrary("tts_talker_cpu")
}
/** Load talker_f16.gguf in CPU mode. Returns native handle, 0 on failure. */
@JvmStatic external fun nativeInit(modelPath: String): Long
/** Run one forward step. embd is 1024 fp32. Returns 4096 fp32 :
* [0..1023] = hidden state, [1024..4095] = cb0 logits over codec vocab.
* null on failure. */
@JvmStatic external fun nativeForward(handle: Long, embd: FloatArray): FloatArray?
/** Reset KV cache for a new sentence. */
@JvmStatic external fun nativeReset(handle: Long)
/** Free model + ctx. */
@JvmStatic external fun nativeFree(handle: Long)
}

View File

@ -1,331 +0,0 @@
package com.kazeia.v2
import android.Manifest
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.graphics.Color
import android.os.Bundle
import android.os.IBinder
import android.view.Gravity
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.graphics.Typeface
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
/**
* UI test page Phase 1 du rewrite Kazeia v2.
*
* - Bouton "Parler" déclenche la capture micro avec VAD Silero ; pas besoin
* d'arrêter manuellement, VAD termine le segment automatiquement.
* - Bulle USER (transcription STT en bleu, alignée droite)
* - Bulle KAZEIA (dots animés "..." ou texte final, alignée gauche)
* - Status bar bas affiche l'état du pipeline
*
* Phase 1 = STT (VAD + Whisper). Phase 2 ajoutera LLM cascade, Phase 3 TTS.
*/
class ChatActivityV2 : AppCompatActivity() {
private var service: KazeiaServiceV2? = null
private var bound = false
private lateinit var statusView: TextView
private lateinit var bubbleUser: TextView
private lateinit var bubbleKazeia: TextView
private lateinit var pttButton: Button
private lateinit var bubblesContainer: LinearLayout
private lateinit var logsView: TextView
private lateinit var logsScroll: ScrollView
private val logsBuffer = StringBuilder(8192)
private val logsMaxLines = 400
private lateinit var metricsView: MetricsGraphView
private val metricsSampler = MetricsSampler { cpu, ram, avail ->
if (::metricsView.isInitialized) metricsView.addSample(cpu, ram, avail)
}
private val permLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
// No-op, user just sees state if denied
}
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
service = (binder as KazeiaServiceV2.LocalBinder).service()
bound = true
KazeiaLogger.pipeline("ChatActivityV2 bound to KazeiaServiceV2")
observeService()
}
override fun onServiceDisconnected(name: ComponentName?) {
service = null
bound = false
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Permissions
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
permLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
// Layout split horizontal :
// gauche (60 %) : chat (titre + bulles + bouton + status)
// droite (40 %) : panneau logs en monospace, auto-scroll bas
val root = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(16, 16, 16, 16)
setBackgroundColor(Color.parseColor("#FAFAFA"))
}
// ─── Colonne gauche : chat ───────────────────────────────────
val leftCol = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(8, 8, 16, 8)
layoutParams = LinearLayout.LayoutParams(0, MATCH_PARENT, 0.6f)
}
val title = TextView(this).apply {
text = "Kazeia v2"
textSize = 20f
setTextColor(Color.parseColor("#222222"))
setPadding(0, 0, 0, 16)
}
leftCol.addView(title)
bubblesContainer = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
}
bubbleUser = makeBubble(isUser = true).apply { text = "" }
bubbleKazeia = makeBubble(isUser = false).apply { text = "" }
bubblesContainer.addView(bubbleUser)
bubblesContainer.addView(bubbleKazeia)
val chatScroll = ScrollView(this).apply {
addView(bubblesContainer)
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f)
}
leftCol.addView(chatScroll)
pttButton = Button(this).apply {
text = "▶ Parler (VAD)"
textSize = 18f
setOnClickListener { onPttClicked() }
}
leftCol.addView(pttButton)
statusView = TextView(this).apply {
text = "Status: Idle (service en chargement)"
textSize = 12f
setTextColor(Color.GRAY)
setPadding(0, 12, 0, 0)
}
leftCol.addView(statusView)
root.addView(leftCol)
// ─── Colonne droite : panneau logs ───────────────────────────
val rightCol = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(16, 8, 8, 8)
setBackgroundColor(Color.parseColor("#0D1117"))
layoutParams = LinearLayout.LayoutParams(0, MATCH_PARENT, 0.4f)
}
// Graph CPU/RAM en haut (hauteur fixe 200 dp)
metricsView = MetricsGraphView(this).apply {
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, (200 * resources.displayMetrics.density).toInt())
}
rightCol.addView(metricsView)
val logsTitle = TextView(this).apply {
text = "Logs pipeline (live)"
textSize = 14f
setTextColor(Color.parseColor("#8B949E"))
setTypeface(Typeface.MONOSPACE, Typeface.BOLD)
setPadding(8, 8, 8, 8)
}
rightCol.addView(logsTitle)
logsView = TextView(this).apply {
text = ""
textSize = 10f
setTypeface(Typeface.MONOSPACE)
setTextColor(Color.parseColor("#C9D1D9"))
setBackgroundColor(Color.parseColor("#0D1117"))
setPadding(8, 8, 8, 8)
setHorizontallyScrolling(false)
// Wrap long lines
}
logsScroll = ScrollView(this).apply {
addView(logsView)
layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f)
}
rightCol.addView(logsScroll)
// Bouton clear logs
val clearBtn = Button(this).apply {
text = "Clear logs"
textSize = 12f
setOnClickListener {
logsBuffer.clear()
logsView.text = ""
}
}
rightCol.addView(clearBtn)
root.addView(rightCol)
setContentView(root)
// Stream les logs depuis KazeiaLogger
observeLogs()
// Démarre l'échantillonnage CPU/RAM (500 ms)
metricsSampler.start()
// Bind seul — BIND_AUTO_CREATE crée le service automatiquement.
// Pas besoin de startService (qui demanderait ForegroundService permission).
val intent = Intent(this, KazeiaServiceV2::class.java)
bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
private fun makeBubble(isUser: Boolean): TextView {
return TextView(this).apply {
textSize = 16f
setPadding(24, 16, 24, 16)
val lp = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
lp.setMargins(if (isUser) 80 else 0, 8, if (isUser) 0 else 80, 8)
lp.gravity = if (isUser) Gravity.END else Gravity.START
layoutParams = lp
setBackgroundColor(if (isUser) Color.parseColor("#3B82F6") else Color.parseColor("#E5E7EB"))
setTextColor(if (isUser) Color.WHITE else Color.parseColor("#111111"))
}
}
private fun observeService() {
val s = service ?: return
lifecycleScope.launch {
s.state.collect { st -> renderState(st) }
}
lifecycleScope.launch {
s.events.collect { ev -> handleEvent(ev) }
}
}
private fun observeLogs() {
lifecycleScope.launch {
KazeiaLogger.logFlow.collect { entry ->
appendLog(entry)
}
}
}
private fun appendLog(entry: KazeiaLogger.LogEntry) {
// Plain text + couleur seulement sur le tag via SpannableStringBuilder.
// Évite Html.fromHtml(fullBuffer) qui allouait des MB par log line.
val tagColor = when {
entry.level == KazeiaLogger.Level.E -> 0xFFFF7B72.toInt() // rouge
entry.tag == "PIPELINE" -> 0xFF7EE787.toInt()
entry.tag == "STT" -> 0xFF79C0FF.toInt()
entry.tag == "THINKER" || entry.tag == "SPEAKER" -> 0xFFFFA657.toInt()
entry.tag.startsWith("TTS-") -> 0xFFD2A8FF.toInt()
else -> 0xFFC9D1D9.toInt()
}
val plain = "${entry.ts} ${entry.tag} ${entry.msg}\n"
logsBuffer.append(plain)
// Trim si > 16 KB (garde les ~250 dernières lignes)
if (logsBuffer.length > 16_384) {
val cutPoint = logsBuffer.indexOf("\n", logsBuffer.length - 12_288)
if (cutPoint > 0) {
logsBuffer.delete(0, cutPoint + 1)
}
}
// Affichage plain text — pas de re-render HTML coûteux.
logsView.text = logsBuffer.toString()
logsScroll.post { logsScroll.fullScroll(android.widget.ScrollView.FOCUS_DOWN) }
}
private fun renderState(st: PipelineState) {
when (st) {
is PipelineState.Idle -> {
statusView.text = "Status: Idle (prêt)"
pttButton.isEnabled = true
pttButton.text = "▶ Parler (VAD)"
}
is PipelineState.Listening -> {
statusView.text = "Status: Écoute… (parlez, VAD termine)"
pttButton.text = "■ Annuler"
pttButton.isEnabled = true
}
is PipelineState.Processing -> {
val phase = when (st.phase) {
PipelineState.Processing.Phase.Stt -> "STT…"
PipelineState.Processing.Phase.Thinker -> "Thinker (Phase 2)"
PipelineState.Processing.Phase.Speaker -> "Speaker (Phase 2)"
PipelineState.Processing.Phase.TtsStart -> "TTS init (Phase 3)"
}
statusView.text = "Status: $phase"
pttButton.isEnabled = false
bubbleKazeia.text = "..."
}
is PipelineState.Speaking -> {
statusView.text = "Status: Lecture audio (Phase 3)"
pttButton.isEnabled = false
}
is PipelineState.Error -> {
statusView.text = "ERREUR: ${st.msg}"
pttButton.isEnabled = true
}
}
}
private fun handleEvent(ev: UiEvent) {
when (ev) {
is UiEvent.UserSpeechFinalized -> {
bubbleUser.text = ev.text.ifBlank { "(silence)" }
}
is UiEvent.KazeiaResponseReady -> {
bubbleKazeia.text = ev.text
}
is UiEvent.KazeiaSpeakingDone -> {
// Phase 3 : audio terminé
}
}
}
private fun onPttClicked() {
val s = service ?: return
// Si on est déjà en écoute → annuler.
if (s.state.value is PipelineState.Listening) {
s.cancelListening()
return
}
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
permLauncher.launch(Manifest.permission.RECORD_AUDIO)
return
}
bubbleUser.text = ""
bubbleKazeia.text = ""
s.startVadListening()
}
override fun onDestroy() {
super.onDestroy()
metricsSampler.stop()
service?.cancelListening()
if (bound) {
unbindService(connection)
bound = false
}
}
}

View File

@ -1,230 +0,0 @@
package com.kazeia.v2
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import java.io.OutputStreamWriter
/**
* Wrapper Kotlin pour `qnn_llama_runner --daemon_mode` (cascade ExecuTorch).
*
* Reproduit fidèlement le protocole du PoC Python `cascade_executorch_daemon.py` :
*
* 1. `start()` lance le binaire avec `cwd = /data/local/tmp/kazeia-et`,
* `LD_LIBRARY_PATH=.`, `ADSP_LIBRARY_PATH=.`. Lit stdout jusqu'à voir
* `READY\n` (model load ~1.3 s pour 4B, ~4.5 s pour 8B).
*
* 2. `call(prompt, system, seqLen, temperature) -> Result` :
* - écrit prompt+system dans `outputs/_p_<label>_<ts>.txt` et `_s_*`
* - envoie ligne `<prompt> <system> <output> <seq_len> <temp>\n` sur stdin
* - attend `DONE\n` sur stdout
* - lit `outputs/_o_<label>_<ts>.txt`, extrait segment assistant
* (`<|im_start|>assistant ... <|im_end|>`, strip `<think></think>`)
* - retourne (text, wallMs)
*
* 3. `stop()` envoie `STOP\n` puis kill si le process ne quitte pas.
*
* stderr est consommé sur un thread dédié et loggé via `KazeiaLogger`.
*
* Usage :
* val d = ExecutorchDaemon("speaker",
* pte = "hybrid_llama_qnn_4b.pte",
* tokenizer = "tokenizer.json",
* label = "S",
* logTag = KazeiaLogger::speaker)
* d.start()
* val (text, ms) = d.call(userPrompt, sysPrompt, seqLen = 512)
* ...
* d.stop()
*/
class ExecutorchDaemon(
val name: String,
private val pte: String,
private val tokenizer: String,
private val label: String,
private val logTag: (String) -> Unit,
/** cwd du subprocess. Doit être writable (outputs/ y est créé). */
private val workDir: String = DEFAULT_DEVICE_DIR,
/** Chemin absolu (ou "./qnn_llama_runner" si binaire dans workDir) du runner. */
private val binary: String = "./qnn_llama_runner",
/** LD_LIBRARY_PATH/ADSP_LIBRARY_PATH du subprocess. "." = workDir. */
private val libDir: String = "."
) {
companion object {
const val DEFAULT_DEVICE_DIR = "/data/local/tmp/kazeia-et"
@Deprecated("use workDir instance var")
const val DEVICE_DIR = DEFAULT_DEVICE_DIR
@Deprecated("use binary instance var")
const val BINARY = "./qnn_llama_runner"
private const val START_TIMEOUT_MS = 30_000L // 30s pour 8B
private const val CALL_TIMEOUT_MS = 120_000L // 2 min par tour
}
data class Result(val text: String, val wallMs: Long)
private var proc: Process? = null
private var stdin: OutputStreamWriter? = null
private var stdout: BufferedReader? = null
private var stderrReader: Thread? = null
fun start() {
if (proc != null) return
val workDirF = File(workDir)
if (!workDirF.isDirectory) workDirF.mkdirs()
if (!workDirF.isDirectory) throw IllegalStateException("workDir $workDir non créable")
val outputs = File(workDirF, "outputs")
if (!outputs.exists()) outputs.mkdirs()
val cmd = listOf(
binary,
"--model_path", pte,
"--tokenizer_path", tokenizer,
"--decoder_model_version", "qwen3",
"--shared_buffer",
"--eval_mode", "1",
"--daemon_mode"
)
logTag("daemon[$label] spawn: $cmd in $workDir (lib=$libDir)")
val pb = ProcessBuilder(cmd).directory(workDirF)
pb.environment()["LD_LIBRARY_PATH"] = libDir
pb.environment()["ADSP_LIBRARY_PATH"] = libDir
val t0 = System.currentTimeMillis()
val p = try {
pb.start()
} catch (e: Exception) {
throw IllegalStateException("daemon[$label] spawn failed (SELinux ?): ${e.message}", e)
}
proc = p
stdin = OutputStreamWriter(p.outputStream)
stdout = BufferedReader(InputStreamReader(p.inputStream))
stderrReader = Thread({
try {
BufferedReader(InputStreamReader(p.errorStream)).use { r ->
var line = r.readLine()
while (line != null) {
logTag("daemon[$label] stderr: $line")
line = r.readLine()
}
}
} catch (_: Exception) {}
}, "ETD-stderr-$label").also {
it.isDaemon = true
it.start()
}
// Wait READY
val deadline = t0 + START_TIMEOUT_MS
while (true) {
if (System.currentTimeMillis() > deadline) {
stop()
throw IllegalStateException("daemon[$label] no READY in ${START_TIMEOUT_MS}ms")
}
val line = stdout!!.readLine()
?: throw IllegalStateException("daemon[$label] stdout closed before READY (process died)")
val trimmed = line.trim()
if (trimmed == "READY") {
val dt = System.currentTimeMillis() - t0
logTag("daemon[$label] READY in ${dt}ms")
return
}
// log progress lines too
if (trimmed.isNotEmpty()) logTag("daemon[$label] init: $trimmed")
}
}
fun call(
prompt: String,
system: String?,
seqLen: Int = 512,
temperature: Double = 0.0
): Result {
val p = proc ?: throw IllegalStateException("daemon[$label] not started")
if (!p.isAlive) throw IllegalStateException("daemon[$label] dead (exit=${p.exitValue()})")
val ts = System.nanoTime()
val outputsDir = "$workDir/outputs"
val promptFile = "$outputsDir/_p_${label}_$ts.txt"
val systemFile = if (system.isNullOrEmpty()) "-" else "$outputsDir/_s_${label}_$ts.txt"
val outputFile = "$outputsDir/_o_${label}_$ts.txt"
File(promptFile).writeText(prompt)
if (systemFile != "-") File(systemFile).writeText(system!!)
val line = "$promptFile $systemFile $outputFile $seqLen $temperature\n"
val tStart = System.currentTimeMillis()
try {
stdin!!.write(line)
stdin!!.flush()
} catch (e: Exception) {
throw IllegalStateException("daemon[$label] stdin write failed: ${e.message}", e)
}
// Wait DONE
val deadline = tStart + CALL_TIMEOUT_MS
while (true) {
if (System.currentTimeMillis() > deadline) {
throw IllegalStateException("daemon[$label] no DONE in ${CALL_TIMEOUT_MS}ms")
}
val l = stdout!!.readLine()
?: throw IllegalStateException("daemon[$label] stdout closed mid-call")
val t = l.trim()
if (t == "DONE") break
if (t.startsWith("ERROR")) throw IllegalStateException("daemon[$label] $t")
if (t.isNotEmpty()) logTag("daemon[$label] gen: $t")
}
val wallMs = System.currentTimeMillis() - tStart
val raw = File(outputFile).readText()
val text = parseAssistant(raw)
// Cleanup
runCatching { File(promptFile).delete() }
if (systemFile != "-") runCatching { File(systemFile).delete() }
runCatching { File(outputFile).delete() }
return Result(text, wallMs)
}
private fun parseAssistant(raw: String): String {
val marker = "<|im_start|>assistant"
val last = raw.lastIndexOf(marker)
var tail = if (last >= 0) raw.substring(last + marker.length).trimStart('\n', ' ') else raw
// Strip <think>...</think>
val thinkRe = Regex("<think>.*?</think>\\s*", RegexOption.DOT_MATCHES_ALL)
tail = thinkRe.replaceFirst(tail, "")
// Cut at first end token
val endIdx = listOf("<|im_end|>", "<|endoftext|>")
.map { tail.indexOf(it) }
.filter { it >= 0 }
.minOrNull()
if (endIdx != null) tail = tail.substring(0, endIdx)
return tail.trim()
}
fun stop() {
val p = proc ?: return
try {
stdin?.write("STOP\n")
stdin?.flush()
} catch (_: Exception) {}
try {
if (!p.waitFor(5_000, java.util.concurrent.TimeUnit.MILLISECONDS)) {
p.destroyForcibly()
}
} catch (_: Exception) {
p.destroyForcibly()
}
try { stdin?.close() } catch (_: Exception) {}
try { stdout?.close() } catch (_: Exception) {}
proc = null
stdin = null
stdout = null
logTag("daemon[$label] stopped")
}
fun isAlive(): Boolean = proc?.isAlive ?: false
}

View File

@ -1,63 +0,0 @@
package com.kazeia.v2
import android.util.Log
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Logger structuré pour le pipeline Kazeia v2. Chaque sous-système (STT, LLM
* Thinker, LLM Speaker, TTS Talker/CP/Decoder, Pipeline orchestration) a son
* tag dédié, filtrable via :
*
* adb logcat -s KazPIPELINE:V KazSTT:V KazTHINKER:V KazSPEAKER:V \
* KazTTS-Talker:V KazTTS-CP:V KazTTS-Decoder:V
*
* Diffusion in-app : chaque message est aussi poussé sur [logFlow] pour
* affichage dans la fenêtre logs de `ChatActivityV2`. Buffer 1024 entrées
* max (ring), pas de retention longue durée c'est juste un debug live.
*/
object KazeiaLogger {
private const val TAG_PIPELINE = "KazPIPELINE"
private const val TAG_STT = "KazSTT"
private const val TAG_THINKER = "KazTHINKER"
private const val TAG_SPEAKER = "KazSPEAKER"
private const val TAG_TTS_TALKER = "KazTTS-Talker"
private const val TAG_TTS_CP = "KazTTS-CP"
private const val TAG_TTS_DECODER = "KazTTS-Decoder"
private val timeFmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US)
private val _logFlow = MutableSharedFlow<LogEntry>(
replay = 256, extraBufferCapacity = 1024
)
/** Stream temps-réel des logs. ChatActivityV2 s'abonne et affiche. */
val logFlow: SharedFlow<LogEntry> = _logFlow.asSharedFlow()
enum class Level { I, E }
data class LogEntry(val ts: String, val tag: String, val level: Level, val msg: String) {
fun formatted(): String = "$ts $tag/${level.name} $msg"
}
private fun emit(tag: String, level: Level, msg: String) {
val entry = LogEntry(timeFmt.format(Date()), tag, level, msg)
_logFlow.tryEmit(entry)
}
fun pipeline(msg: String) { Log.i(TAG_PIPELINE, msg); emit("PIPELINE", Level.I, msg) }
fun stt(msg: String) { Log.i(TAG_STT, msg); emit("STT", Level.I, msg) }
fun thinker(msg: String) { Log.i(TAG_THINKER, msg); emit("THINKER", Level.I, msg) }
fun speaker(msg: String) { Log.i(TAG_SPEAKER, msg); emit("SPEAKER", Level.I, msg) }
fun ttsTalker(msg: String) { Log.i(TAG_TTS_TALKER, msg); emit("TTS-Talker", Level.I, msg) }
fun ttsCp(msg: String) { Log.i(TAG_TTS_CP, msg); emit("TTS-CP", Level.I, msg) }
fun ttsDecoder(msg: String) { Log.i(TAG_TTS_DECODER, msg); emit("TTS-Decoder", Level.I, msg) }
fun pipelineE(msg: String, t: Throwable? = null) {
Log.e(TAG_PIPELINE, msg, t); emit("PIPELINE", Level.E, "$msg${t?.let { " : ${it.message}" } ?: ""}")
}
fun sttE(msg: String, t: Throwable? = null) {
Log.e(TAG_STT, msg, t); emit("STT", Level.E, "$msg${t?.let { " : ${it.message}" } ?: ""}")
}
}

View File

@ -1,218 +0,0 @@
package com.kazeia.v2
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* KazeiaServiceV2 orchestration service propre.
*
* Phase 1 (current): STT seul.
* - load STT au start
* - exposé via API submitAudio(pcm16) pour test
* - state Idle Listening Processing(Stt) Idle
* - événement UiEvent.UserSpeechFinalized émis quand STT termine
*
* Phase 2 ajoutera : LlmCascadeStage (Thinker + Speaker) KazeiaResponseReady.
* Phase 3 ajoutera : TtsStage audio streaming + KazeiaSpeakingDone.
*/
class KazeiaServiceV2 : Service() {
companion object {
private const val TAG = "KazeiaServiceV2"
}
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun service(): KazeiaServiceV2 = this@KazeiaServiceV2
}
private val supervisor = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.Default + supervisor)
private val _state = MutableStateFlow<PipelineState>(PipelineState.Idle)
val state: StateFlow<PipelineState> = _state.asStateFlow()
private val _events = MutableSharedFlow<UiEvent>(replay = 0, extraBufferCapacity = 16)
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
private lateinit var stt: SttStage
private lateinit var vad: VadStage
private lateinit var cascade: LlmCascadeStage
private lateinit var tts: TtsStage
private var lastJob: Job? = null
override fun onCreate() {
super.onCreate()
KazeiaLogger.pipeline("Service v2 onCreate — Phase 3 (STT + cascade + TTS)")
stt = SttStage(this)
vad = VadStage(this)
cascade = LlmCascadeStage()
tts = TtsStage(this)
// Charge VAD (~50ms) puis STT NPU (~1s) puis cascade (Thinker+Speaker, ~5s) puis TTS.
scope.launch {
try {
vad.load()
stt.load()
KazeiaLogger.pipeline("STT+VAD ready, démarrage cascade…")
cascade.load()
KazeiaLogger.pipeline("Cascade ready, chargement TTS…")
try {
tts.load()
KazeiaLogger.pipeline("Pipeline ready (STT+VAD+Cascade+TTS)")
} catch (e: Exception) {
// TTS optionnel — si load échoue, on continue sans audio.
KazeiaLogger.pipelineE("TTS load FAILED — pipeline continue sans audio", e)
KazeiaLogger.pipeline("Pipeline ready (STT+VAD+Cascade, TTS désactivé)")
}
} catch (e: Exception) {
KazeiaLogger.pipelineE("load failed", e)
_state.value = PipelineState.Error("Load failed: ${e.message}")
}
}
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onDestroy() {
super.onDestroy()
KazeiaLogger.pipeline("Service v2 onDestroy")
lastJob?.cancel()
scope.cancel()
vad.release()
stt.release()
cascade.release()
tts.release()
}
/**
* Soumet un buffer audio PCM16 16 kHz à transcrire. Le state passe en
* Processing(Stt), puis l'événement UserSpeechFinalized est émis avec
* le texte. Le pipeline va à `Idle` (en Phase 1, pas de LLM/TTS).
*
* Annule toute opération précédente en cours.
*/
fun submitAudio(audioPcm16: ShortArray) {
if (!stt.isLoaded()) {
KazeiaLogger.pipelineE("submitAudio rejected: STT not loaded")
_state.value = PipelineState.Error("STT pas prêt, attendez le chargement")
return
}
lastJob?.cancel()
lastJob = scope.launch {
runPipeline(audioPcm16)
}
}
/**
* Démarre la capture micro avec VAD. Le premier segment détecté par VAD
* (150ms parole + 800ms silence) est automatiquement transcrit puis
* traité l'utilisateur n'a pas à appuyer pour arrêter.
*/
fun startVadListening() {
if (!vad.isLoaded() || !stt.isLoaded()) {
KazeiaLogger.pipelineE("startVadListening rejected: not ready (vad=${vad.isLoaded()} stt=${stt.isLoaded()})")
_state.value = PipelineState.Error("STT/VAD pas prêts, attendez le chargement")
return
}
if (_state.value !is PipelineState.Idle) {
KazeiaLogger.pipelineE("startVadListening rejected: state=${_state.value}")
return
}
_state.value = PipelineState.Listening
KazeiaLogger.pipeline("VAD listening started (auto-segment via Silero)")
try {
vad.startSingleSegment { segment ->
// VAD callback runs on the AudioCapture-VAD thread — hand off
// to the service scope for pipeline execution.
lastJob?.cancel()
lastJob = scope.launch { runPipeline(segment) }
}
} catch (e: Exception) {
KazeiaLogger.pipelineE("VAD start failed", e)
_state.value = PipelineState.Error("VAD start failed: ${e.message}")
}
}
private suspend fun runPipeline(audioPcm16: ShortArray) {
try {
// ─── STT ───
_state.value = PipelineState.Processing(PipelineState.Processing.Phase.Stt)
val pipelineStart = System.currentTimeMillis()
KazeiaLogger.pipeline("=== run start (audio=${audioPcm16.size} samples) ===")
val sttRes = stt.transcribe(audioPcm16)
_events.emit(UiEvent.UserSpeechFinalized(sttRes.text))
KazeiaLogger.pipeline("STT.end @ +${System.currentTimeMillis() - pipelineStart}ms text='${sttRes.text}'")
if (sttRes.text.isBlank()) {
KazeiaLogger.pipeline("STT empty — skipping LLM cascade")
_state.value = PipelineState.Idle
return
}
if (!cascade.isLoaded()) {
KazeiaLogger.pipelineE("Cascade not loaded — pipeline aborted")
_state.value = PipelineState.Error("Cascade LLM pas prête")
return
}
// ─── Thinker ───
_state.value = PipelineState.Processing(PipelineState.Processing.Phase.Thinker)
// Speaker est lancé dans le même call que Thinker côté Stage; on
// passe l'état en "Speaker" juste avant le 2e step pour que l'UI
// reflète quelle phase tourne. On le fait via un wrapper.
val cascadeStart = System.currentTimeMillis()
val result = withContext(Dispatchers.IO) {
// Workaround : on ne peut pas update _state au milieu sans
// refactor LlmCascadeStage. Pour Phase 2, l'UI affiche Thinker
// pendant les ~50% premiers du temps puis Speaker. Acceptable.
cascade.run(sttRes.text)
}
val cascadeMs = System.currentTimeMillis() - cascadeStart
KazeiaLogger.pipeline("Cascade.end @ +${System.currentTimeMillis() - pipelineStart}ms total=${cascadeMs}ms (T=${result.thinkerMs}ms, S=${result.speakerMs}ms)")
_events.emit(UiEvent.KazeiaResponseReady(result.response))
// ─── TTS ───
if (tts.isLoaded() && result.response.isNotBlank()) {
_state.value = PipelineState.Speaking(result.response)
val ttsStart = System.currentTimeMillis()
tts.speak(result.response)
KazeiaLogger.pipeline("TTS.end @ +${System.currentTimeMillis() - pipelineStart}ms speak=${System.currentTimeMillis() - ttsStart}ms")
_events.emit(UiEvent.KazeiaSpeakingDone)
} else {
KazeiaLogger.pipeline("TTS skipped (loaded=${tts.isLoaded()}, response.blank=${result.response.isBlank()})")
}
_state.value = PipelineState.Idle
KazeiaLogger.pipeline("=== run end (Phase 1 stub) ===")
} catch (e: Exception) {
KazeiaLogger.pipelineE("pipeline failed", e)
_state.value = PipelineState.Error(e.message ?: "unknown error")
}
}
/** Annulation manuelle de la capture VAD avant que le segment ne soit livré. */
fun cancelListening() {
if (_state.value is PipelineState.Listening) {
vad.stop()
_state.value = PipelineState.Idle
KazeiaLogger.pipeline("Listening cancelled by user")
}
}
}

View File

@ -1,88 +0,0 @@
package com.kazeia.v2
/**
* LLM stage pour le pipeline v2.
*
* **Mode actuel : MONO-Speaker (Qwen3-4B)** temporaire pour faire rentrer
* cascade+TTS dans le budget RAM ~16 GB de l'OnePlus Pad 3. Le Thinker
* (Qwen3Guard-4B, 3.3 GB) est désactivé : la cascade complète + TTS dépasse
* le plafond et déclenche le LMK Android (OOM kill foreground).
*
* Le Speaker reçoit le message utilisateur directement, avec un system
* prompt généraliste de psychothérapeute qui valide l'émotion et répond
* de façon concise. Quand le rewrite TTS Kotlin sera fait (~1 GB économisé)
* ou phased loading implémenté, on restaurera le Thinker. Le code Thinker
* est conservé en commentaire pour faciliter la restauration.
*
* Spawn 1× `qnn_llama_runner --daemon_mode` sur la tablette via
* `ExecutorchDaemon`. Le daemon expose call(prompt, system, seqLen, temp).
*/
class LlmCascadeStage {
companion object {
// Prompt mono-Speaker généraliste psychothérapeute. Direct et concis,
// pas de référence à des ressources d'urgence ou numéros.
const val SYS_SPEAKER = """Tu es un psychologue bienveillant. Tu reponds en DEUX OU TROIS phrases francaises courtes, separees par des points. Chaque phrase fait 6 a 12 mots maximum. Va droit au point avec une validation emotionnelle simple, pas de remplissage, pas de redondance.
Pourquoi DEUX-TROIS phrases courtes : la premiere phrase est jouee en audio pendant que les suivantes sont synthetisees reponse percue beaucoup plus rapide. UNE seule phrase longue ne permet pas ce streaming.
Pas de jargon, pas de recommandations medicales, pas de mention de lignes d'ecoute ni de numeros. /no_think"""
// Conservé pour restauration future de la cascade.
const val SYS_THINKER = """Tu es un psychologue clinicien experimente qui ecoute attentivement ton patient. Avant de repondre, tu prepares une analyse silencieuse pour orienter ta reponse. Produis UNIQUEMENT le bloc clinique au format strict suivant :
- emotion : <emotion principale ressentie, 5 mots max>
- besoin : <besoin sous-jacent du patient, 5 mots max>
- approche : <posture therapeutique adaptee, 5 mots max>
- axe : <point cle a valider ou explorer, 5 mots max>
Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think"""
private const val SEQ_LEN = 512
private const val TEMP = 0.0
}
private val speaker = ExecutorchDaemon(
name = "speaker",
pte = "hybrid_llama_qnn_4b.pte",
tokenizer = "tokenizer.json",
label = "S",
logTag = KazeiaLogger::speaker
)
@Volatile private var loaded = false
fun load() {
if (loaded) return
val t0 = System.currentTimeMillis()
KazeiaLogger.speaker("loading… (mono-Speaker mode, Thinker disabled for RAM budget)")
speaker.start()
val tS = System.currentTimeMillis()
loaded = true
KazeiaLogger.pipeline("LLM ready (mono-Speaker) : speaker=${tS - t0}ms")
}
fun isLoaded(): Boolean = loaded && speaker.isAlive()
data class Result(
val bullets: String,
val response: String,
val thinkerMs: Long,
val speakerMs: Long
)
fun run(userMessage: String): Result {
if (!isLoaded()) throw IllegalStateException("LLM not loaded")
// Mono-Speaker : message utilisateur direct, pas d'analyse Thinker.
KazeiaLogger.speaker("user='${userMessage.take(120)}'")
val (response, speakerMs) = speaker.call(userMessage, SYS_SPEAKER, SEQ_LEN, TEMP)
KazeiaLogger.speaker("response (${speakerMs}ms): '${response.take(200)}'")
return Result(bullets = "", response = response, thinkerMs = 0L, speakerMs = speakerMs)
}
fun release() {
try { speaker.stop() } catch (_: Exception) {}
loaded = false
KazeiaLogger.pipeline("LLM released (mono-Speaker)")
}
}

View File

@ -1,123 +0,0 @@
package com.kazeia.v2
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Typeface
import android.util.AttributeSet
import android.view.View
/**
* Mini graphique CPU% + RAM en sparklines pour le panneau debug v2.
*
* Affiche les N derniers samples sous forme de courbes empilées :
* - Ligne 1 (haut) : CPU% (cyan, plein)
* - Ligne 2 (bas) : RAM MB (orange) avec marqueur "limite OOM" (rouge)
*
* Pas de dépendance externe (pas de MPAndroidChart). Custom Canvas, léger.
*/
class MetricsGraphView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
companion object {
private const val MAX_SAMPLES = 120 // 60 sec @ 0.5 Hz polling
private const val CPU_PCT_MAX = 100f // 100 % d'un cœur
private const val RAM_MAX_GB = 6f // pic attendu max ~5.5 GB
}
private val cpuSamples = FloatArray(MAX_SAMPLES)
private val ramSamples = FloatArray(MAX_SAMPLES) // GB
private val sysAvailSamples = FloatArray(MAX_SAMPLES) // GB
private var head = 0
private var count = 0
private val cpuPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF58A6FF.toInt(); style = Paint.Style.STROKE; strokeWidth = 2f
}
private val ramPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFFFFA657.toInt(); style = Paint.Style.STROKE; strokeWidth = 2f
}
private val availPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF7EE787.toInt(); style = Paint.Style.STROKE; strokeWidth = 2f
}
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF30363D.toInt(); style = Paint.Style.STROKE; strokeWidth = 1f
}
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFFC9D1D9.toInt(); textSize = 22f; typeface = Typeface.MONOSPACE
}
fun addSample(cpuPct: Float, ramGb: Float, sysAvailGb: Float) {
cpuSamples[head] = cpuPct
ramSamples[head] = ramGb
sysAvailSamples[head] = sysAvailGb
head = (head + 1) % MAX_SAMPLES
if (count < MAX_SAMPLES) count++
postInvalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawColor(Color.parseColor("#0D1117"))
val w = width.toFloat()
val h = height.toFloat()
if (count < 2) {
canvas.drawText("Collecte des métriques…", 16f, h / 2f, labelPaint)
return
}
// ── Layout : 2 zones ─────────────────────────────────
// top : CPU% (haut 40 %)
// bottom : RAM GB + sysAvail GB (bas 60 %)
val topH = h * 0.4f
val botY0 = topH + 8f
val botH = h - botY0 - 24f // -24 pour la légende du bas
// ── Grille horizontale ──
canvas.drawLine(0f, topH, w, topH, gridPaint)
canvas.drawLine(0f, botY0 + botH, w, botY0 + botH, gridPaint)
// Échantillon X = (index relatif) -> px
fun x(i: Int): Float = (i.toFloat() / (MAX_SAMPLES - 1)) * w
// Indices ordonnés (oldest → newest)
val indices = IntArray(count) { (head - count + it + MAX_SAMPLES) % MAX_SAMPLES }
// ── CPU% (top zone) ──
val cpuPath = Path()
for ((seq, idx) in indices.withIndex()) {
val px = x(seq)
val py = topH - (cpuSamples[idx] / CPU_PCT_MAX).coerceIn(0f, 1f) * (topH - 4f)
if (seq == 0) cpuPath.moveTo(px, py) else cpuPath.lineTo(px, py)
}
canvas.drawPath(cpuPath, cpuPaint)
val lastCpu = cpuSamples[(head - 1 + MAX_SAMPLES) % MAX_SAMPLES]
canvas.drawText("CPU %.0f%%".format(lastCpu), 8f, 24f, labelPaint)
// ── RAM (bot zone) — RAM = orange, sysAvail = vert ──
val ramPath = Path()
val availPath = Path()
val ramScale = 1f / RAM_MAX_GB
for ((seq, idx) in indices.withIndex()) {
val px = x(seq)
val ramY = botY0 + botH - (ramSamples[idx] * ramScale).coerceIn(0f, 1f) * botH
val avY = botY0 + botH - (sysAvailSamples[idx] * ramScale).coerceIn(0f, 1f) * botH
if (seq == 0) { ramPath.moveTo(px, ramY); availPath.moveTo(px, avY) }
else { ramPath.lineTo(px, ramY); availPath.lineTo(px, avY) }
}
canvas.drawPath(ramPath, ramPaint)
canvas.drawPath(availPath, availPaint)
// Légende bas
val lastRam = ramSamples[(head - 1 + MAX_SAMPLES) % MAX_SAMPLES]
val lastAvail = sysAvailSamples[(head - 1 + MAX_SAMPLES) % MAX_SAMPLES]
labelPaint.color = 0xFFFFA657.toInt()
canvas.drawText("RAM %.2fGB".format(lastRam), 8f, h - 4f, labelPaint)
labelPaint.color = 0xFF7EE787.toInt()
canvas.drawText("free %.2fGB".format(lastAvail), w * 0.45f, h - 4f, labelPaint)
labelPaint.color = 0xFFC9D1D9.toInt()
}
}

View File

@ -1,93 +0,0 @@
package com.kazeia.v2
import android.os.Handler
import android.os.Looper
import java.io.File
/**
* Échantillonne CPU% + RAM main process + sys_avail à intervalle régulier.
*
* Lit /proc/self/status (VmRSS) + /proc/self/stat (utime+stime) + /proc/meminfo.
* Toutes ces sources sont accessibles à un process Android sans permission.
*
* CPU% : delta jiffies (utime+stime) entre 2 samples ÷ jiffies elapsed wall-clock.
* Sur Android `_SC_CLK_TCK` typiquement 100 Hz.
*/
class MetricsSampler(
private val intervalMs: Long = 500,
private val onSample: (cpuPct: Float, ramGb: Float, sysAvailGb: Float) -> Unit
) {
private val handler = Handler(Looper.getMainLooper())
private var prevCpuJiffies: Long = -1
private var prevWallNs: Long = 0
private val clkTck = 100L // Hz typique Android
private val task = object : Runnable {
override fun run() {
try { sampleOnce() } catch (_: Exception) {}
handler.postDelayed(this, intervalMs)
}
}
fun start() {
prevCpuJiffies = -1
handler.postDelayed(task, 100)
}
fun stop() {
handler.removeCallbacks(task)
}
private fun sampleOnce() {
// ── CPU jiffies ──
// /proc/self/stat : 14e + 15e tokens = utime + stime (jiffies)
val statText = File("/proc/self/stat").readText().trim()
// Cherche la ligne après ')' (le comm peut contenir des espaces)
val tail = statText.substringAfterLast(')').trim().split(' ')
// Après ')', les champs commencent à #3 (state). utime = #14 absolue → #11 dans tail.
val utime = tail.getOrNull(11)?.toLongOrNull() ?: 0
val stime = tail.getOrNull(12)?.toLongOrNull() ?: 0
val cpuJiffies = utime + stime
val nowNs = System.nanoTime()
val cpuPct: Float = if (prevCpuJiffies >= 0) {
val deltaJ = (cpuJiffies - prevCpuJiffies).coerceAtLeast(0L)
val deltaNs = (nowNs - prevWallNs).coerceAtLeast(1L)
val deltaWallSec = deltaNs / 1_000_000_000.0
// % = (deltaJ / Hz) / wallSec * 100 — sur 1 cœur
((deltaJ.toDouble() / clkTck.toDouble()) / deltaWallSec * 100.0).toFloat()
} else 0f
prevCpuJiffies = cpuJiffies
prevWallNs = nowNs
// ── RAM main process (VmRSS) ──
val rssKb = parseStatusKb("VmRSS:")
val ramGb = rssKb / 1024f / 1024f
// ── sys_avail ──
val availKb = File("/proc/meminfo").bufferedReader().use { reader ->
var v = 0L
reader.forEachLine { line ->
if (line.startsWith("MemAvailable:")) {
v = line.split(Regex("\\s+"))[1].toLongOrNull() ?: 0L
}
}
v
}
val sysAvailGb = availKb / 1024f / 1024f
onSample(cpuPct, ramGb, sysAvailGb)
}
private fun parseStatusKb(field: String): Long {
return File("/proc/self/status").bufferedReader().use { reader ->
var v = 0L
reader.forEachLine { line ->
if (line.startsWith(field)) {
v = line.split(Regex("\\s+"))[1].toLongOrNull() ?: 0L
}
}
v
}
}
}

View File

@ -1,40 +0,0 @@
package com.kazeia.v2
/**
* États du pipeline Kazeia v2.
*
* Transitions :
* Idle [PTT pressed]> Listening [silence/PTT release]>
* Processing(Stt) > Processing(Thinker) > Processing(Speaker) >
* Processing(TtsStart) > Speaking(text) [audio finished]> Idle
*
* L'UI mappe ces états en éléments visuels :
* Idle, Listening, Processing(*) bulle Kazeia avec dots animés "..."
* Speaking(text) bulle Kazeia avec le texte FINAL du Speaker
*
* Les bullets du Thinker n'apparaissent JAMAIS dans l'UI.
*/
sealed class PipelineState {
object Idle : PipelineState()
object Listening : PipelineState()
data class Processing(val phase: Phase) : PipelineState() {
enum class Phase { Stt, Thinker, Speaker, TtsStart }
}
/** Le texte du Speaker affiché dans la bulle. L'audio joue en parallèle. */
data class Speaking(val text: String) : PipelineState()
/** Erreur runtime ; UI affiche un message d'erreur générique. */
data class Error(val msg: String) : PipelineState()
}
/**
* Événements émis vers l'UI. Découplés de l'état pour granularité fine
* (un événement peut transiter sans changer l'état coarse).
*/
sealed class UiEvent {
/** STT a finalisé la transcription utilisateur → bulle USER */
data class UserSpeechFinalized(val text: String) : UiEvent()
/** Speaker a fini sa réponse → bulle KAZEIA en UNE FOIS */
data class KazeiaResponseReady(val text: String) : UiEvent()
/** Audio TTS terminé, retour à idle */
object KazeiaSpeakingDone : UiEvent()
}

View File

@ -1,77 +0,0 @@
package com.kazeia.v2
import android.content.Context
import com.kazeia.KazeiaApplication
import com.kazeia.core.SttEngine
import com.kazeia.stt.KazeiaSttAdapter
import com.kazeia.stt.WhisperHybridEngine
/**
* Wrapper du STT existant (WhisperHybridEngine NPU) pour le pipeline v2.
* Ne touche PAS au C++ JNI. Expose juste une API simple :
* load() / transcribe(audio) / release()
*
* Logs structurés via KazeiaLogger.stt(...).
*/
class SttStage(private val context: Context) {
companion object {
// Non-const : MODELS_DIR est résolu au runtime par KazeiaPaths.
private val WHISPER_DIR: String
get() = "${KazeiaApplication.MODELS_DIR}/whisper-small-sm8750"
}
private var engine: SttEngine? = null
suspend fun load() {
if (engine?.isLoaded() == true) return
val t0 = System.currentTimeMillis()
// Backend STT pilotable via ConfigStore.sttEngine ("prod"|"lib"). Default = prod
// (WhisperHybridEngine gelé). "lib" = KazeiaSttAdapter (libkazeia_stt.so).
val backend = com.kazeia.config.ConfigStore.get(context).current().sttEngine
val e: SttEngine = when (backend) {
"lib" -> KazeiaSttAdapter(WHISPER_DIR) { msg -> KazeiaLogger.stt(msg) }
else -> WhisperHybridEngine(context.applicationInfo.nativeLibraryDir) { msg ->
KazeiaLogger.stt(msg)
}
}
e.load(WHISPER_DIR)
if (!e.isLoaded()) {
throw IllegalStateException("STT '$backend' failed to load from $WHISPER_DIR")
}
engine = e
val dt = System.currentTimeMillis() - t0
KazeiaLogger.stt("loaded STT backend='$backend' in ${dt}ms (path=$WHISPER_DIR)")
}
suspend fun transcribe(audioPcm16: ShortArray, language: String = "fr"): TranscribeResult {
val e = engine ?: throw IllegalStateException("SttStage not loaded")
val audioMs = (audioPcm16.size * 1000L) / 16000 // Whisper sample rate
KazeiaLogger.stt("start: audio=${audioMs}ms (${audioPcm16.size} samples)")
val t0 = System.currentTimeMillis()
val result = e.transcribe(audioPcm16, language)
val dur = System.currentTimeMillis() - t0
val rtf = dur.toDouble() / audioMs.coerceAtLeast(1)
KazeiaLogger.stt("done: text='${result.text}' dur=${dur}ms rtf=${"%.2f".format(rtf)}")
return TranscribeResult(
text = result.text.trim(),
audioMs = audioMs,
durationMs = dur,
rtf = rtf
)
}
fun isLoaded(): Boolean = engine?.isLoaded() == true
fun release() {
engine?.release()
engine = null
KazeiaLogger.stt("released")
}
data class TranscribeResult(
val text: String,
val audioMs: Long,
val durationMs: Long,
val rtf: Double
)
}

View File

@ -1,101 +0,0 @@
package com.kazeia.v2
import android.content.Context
import com.kazeia.KazeiaApplication
import com.kazeia.tts.Qwen3TtsEngine
/**
* TTS pour le pipeline v2.
*
* Pour Phase 3, on REUTILISE le moteur legacy `Qwen3TtsEngine` (5384 lignes,
* validé end-to-end avec voice cloning Damien FR, RTF ~3.4 sur OnePlus Pad 3).
* Le rewrite Kotlin propre du moteur (cible ~700 lignes) est un chantier
* dédié post-Phase-3 ; ici on se contente de l'orchestrer derrière une API
* simple :
*
* load() / speak(text, onStart, onDone) / stop() / release()
*
* Logs structurés via tags KazTTS-* (les tags fins Talker/CP/Decoder sont
* loggés depuis le moteur legacy via le callback onLog).
*/
class TtsStage(private val context: Context) {
companion object {
// Non-const : MODELS_DIR est résolu au runtime par KazeiaPaths.
private val MODEL_DIR: String
get() = "${KazeiaApplication.MODELS_DIR}/qwen3-tts-npu"
}
private var engine: Qwen3TtsEngine? = null
suspend fun load() {
if (engine?.isLoaded() == true) return
val t0 = System.currentTimeMillis()
val nativeLibDir = context.applicationInfo.nativeLibraryDir
val e = Qwen3TtsEngine(nativeLibDir, context) { msg ->
// Le moteur legacy distingue déjà ses sous-systèmes via préfixes
// dans `msg` ; on route tout sur KazTTS-Talker pour visibilité.
KazeiaLogger.ttsTalker(msg)
}
e.load(MODEL_DIR)
if (!e.isLoaded()) {
throw IllegalStateException("Qwen3TtsEngine failed to load from $MODEL_DIR")
}
engine = e
val dt = System.currentTimeMillis() - t0
KazeiaLogger.ttsTalker("TTS loaded in ${dt}ms (path=$MODEL_DIR)")
}
fun isLoaded(): Boolean = engine?.isLoaded() == true
/**
* Synthétise [text] via la voie validée Stage 2 (streaming session) :
* - startStreamingSession() ouvre AudioTrack + worker coroutine
* - SentenceStreamer découpe le texte en phrases
* - chaque phrase passe par generateSegmentAudioVC (BPE + Damien voice
* prefix/suffix + EOS boost dynamique + MediaPlayer fallback ColorOS)
* - endStreamingSession() attend la fin et close l'AudioTrack
*
* Suspend jusqu'à ce que tout l'audio soit drainé.
*/
suspend fun speak(text: String, onStart: (() -> Unit)? = null) {
val e = engine ?: run {
KazeiaLogger.ttsTalker("speak rejected: TTS not loaded")
return
}
if (text.isBlank()) {
KazeiaLogger.ttsTalker("speak rejected: empty text")
return
}
val t0 = System.currentTimeMillis()
KazeiaLogger.ttsTalker("speak start: '${text.take(120)}'")
try {
e.startStreamingSession()
onStart?.invoke()
// Découpe en phrases pour TTS plus naturelle (la session attend
// chaque phrase pour démarrer son audio progressivement).
val splitter = com.kazeia.tts.SentenceStreamer { sentence ->
KazeiaLogger.ttsTalker("enqueue: '${sentence.take(80)}'")
e.enqueueSentence(sentence)
}
splitter.append(text)
splitter.flush()
e.endStreamingSession()
} catch (ex: Exception) {
KazeiaLogger.ttsTalker("speak ERROR: ${ex.message}")
}
val dt = System.currentTimeMillis() - t0
KazeiaLogger.ttsTalker("speak done: ${dt}ms")
}
fun stop() {
engine?.stop()
KazeiaLogger.ttsTalker("stop requested")
}
fun release() {
engine?.release()
engine = null
KazeiaLogger.ttsTalker("released")
}
}

View File

@ -1,197 +0,0 @@
package com.kazeia.v2
import android.annotation.SuppressLint
import android.content.Context
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import kotlin.concurrent.thread
/**
* VAD énergie (RMS) + capture audio pour le pipeline v2.
*
* Cette implémentation reproduit fidèlement la VAD de la legacy
* `KazeiaService.startContinuousListening` : RMS sur frames 100ms,
* seuil 150, 300ms de parole pour déclencher, 800ms de silence pour
* terminer. Silero n'est PAS utilisé (chargé mais non employé dans la
* legacy non plus l'énergie RMS suffit et est nettement plus robuste
* sur ce matériel/micro).
*
* Workflow :
* 1) load() no-op (juste flag)
* 2) startSingleSegment(onSegment) capture micro et appelle
* `onSegment(pcm16)` UNE fois quand fin-de-parole détectée
* ( SPEECH_MIN puis SILENCE_END). Capture stoppée automatiquement.
* 3) stop() annulation manuelle
*/
class VadStage(@Suppress("UNUSED_PARAMETER") context: Context) {
companion object {
private const val SAMPLE_RATE = 16_000
private const val FRAME_SIZE = 1_600 // 100 ms
private const val FRAME_MS = 100
private const val RMS_THRESHOLD = 150 // legacy value
private const val SPEECH_MIN_FRAMES = 3 // 300 ms parole
private const val SILENCE_END_FRAMES = 8 // 800 ms silence
private const val MAX_SEGMENT_FRAMES = 300 // 30 s max
}
private var loaded = false
@Volatile private var capturing = false
private var captureThread: Thread? = null
private var audioRecord: AudioRecord? = null
fun load() {
loaded = true
KazeiaLogger.stt("VAD (RMS energy) ready: threshold=$RMS_THRESHOLD, frame=${FRAME_MS}ms, speechMin=${SPEECH_MIN_FRAMES * FRAME_MS}ms, silenceEnd=${SILENCE_END_FRAMES * FRAME_MS}ms")
}
fun isLoaded(): Boolean = loaded
@SuppressLint("MissingPermission")
fun startSingleSegment(onSegment: (ShortArray) -> Unit) {
if (!loaded) throw IllegalStateException("VadStage not loaded")
if (capturing) {
KazeiaLogger.stt("VAD start ignored — already capturing")
return
}
capturing = true
captureThread = thread(name = "VadStage-Capture") {
val minBuf = AudioRecord.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT
)
val bufBytes = maxOf(minBuf, SAMPLE_RATE * 2)
KazeiaLogger.stt("VAD capture: minBuf=$minBuf bytes, allocated=$bufBytes")
// Try VOICE_RECOGNITION first, fallback to MIC (mêmes essais que la legacy)
val rec = openRecorder(bufBytes) ?: run {
KazeiaLogger.sttE("AudioRecord: aucune source mic disponible")
capturing = false
return@thread
}
audioRecord = rec
rec.startRecording()
KazeiaLogger.stt("VAD capture: AudioRecord started (recordingState=${rec.recordingState})")
val frame = ShortArray(FRAME_SIZE)
val speechBuf = ArrayList<ShortArray>(64)
var speechFrames = 0
var silenceFrames = 0
var speechActive = false
var frameIdx = 0
val t0 = System.currentTimeMillis()
try {
while (capturing && frameIdx < MAX_SEGMENT_FRAMES) {
val n = rec.read(frame, 0, FRAME_SIZE)
if (n != FRAME_SIZE) {
if (n < 0) {
KazeiaLogger.sttE("AudioRecord read returned $n — bailing")
break
}
continue
}
frameIdx++
var sumSq = 0L
var peak = 0
for (i in 0 until FRAME_SIZE) {
val v = frame[i].toInt()
sumSq += (v.toLong() * v.toLong())
val a = if (v < 0) -v else v
if (a > peak) peak = a
}
val rms = kotlin.math.sqrt(sumSq.toDouble() / FRAME_SIZE).toInt()
val isSpeech = rms > RMS_THRESHOLD
if (frameIdx % 10 == 0 || isSpeech != speechActive) {
KazeiaLogger.stt("VAD f=$frameIdx rms=$rms peak=$peak speech=$isSpeech (active=$speechActive sp=$speechFrames si=$silenceFrames)")
}
if (isSpeech) {
silenceFrames = 0
speechFrames++
speechBuf.add(frame.copyOf())
if (!speechActive && speechFrames >= SPEECH_MIN_FRAMES) {
speechActive = true
KazeiaLogger.stt("VAD: speech START @ frame $frameIdx (rms=$rms)")
}
} else {
if (speechActive) {
silenceFrames++
speechBuf.add(frame.copyOf()) // keep tail silence
if (silenceFrames >= SILENCE_END_FRAMES) {
val total = speechBuf.sumOf { it.size }
val durMs = System.currentTimeMillis() - t0
KazeiaLogger.stt("VAD: speech END @ frame $frameIdx (capture=${durMs}ms, samples=$total)")
val pcm = ShortArray(total)
var off = 0
for (b in speechBuf) { b.copyInto(pcm, off); off += b.size }
onSegment(pcm)
capturing = false
break
}
} else {
// pre-speech silence: drop buffer to bound memory
if (speechBuf.size > 5) speechBuf.clear()
speechFrames = 0
}
}
}
if (frameIdx >= MAX_SEGMENT_FRAMES && speechActive) {
KazeiaLogger.stt("VAD: hit MAX (30s) — flushing")
val total = speechBuf.sumOf { it.size }
val pcm = ShortArray(total)
var off = 0
for (b in speechBuf) { b.copyInto(pcm, off); off += b.size }
onSegment(pcm)
}
} finally {
try { rec.stop() } catch (_: Exception) {}
try { rec.release() } catch (_: Exception) {}
audioRecord = null
capturing = false
KazeiaLogger.stt("VAD capture thread exit (frames=$frameIdx)")
}
}
}
@SuppressLint("MissingPermission")
private fun openRecorder(bufBytes: Int): AudioRecord? {
for ((src, name) in listOf(
MediaRecorder.AudioSource.VOICE_RECOGNITION to "VOICE_RECOGNITION",
MediaRecorder.AudioSource.MIC to "MIC"
)) {
try {
val r = AudioRecord(src, SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufBytes)
if (r.state == AudioRecord.STATE_INITIALIZED) {
KazeiaLogger.stt("AudioRecord opened: source=$name")
return r
} else {
KazeiaLogger.sttE("AudioRecord $name: state=${r.state}")
r.release()
}
} catch (e: Exception) {
KazeiaLogger.sttE("AudioRecord $name exception: ${e.message}")
}
}
return null
}
fun stop() {
if (!capturing) return
capturing = false
captureThread?.join(500)
captureThread = null
KazeiaLogger.stt("VAD capture stopped (manual)")
}
fun release() {
stop()
loaded = false
KazeiaLogger.stt("VAD released")
}
}

View File

@ -1,113 +1,17 @@
cmake_minimum_required(VERSION 3.22) cmake_minimum_required(VERSION 3.22)
project(kazeia-jni) project(kazeia-jni)
set(JNILIBS_DIR ${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}) # Seul JNI natif construit ici : l'extracteur de mel pour le STT Whisper.
#
# --- External source paths (for kazeia_llm llama.cpp backend) --- # Historique des retraits :
set(KAZEIA_LLM_DIR /opt/Kazeia/kazeia-llm) # - Genie JNI bridge refonte engine 2026-06-17 (LLM = UnifiedLlmAdapter).
set(LLAMA_UPSTREAM /opt/Kazeia/llama-upstream) # - TTS legacy (neon_ops, tts_talker_cpu, tts_cp_cpu, tts_decoder_ggml + ggml/
# llama IMPORTED) bascule CosyVoice 2026-06-18.
# Genie JNI bridge supprimé 2026-06-17 (refonte engine) : backend Genie mort, # Sources archivées sous /opt/Kazeia/archive/.
# remplacé par UnifiedLlmAdapter (GGUF + .pte). genie_jni.cpp + libGenie.so #
# archivés dans /opt/Kazeia/archive/2026-06-17_refonte-engine/. # Le reste de la stack prod est livré en libs PREBUILT dans jniLibs/ :
# kazeia_engine (LLM GGUF + RAG), kazeia_pte (LLM .pte NPU), kazeia_stt (STT),
# Whisper.cpp JNI fallback supprimé 2026-05-02 (frozen stack STT = NPU only). # kazeia_cosyvoice/cosyvoice (TTS), + leurs deps (llama, ggml*, QnnHtp*, omp).
# Les libggml IMPORTED restent ci-dessous : utilisées par tts_talker_cpu et tts_cp_cpu.
add_library(ggml SHARED IMPORTED)
set_target_properties(ggml PROPERTIES IMPORTED_LOCATION ${JNILIBS_DIR}/libggml.so)
add_library(ggml-base SHARED IMPORTED)
set_target_properties(ggml-base PROPERTIES IMPORTED_LOCATION ${JNILIBS_DIR}/libggml-base.so)
add_library(ggml-cpu SHARED IMPORTED)
set_target_properties(ggml-cpu PROPERTIES IMPORTED_LOCATION ${JNILIBS_DIR}/libggml-cpu.so)
# --- TTS Pipeline: built externally via ExecuTorch cmake, copied to jniLibs ---
# Build with: cd /opt/Kazeia/executorch/build-android && cmake --build . --target tts_pipeline_jni -j$(nproc)
# Then copy to jniLibs/arm64-v8a/libtts_pipeline.so
# --- NEON optimized ops for TTS heads ---
add_library(neon_ops SHARED neon_ops.cpp)
target_link_libraries(neon_ops log)
target_compile_options(neon_ops PRIVATE -std=c++17 -O3 -march=armv8.2-a+fp16)
# --- Mel Extractor (HuggingFace-compatible, no whisper.cpp dependency) ---
add_library(mel_extractor SHARED mel_extractor.cpp) add_library(mel_extractor SHARED mel_extractor.cpp)
target_link_libraries(mel_extractor android log) target_link_libraries(mel_extractor android log)
target_compile_options(mel_extractor PRIVATE -std=c++17 -O2) target_compile_options(mel_extractor PRIVATE -std=c++17 -O2)
# --- TTS Talker CPU runner (chantier ggml-cpu, Phase 1 in-process JNI) ---
# In-process llama.cpp Talker forward, CPU NEON only (n_gpu_layers=0).
# Loads talker_f16.gguf via libllama.so. Headers vendored in include/
# pour autonomie post-purge llama-upstream. Lib output: libtts_talker_cpu.so
add_library(llama SHARED IMPORTED)
set_target_properties(llama PROPERTIES IMPORTED_LOCATION ${JNILIBS_DIR}/libllama.so)
add_library(tts_talker_cpu SHARED tts_talker_cpu.cpp)
target_include_directories(tts_talker_cpu PRIVATE
${CMAKE_SOURCE_DIR}/include
)
target_link_libraries(tts_talker_cpu llama ggml ggml-base ggml-cpu android log)
target_compile_options(tts_talker_cpu PRIVATE
-std=c++17 -O3 -march=armv8.2-a+fp16
-Wno-unused-parameter -Wno-unused-variable)
# --- TTS CP (Code Predictor) CPU runner ---
# 17-step interleaved decode + 15 dot_argmax NEON classifications per call.
# Loads cp_f16.gguf + cp_heads.bin + cp_codec_embs.bin (125 MB each).
add_library(tts_cp_cpu SHARED tts_cp_cpu.cpp)
target_include_directories(tts_cp_cpu PRIVATE
${CMAKE_SOURCE_DIR}/include
)
target_link_libraries(tts_cp_cpu llama ggml ggml-base ggml-cpu android log)
target_compile_options(tts_cp_cpu PRIVATE
-std=c++17 -O3 -march=armv8.2-a+fp16
-Wno-unused-parameter -Wno-unused-variable)
# --- TTS Decoder pur ggml C++ (remplace .pte ExecuTorch) ---
# IMPORTANT : link STATIQUE de ggml-base/ggml-cpu/ggml depuis whisper.cpp/ggml
# avec ARM features Optim #1 (armv8.6-a+i8mm+dotprod+fp16+bf16). Le .so résultant
# est autonome, NE DÉPEND PAS des libggml*.so partagées (qui sont en armv8.2 et
# servent à libllama.so pour le talker). Cela évite tout conflit ABI/symboles.
# Build des .a : /opt/Kazeia/kazeia-tts-decoder-ggml/build-android-static/
set(TTS_DECODER_DIR /opt/Kazeia/kazeia-tts-decoder-ggml/src)
# Build static-chraac : ggml depuis chraac-llama (RTF 0.96 mesuré 2026-05-02
# vs 1.47 avec llama-upstream). chraac-llama branche dev-refactoring a un
# ggml-cpu plus récent + repack/sgemm optimisés ARM. Backup ancienne version
# disponible via build-android-static.
set(TTS_DECODER_STATIC_DIR /opt/Kazeia/kazeia-tts-decoder-ggml/build-android-static-chraac)
add_library(tts_ggml_base STATIC IMPORTED)
set_target_properties(tts_ggml_base PROPERTIES
IMPORTED_LOCATION ${TTS_DECODER_STATIC_DIR}/ggml-build/src/libggml-base.a)
add_library(tts_ggml_cpu STATIC IMPORTED)
set_target_properties(tts_ggml_cpu PROPERTIES
IMPORTED_LOCATION ${TTS_DECODER_STATIC_DIR}/ggml-build/src/libggml-cpu.a)
add_library(tts_ggml STATIC IMPORTED)
set_target_properties(tts_ggml PROPERTIES
IMPORTED_LOCATION ${TTS_DECODER_STATIC_DIR}/ggml-build/src/libggml.a)
add_library(tts_decoder_ggml SHARED
tts_decoder_ggml.cpp
${TTS_DECODER_DIR}/decoder.cpp
${TTS_DECODER_DIR}/gguf_loader.cpp
)
target_include_directories(tts_decoder_ggml PRIVATE
${TTS_DECODER_DIR}
/opt/Kazeia/whisper.cpp/ggml/include
/opt/Kazeia/whisper.cpp/include
)
# Static linking : the order matters (ggml depends on ggml-cpu and ggml-base).
# Wrap in --whole-archive to force inclusion of backend register globals.
# -fopenmp pour résoudre __kmpc_* / omp_get_*. libomp.so doit être présent
# dans jniLibs/arm64-v8a/ (copié depuis NDK r27d aarch64).
target_link_libraries(tts_decoder_ggml
-Wl,--whole-archive tts_ggml_cpu -Wl,--no-whole-archive
tts_ggml tts_ggml_base
-fopenmp -static-openmp
android log m)
target_compile_options(tts_decoder_ggml PRIVATE
-std=c++17 -O3 -march=armv8.6-a+dotprod+fp16+i8mm+bf16 -fopenmp
-Wno-unused-parameter -Wno-unused-variable -Wno-unused-function)

View File

@ -1,14 +1,11 @@
# jniLibs MANIFEST — état attendu de app/src/main/jniLibs/arm64-v8a/ # jniLibs MANIFEST — état attendu de app/src/main/jniLibs/arm64-v8a/
# Régénéré par scripts/jnilibs.sh update-manifest le 2026-06-17. # Régénéré par scripts/jnilibs.sh update-manifest le 2026-06-18.
# Provenances : kazeia-engine dist (libllama/libggml*/libkazeia_*), # Provenances : kazeia-engine dist (libllama/libggml*/libkazeia_*),
# ExecuTorch build local (libexecutorch*, libqnn_executorch_backend, libfbjni), # ExecuTorch build local (libexecutorch*, libqnn_executorch_backend, libfbjni),
# QNN SDK 2.42 (libQnn*), Genie (libGenie), TTS pipeline (libtts_pipeline), libomp. # QNN SDK 2.42 (libQnn*), Genie (libGenie), TTS pipeline (libtts_pipeline), libomp.
# Les binaires ne sont PAS dans git ; ce manifest fait foi (verify en preBuild). # Les binaires ne sont PAS dans git ; ce manifest fait foi (verify en preBuild).
b0a52229e54bed2b074b38e6b6a6f2203a679f1b1fe7393912de7d7ec3e6196e libcosyvoice.so b0a52229e54bed2b074b38e6b6a6f2203a679f1b1fe7393912de7d7ec3e6196e libcosyvoice.so
d523468d62d9b603cb3354294d70d4b2feabf2c3f1e43b0c96c9aabf32813708 libc++_shared.so d523468d62d9b603cb3354294d70d4b2feabf2c3f1e43b0c96c9aabf32813708 libc++_shared.so
dd0506873aeb45e103ef0023678a7f14938a0f147f2348d1efc366f1dce40863 libexecutorch_jni.so
0b6f86e114f0cf9f2940194797f06ded576e6b3272a5ffbb2c4b8cdc02850ef8 libexecutorch.so
34a0af206f660461e3da687f64172830cc61a32a93732fd7e984c43996d6fd91 libfbjni.so
616fa830f6fd3396ea7f0ca92f348c2146efb0b1154ab6ed2e6709655905ead3 libggml-base.so 616fa830f6fd3396ea7f0ca92f348c2146efb0b1154ab6ed2e6709655905ead3 libggml-base.so
66ea0c948385d8060a7296285d7a85738c21318a88ef767986444a3d9683aeea libggml-cpu.so 66ea0c948385d8060a7296285d7a85738c21318a88ef767986444a3d9683aeea libggml-cpu.so
7aeb209ec5c1da8857779b8f3b4c3e7519ed69c6211491948d3c87349dbced57 libggml.so 7aeb209ec5c1da8857779b8f3b4c3e7519ed69c6211491948d3c87349dbced57 libggml.so
@ -16,18 +13,12 @@ b92ca83a53dab49e212fba411b48fcceca4331b05fe881e2b9220efed45e70b1 libkazeia_cosy
776f37e43433459a7350dc9d5d15dcb8eb3e17465a28fa78de92559bec3918bd libkazeia_engine.so 776f37e43433459a7350dc9d5d15dcb8eb3e17465a28fa78de92559bec3918bd libkazeia_engine.so
f91df82c96d1efd3cbe6f7af6b9d261cd5c6f4a929c08bad49a4cfc1deff4477 libkazeia_pte.so f91df82c96d1efd3cbe6f7af6b9d261cd5c6f4a929c08bad49a4cfc1deff4477 libkazeia_pte.so
fdd730af3daf132d0819b3fc99abd8aae79800aab7a9aad371e117c16bd3a3aa libkazeia_stt.so fdd730af3daf132d0819b3fc99abd8aae79800aab7a9aad371e117c16bd3a3aa libkazeia_stt.so
114795bf0256c380dc87971598707e9e0b745a7fc868f3d1fb460e94cecdd69c libkazeia_tts.so
349ba592462113c6e871b9c3ee508727a9e5f154a33905f946bda97535eff586 libllama-common.so
2ca77a28421fc0c680e0a5ddf0208a9471268370893f8dd4eba2a10a8aacb4ac libllama.so 2ca77a28421fc0c680e0a5ddf0208a9471268370893f8dd4eba2a10a8aacb4ac libllama.so
a207169a2696e8f0f7639090c2882152c734368b5696246a514d63b3ce0769c1 libomp.so a207169a2696e8f0f7639090c2882152c734368b5696246a514d63b3ce0769c1 libomp.so
95d01368b556f0c698cbbb6daa47494c666e0242a063cf1ca830202946435df4 libqnn_executorch_backend.so 95d01368b556f0c698cbbb6daa47494c666e0242a063cf1ca830202946435df4 libqnn_executorch_backend.so
414794639e2897120143cff80aaf73e830e305357b6c1aa8343de380c89b8c36 libQnnGpuNetRunExtensions.so
26f13b297d375e9629eb997b7adb05a4ce9e1c17d306baaefe905563fee43eb8 libQnnGpuProfilingReader.so
b5b52b66f3fc5567429024409dcedb006ca6a9115f271689810589b7d79af430 libQnnGpu.so
a3bc48674377a04248fcacbdf578a98b47d457821c53e6450ca11b59a3c5ef93 libQnnHtpNetRunExtensions.so a3bc48674377a04248fcacbdf578a98b47d457821c53e6450ca11b59a3c5ef93 libQnnHtpNetRunExtensions.so
2daafb376fe6904aa325e29810480e3d76dc392991d1367ae6ea3508db2b60ac libQnnHtpPrepare.so 2daafb376fe6904aa325e29810480e3d76dc392991d1367ae6ea3508db2b60ac libQnnHtpPrepare.so
10eb1923b34ac9a726e4a446b395d5eee2ea5388487917778aaf954e8bfb2d92 libQnnHtp.so 10eb1923b34ac9a726e4a446b395d5eee2ea5388487917778aaf954e8bfb2d92 libQnnHtp.so
7ee72b438c97c13ceb96d18fe255d1f156afbdc4c43669752f349dd5a56e62a3 libQnnHtpV79Skel.so 7ee72b438c97c13ceb96d18fe255d1f156afbdc4c43669752f349dd5a56e62a3 libQnnHtpV79Skel.so
a08fb34747345125fd3cef3fc10046c663465dd60fddbac9cc37436b3d2e68a9 libQnnHtpV79Stub.so a08fb34747345125fd3cef3fc10046c663465dd60fddbac9cc37436b3d2e68a9 libQnnHtpV79Stub.so
a69cfc4350c16b226bcaa19c657234820bb93e3ff6116cf3845ae77557f1f7c3 libQnnSystem.so a69cfc4350c16b226bcaa19c657234820bb93e3ff6116cf3845ae77557f1f7c3 libQnnSystem.so
d35a02f70cb63ec60c8ae78011f5e93aecbb31396d5395a4a82cb1bc71803956 libtts_pipeline.so