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:
parent
3d002318d5
commit
66e81f0668
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
|
||||||
qwen.onSegmentPlaying = onSegmentPlaying
|
|
||||||
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,
|
ttsEngine.synthesizeAndPlay(text, context.language,
|
||||||
onComplete = { _pipelineState.value = PipelineState.Idle }
|
onComplete = { _pipelineState.value = PipelineState.Idle })
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log("TTS error: ${e.message}")
|
log("TTS error: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
// 9 langues, CPU-only) — NON validé device tant que la collision
|
|
||||||
// libggml n'est pas levée côté libs engine (cf CosyVoiceTtsEngine).
|
|
||||||
when (runtimeConfig.ttsEngine) {
|
|
||||||
"cosyvoice" -> {
|
|
||||||
_loadingState.value = LoadingState(15, "TTS CosyVoice…")
|
_loadingState.value = LoadingState(15, "TTS CosyVoice…")
|
||||||
val cv = com.kazeia.tts.CosyVoiceTtsEngine(log = { msg -> log("[TTS] $msg") })
|
val cv = com.kazeia.tts.CosyVoiceTtsEngine(log = { msg -> log("[TTS] $msg") })
|
||||||
cv.load()
|
cv.load()
|
||||||
if (!cv.isLoaded()) throw IllegalStateException("CosyVoice load failed")
|
if (!cv.isLoaded()) throw IllegalStateException("CosyVoice load failed")
|
||||||
tts = cv
|
tts = cv
|
||||||
log("TTS: CosyVoiceTtsEngine loaded (clonage vocal CPU-only)")
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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 ===")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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) }
|
|
||||||
}
|
|
||||||
|
|
@ -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?
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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}" } ?: ""}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue