Compare commits

...

22 Commits

Author SHA1 Message Date
Kazeia Team 2e6c55a068 feat(debug): encart temps = génération seule (sans la durée de lecture)
L'encart « dernier tour » ne compte plus que le temps de PRODUCTION de la réponse
(STT · Thinker · Speaker · TTS jusqu'au 1er son). La durée de lecture audio est
retirée du décompte (ce n'est pas de la génération, elle dépend de la longueur du
texte). Les lignes s'additionnent exactement au « TOTAL gén. ».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 14:48:28 +02:00
Kazeia Team 078804e2de feat(debug): encart « dernier tour » + verrou half-duplex entrée
- Verrou half-duplex : pendant la génération LLM + la création TTS (avant le 1er
  son), l'entrée est verrouillée (toggleListening/interruptTts ignorés) — un tap
  micro ne casse plus la synthèse en cours. Déverrouillage au 1er son audible
  (l'usager peut alors interrompre). Flag audiblePlayback.
- KazeiaService déclenche le pré-chauffage TTS (cv.warmup()) en tâche de fond
  après le chargement.
- Métriques par étape : PipelineMetrics expose STT·Thinker·Speaker·TTS isolés ;
  snapshot /turns enrichi (thinker_ms, tts_ms) ; sinks vers le panneau debug.
- Encart debug PERSISTANT « dernier tour » (remplace le graph GPU inutilisé) :
  phrase input · réponse output · temps par étape, en partition ADDITIVE
  Traitement (avant 1er son) / Lecture (durée parlée) dont la somme == TOTAL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 14:42:28 +02:00
Kazeia Team 76ffbcb8fd fix(tts): robustesse lecture CosyVoice + pré-chauffage du cache voix
Série de correctifs après diagnostic device (logs) :
- AudioTrack : garde STATE_INITIALIZED + retry à la construction (le HAL rend
  parfois un track non initialisé après interruption + réouverture micro).
- Interruption pendant la synthèse : abandon PROPRE (check identité du track +
  play() gardé) → plus de « play() on uninitialized AudioTrack » / tour avorté.
- Drain DÉTERMINISTE de fin de lecture : on ne se fie plus à onMarkerReached
  (exige un Looper, absent sur Dispatchers.Default → ne se déclenchait jamais →
  ~6-9 s morts après l'audio). On lit playbackHeadPosition et on attend EXACTEMENT
  le reste à jouer avant de libérer → fin au plus près de la dernière syllabe,
  sans queue morte NI troncature (le flush prématuré coupait la fin).
- Pré-chauffage : warmup() synthétise une phrase jetée au démarrage pour remplir
  le cache K/V du prompt voix → 1er vrai tour chaud (~3 s) au lieu de ~10 s
  (mesuré : 1er énoncé à froid 10→8→5 s en chauffant). Idempotent par voix.
- Mutex de synthèse : warmup et vraie synthèse ne tournent jamais en parallèle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 14:42:14 +02:00
Kazeia Team 7fffd3d9df feat(icons): nouvelles icônes adaptatives orbe (patient + admin)
Identité visuelle = l'orbe Kazeia (la « face » de l'app, cf. AudioVisualizerView).
100% vectoriel (net toutes densités, aucun PNG), monochrome (icônes thématisées 13+).

- Patient : orbe lavande lumineux (halo + reflet) sur dégradé violet profond.
  Remplace l'ancien adaptive-icon vide (fond uni, sans logo) mal placé en
  mipmap-hdpi (supprimé) → mipmap-anydpi-v26 + roundIcon.
- Admin : même orbe cerné d'un anneau d'engrenage 8 dents (signal console/contrôle)
  sur dégradé ardoise-violet sombre ; fond passé de couleur plate à dégradé.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:52:25 +02:00
Kazeia Team 4d73a77ebd feat(voice): exclusivité au record-time + phrase de consentement enrichie
- Phrase de consentement (rec_reference_text) : fin retravaillée pour maximiser
  la couverture phonétique/prosodique du clonage CosyVoice (nasales, semi-voyelles,
  ch/j/r, exclamation + question) tout en gardant le préambule légal.
- Exclusivité DURE capturée à l'enregistrement (consentement/RGPD) : sélecteur de
  portée (exclusive→patient / non-exclusive / lier plus tard) ; picker patient
  peuplé via /profiles (défaut = profil actif). Écrit scope + owner_profile_id +
  owner_name dans le manifeste VoiceStorage → Kazeia-central applique la politique.
  Save désactivé si « exclusive » sans patient ; bascule « pending » si aucun profil.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:52:13 +02:00
Kazeia Team 8bcf9f8ebe fix(voices): /voices reflète l'inventaire CosyVoice (.cvps), pas les vestiges Qwen3
voicesCursor() marquait une voix « ready » dès que ses embeddings Qwen3
(_voice_prefix/suffix.bin) existaient — des vestiges morts depuis la bascule
CosyVoice → des voix fantômes « prête » sans aucun .cvps (incident zelda).

Désormais : union (WAV = « à convertir ») ∪ (cosyvoice/*.cvps = « prête ») ;
les .bin Qwen3 ne sont plus comptés ; nouvelle colonne cvps_exists (additive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:51:56 +02:00
Kazeia Team 613c7efc66 fix(tts): repli sur la voix par défaut si le .cvps configuré est absent
Incident device : profil actif PatientTest a voice_id=zelda, mais seuls
damien.cvps + elodie.cvps sont déployés → voiceHandleOrThrow() levait
« aucune voix chargée ('zelda') » → speakText catch → TTS MUET (aucun son),
alors que STT + LLM fonctionnaient.

voiceHandleOrThrow bascule désormais sur defaultVoiceId (damien, toujours livré)
quand la voix demandée n'a pas de .cvps, au lieu de couper toute parole. Une
voix manquante dégrade gracieusement au lieu de rendre Kazeia muet.

Note (hors scope) : l'endpoint /voices reflète l'inventaire LEGACY Qwen3
(WAV+.bin), pas les .cvps CosyVoice → l'admin peut sélectionner une voix
non déployée. À réconcilier séparément.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:40:42 +02:00
Kazeia Team ca530f6d85 fix(pipeline): régressions UI/STT post-bascule CosyVoice (orbe, texte, 1ère phrase)
Trois bugs introduits/exposés par la bascule CosyVoice (66e81f0) :

#3 orbe figé "réflexion" même fini — KazeiaService.finally : le reset du
   visualizer était conditionné à !_isListening → en écoute continue l'orbe ne
   quittait jamais Thinking. Reset VisualizerSignal.Idle désormais inconditionnel.

#2 texte non affiché pendant le TTS + #3 orbe non animé — cause commune : le
   callback onSegmentPlaying était accepté puis JETÉ par KazeiaPipeline.speakText
   (la branche streaming Qwen3 qui le câblait avait été retirée). Rebranché :
   - core.TtsEngine.synthesizeAndPlay(..., onSegment) ;
   - CosyVoiceTtsEngine émet onSegment(phrase, durée, enveloppe, spectro) par
     phrase au démarrage de sa lecture ;
   - nouvel util tts/AudioFeatures (enveloppe RMS + spectrogramme 12 bandes via
     FFT radix-2 maison, sans dépendance) remplaçant le calcul perdu du chemin
     Qwen3 ; le reveal mot-par-mot et l'orbe Speaking refonctionnent.

#1 1ère phrase ratée — boucle de capture KazeiaService :
   - pré-roll (~300 ms d'anneau sous-seuil préfixé au buffer) → l'attaque
     consonantique n'est plus clippée ;
   - tail buffer anti-écho 800→300 ms : 800 ms visait la queue MediaPlayer
     (disparue) ; CosyVoice draine via marker AudioTrack → 300 ms suffit et ne
     mange plus le début de la réponse après que Kazeia a parlé.

Build release vert + installé OPD2415 (vc15, données préservées). À valider à
l'usage : synchro texte, orbe, capture début de phrase, absence de larsen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:51:35 +02:00
Kazeia Team ecda7b5715 chore(stt): retrait du backend STT "lib" (non utilisé/non validé)
Le backend STT "lib" (KazeiaSttAdapter → com.kazeia.stt.SttEngine → libkazeia_stt)
n'était jamais le défaut (sttEngine="prod"=Whisper) et restait non validé
(transcriptions vides/drift, A/B non mergé). Retiré :
- KazeiaService : dispatch STT simplifié → WhisperHybridEngine seul.
- archivés : KazeiaSttAdapter.kt, stt/SttEngine.kt, BenchQnnHarness.kt + libkazeia_stt.so
  (/opt/Kazeia/archive/2026-06-18_cosyvoice-bascule/stt-lib/).
- KazeiaApplication : trigger bench_qnn retiré.
- ConfigStore.sttEngine documenté vestigial.
- manifest jniLibs re-figé (18→17).
Gardés : WhisperHybridEngine (NPU, gelé), MelExtractor (mel_extractor), core.SttEngine.
Build vert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:56:58 +02:00
Kazeia Team a1c785bdf8 chore(cleanup): purge des traces legacy résiduelles (audit post-bascule)
Audit complet : 0 référence de CODE aux classes/libs supprimées (vérifié).
Nettoyage des résidus :
- code mort : vars locales inutilisées (wavPath dans setVoiceId, sentenceCounter)
  + bindings JNI TTS-Talker Qwen3-TTS de EngineJni (nEmbd/nVocab/resetEmbeds/
  prefillEmbeds/decodeEmbed, 0 caller).
- commentaires périmés recalés : docstring ttsEngine (ConfigStore) + défaut field
  ttsEngine "lib"→"cosyvoice" ; commentaire cascade (ExecuTorchLlmEngine→UnifiedLlmAdapter,
  Guard-0.6B→Guard-4B) ; ttsStreamer (Qwen3→CosyVoice) ; AudioVisualizer SPECTRUM_BANDS.

Build app+admin vert. Restent uniquement des notes historiques exactes
(« X retiré 2026-06-18 ») — documentation, pas du code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:48:03 +02:00
Kazeia Team a2fc01987b release(app): 0.2.3 / versionCode 15 (APK lean sans natifs JNA)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:38:51 +02:00
Kazeia Team 22dbd2145f build: exclure les natifs JNA cross-plateforme (-2,3 Mo, APK = binaires utiles)
JNA (transitif sqlcipher/DJL) embarquait des natifs aix/darwin/win inutiles
(aucun android-aarch64 fourni, JNA non utilisé dans le code). APK ne contient
plus que les binaires nécessaires + le code ; tous les modèles passent par
l'OTA (rappel : l'APK ne doit contenir aucun modèle — vérifié, 0 fichier modèle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:20:13 +02:00
Kazeia Team 66e81f0668 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>
2026-06-18 09:07:41 +02:00
Kazeia Team 3d002318d5 feat(updates): MAJ manuelles pilotées depuis l'admin (plus d'auto-update)
L'app patient ne vérifie/installe plus de MAJ au lancement. OnboardingActivity
démarre directement si le cœur est présent (offline) ; le catalogue n'est
sollicité que pour la 1ʳᵉ installation. Plus de prompt APK ni de worker
d'update en arrière-plan au boot.

Côté patient :
- UpdateStatus (état live) + UpdateWorker (check / install) réutilisant
  ProvisioningManager (contenu) + ApkUpdater (APK, PackageInstaller).
- ContentProvider : call("update_check"|"update_install") enqueue le worker ;
  query /updates expose phase + dispo APK + nb composants + progression.

Côté admin (Kazeia Admin) :
- écran « Mises à jour » (nav) : bouton Vérifier + Installer, état + barre de
  progression (poll /updates), via KazeiaUpdateClient.
- L'install APK ouvre le dialogue système PackageInstaller sur la tablette.

Bump 0.2.1 / versionCode 13. Round-trip device validé (check → AVAILABLE,
1 composant GGUF 2.38 Go détecté).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 08:27:41 +02:00
Kazeia Team a2f82f69f9 release(app): refonte 0.2.0 (versionCode 12)
Jalon refonte : moteur LLM unifié GGUF+.pte (UnifiedLlmAdapter), presets
sampling + toggle debug admin, OTA catalogue v7 (Speaker GGUF). Bump pour
publication self-update aux tablettes de prod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 08:09:06 +02:00
Kazeia Team 577aad3b2f fix(dist): hasCoreModels() reflète le cœur post-refonte (GGUF speaker + Whisper)
Le raccourci hors-ligne testait encore l'ancien .pte 4B + talker_f16 comme
« cœur », périmé depuis la refonte (Speaker = q35-lmq4.gguf, .pte = alternative
admin, TTS provisionné à part #286). Une tablette correctement provisionnée
était vue « cœur manquant » → re-check réseau inutile. Teste désormais
q35-lmq4.gguf + whisper-small-sm8750.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 07:52:22 +02:00
Kazeia Team 471fb5d5fb feat(admin): presets d'échantillonnage LLM (Speaker/Thinker) — app-side complet
Système de presets de sampling réglables depuis l'admin, applicables au Speaker
et au Thinker. Inspiré du tuning Kaz (temp/top-p/top-k/pénalités).

Core/config :
- SamplingParams (core) : +presencePenalty, +frequencyPenalty
- ConfigStore.ModelConfig : +topP/topK/repeatPenalty/presence/frequency/maxTokens
  +presetName ; SamplingPreset + RuntimeConfig.presets (bibliothèque) ; JSON ;
  5 presets d'usine (Équilibré/Kaz chaleureux/Factuel/Analyse/Créatif).
  Défauts maxTokens : Speaker 450, Thinker 128 (préserve la longueur prod).
- ContentProvider : colonnes speaker_*/thinker_* sampling + presets_json (R/W).
- KazeiaService : samplingFrom(ModelConfig) → SamplingParams pour Speaker+Thinker
  (au lieu du hardcodé). maxTokens honoré par le moteur ; le reste prêt.

Admin :
- KazeiaConfigClient : Sampling + Preset + parse/serialize presets_json + updateSampling/updatePresets
- ConfigRepository : updateSampling/updatePresets
- ParametersScreen (nav "Paramètres") : éditeurs sliders Speaker/Thinker + apply preset
  + bibliothèque CRUD ; bandeau honnête « seul Tokens max agit aujourd'hui ».

⚠ Le moteur natif n'honore que maxTokens à ce jour. Spec d'extension JNI sampling
pour le dev engine : docs/SAMPLING_ENGINE_SPEC.md (~10 lignes app à brancher après).

Vérifié device : colonnes sampling R/W (temp/max/preset_name), presets_json seedés
+ lus. Build app+admin verts, admin lancé sans crash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 05:59:18 +02:00
Kazeia Team b11d8a96d4 diag(bench): option .pte multi-contexte en premier (bench_llm_pte_first)
Trigger de diagnostic pour charger les .pte multi-contextes (8B/7B) en
premier dans un process frais. A servi à prouver que l'échec in-app des
gros .pte est un artefact du bench (2ᵉ .pte multi-contexte dans le même
process), PAS un problème d'artefact/version : 8B chargé en 1er = génère
12 tok/s in-app. Prod = 1 .pte/process (restart au switch) → 7B/8B OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 00:13:54 +02:00
Kazeia Team 93a3953614 feat(admin): toggle "mode debug" — masque le bouton logs/métriques côté patient
Nouveau flag ConfigStore.debugEnabled (défaut false), propagé via le
ContentProvider (/config colonne debug_enabled) et piloté par un Switch dans
l'écran Système de l'app admin.

- ConfigStore : champ debugEnabled + (de)sérialisation JSON
- KazeiaTelemetryProvider : colonne debug_enabled (query + update)
- KazeiaService : applique debugEnabled → _debugMode au boot et au reload config
- ChatActivity : _debugMode gate désormais la VISIBILITÉ du bouton debug
  (applyDebugGate). Off → bouton masqué + panneau forcé fermé (écran patient
  épuré). On → bouton visible, ouvre logs + métriques CPU/GPU/NPU/RAM.
- activity_chat.xml : btnDebugToggle visibility=gone par défaut (pas de flash)
- app-admin : KazeiaConfigClient/ConfigRepository + DebugCard dans SystemScreen

Vérifié device : round-trip provider debug_enabled 0→1→0 OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:43:50 +02:00
Kazeia Team d1baacd096 refonte(llm): Phase 3 — archivage code mort (Genie + EngineLlmAdapter)
Après analyse de dépendances : l'essentiel d'ExecuTorch/QNN est VIVANT via le
TTS Qwen3TtsEngine "prod" (org.pytorch.executorch.Module → libexecutorch.so,
+ libQnnGpu.so backend Adreno, + QnnHtp). NE PAS archiver (stack TTS figée).

Réellement mort (refonte LLM) → archivé dans
/opt/Kazeia/archive/2026-06-17_refonte-engine/ :
- EngineLlmAdapter.kt (ancien adaptateur GGUF, remplacé par UnifiedLlmAdapter ;
  références restantes = commentaires uniquement)
- GenieJni.kt + genie_jni.cpp + libGenie.so (backend Genie mort ; libgenie_jni.so
  n'était même plus shipé → GenieJni ne pouvait plus se charger)
- cible CMake genie_jni retirée ; manifest jniLibs re-figé (27 libs, -libGenie)

Aussi : ConfigStore.llmEngine documenté comme VESTIGIAL (le dispatch passe par
UnifiedLlmAdapter magic-byte depuis la refonte ; modèle choisi par speaker.modelId).

Build release vert ; LLM device non-régressé (GGUF 18 / qwen3-4b .pte 18.6 /
guard4b .pte 21 tok/s) ; aucune lib genie dans l'APK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:28:14 +02:00
Kazeia Team 0108f47c09 refonte(llm): NPU .pte in-app sans root (ADSP_LIBRARY_PATH) + normalisation think-block
Validation device (OPD2415 rooté, mais .pte sans root via PD signé) :
- GGUF défaut Qwen3.5-4B : ~18 tok/s, FR cohérent (chemin session cache-préfixe)
- qwen3-4b .pte NPU : 18.6 tok/s | guard4b .pte NPU : 21 tok/s — FR cohérent
- DJL tokenizer arm64 (tokenizer-native:0.33.0) OK on-device

Fixes :
- KazeiaApplication : pose ADSP_LIBRARY_PATH (dossier natif extrait + chemins DSP)
  AVANT toute session QNN. Sans ça le CDSP ne trouve pas libQnnHtpV79Skel.so
  (errno 2) → device_handle 14001 → .pte renvoie handle 0. Absent de la spec
  engine (validée via qnn_llama_runner CLI, où le shell exporte ADSP_LIBRARY_PATH).
- UnifiedLlmAdapter : strip du bloc <think>…</think> en tête de sortie .pte
  (pas des special tokens => skipSpecialTokens ne les retire pas ; le GGUF le
  strippe nativement). Sinon le TTS le lit et la cascade parse faux.
- BenchLlmHarness : réécrit pour valider UnifiedLlmAdapter sur GGUF + chaque .pte.

Connu (hors périmètre intégration) :
- qwen3-8b .pte (export 05-23, sharding=2) : blob contexte incompatible runtime
  QAIRT 2.42 (magic 0x5678abcd attendu, 0x2000000 lu ; "Context group 1") →
  re-export 2.42 requis côté engine.
- qwen2.5-7b .pte absent du device (seul le GGUF est présent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:00:31 +02:00
Kazeia Team 0a72654093 refonte(llm): moteur LLM unifié GGUF + .pte NPU (UnifiedLlmAdapter)
Remplace la couche LLM par la dernière version de kazeia-engine
(cf dist/LLM_INTEGRATION.md). Chargement par magic-byte :
- GGUF ("GGUF"@0)  -> EngineLlmEngine (libllama CPU i8mm, session cache-préfixe KV)
- .pte  ("ET12"@4) -> PteLlmEngine (ExecuTorch+QNN, NPU V79, sans root)

- LlmLoader.kt copié de l'engine + fix encode DJL (encode(p,false,false))
- UnifiedLlmAdapter : pont com.kazeia.core.LlmEngine, generateWithSystem par tour
- ModelRegistry : 5 modèles (Qwen3.5-4B GGUF défaut + 4 .pte en sous-dossiers)
- KazeiaService recâblé ; DEFAULT_SYSTEM_PROMPT -> ConfigStore.DEFAULT_SPEAKER_PROMPT
- DJL tokenizer arm64 Android : tokenizers + tokenizer-native alignés 0.33.0
  (natif libdjl_tokenizer.so dans l'AAR ; natifs desktop exclus, -23 Mo APK)
- jniLibs : +libkazeia_pte.so, +libQnnHtpNetRunExtensions.so,
  libqnn_executorch_backend.so -> QAIRT 2.42 ; manifest re-figé (28 libs)
- fichiers morts archivés (ExecuTorchLlmEngine, GenieLlmEngine)

Phase 1 (#287) : build debug green. Phase 2 = validation device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:47:32 +02:00
80 changed files with 2662 additions and 9903 deletions

View File

@ -14,7 +14,7 @@
<application <application
android:label="Kazeia Admin" android:label="Kazeia Admin"
android:icon="@mipmap/ic_launcher_admin" android:icon="@mipmap/ic_launcher_admin"
android:roundIcon="@mipmap/ic_launcher_admin" android:roundIcon="@mipmap/ic_launcher_admin_round"
android:theme="@style/Theme.KazeiaAdmin" android:theme="@style/Theme.KazeiaAdmin"
android:supportsRtl="true" android:supportsRtl="true"
android:allowBackup="false"> android:allowBackup="false">

View File

@ -93,6 +93,9 @@ private fun MainShell() {
composable(AdminDestination.Prompts.route) { composable(AdminDestination.Prompts.route) {
PromptsScreen() PromptsScreen()
} }
composable(AdminDestination.Parameters.route) {
com.kazeia.admin.ui.parameters.ParametersScreen()
}
composable(AdminDestination.Rag.route) { composable(AdminDestination.Rag.route) {
com.kazeia.admin.ui.rag.RagScreen() com.kazeia.admin.ui.rag.RagScreen()
} }
@ -102,6 +105,9 @@ private fun MainShell() {
composable(AdminDestination.Telemetry.route) { composable(AdminDestination.Telemetry.route) {
TelemetryScreen() TelemetryScreen()
} }
composable(AdminDestination.Updates.route) {
com.kazeia.admin.ui.updates.UpdatesScreen()
}
composable(AdminDestination.System.route) { composable(AdminDestination.System.route) {
SystemScreen() SystemScreen()
} }

View File

@ -22,6 +22,18 @@ class ConfigRepository(context: Context) {
fun updateTts(enabled: Boolean): Boolean = fun updateTts(enabled: Boolean): Boolean =
client.updateConfig(ttsEnabled = enabled) client.updateConfig(ttsEnabled = enabled)
fun updateDebug(enabled: Boolean): Boolean =
client.updateConfig(debugEnabled = enabled)
/** Applique un jeu de sampling à un rôle ("speaker"|"thinker"), avec le nom
* du preset source ("" = réglages custom). */
fun updateSampling(role: String, s: KazeiaConfigClient.Sampling, presetName: String): Boolean =
client.updateSampling(role, s, presetName)
/** Remplace toute la bibliothèque de presets (création / édition / suppression). */
fun updatePresets(presets: List<KazeiaConfigClient.Preset>): Boolean =
client.updatePresets(presets)
fun updateSpeakerModel(modelId: String): Boolean = fun updateSpeakerModel(modelId: String): Boolean =
client.updateConfig(speakerModelId = modelId) client.updateConfig(speakerModelId = modelId)

View File

@ -2,7 +2,10 @@ package com.kazeia.admin.data.source
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentValues import android.content.ContentValues
import android.database.Cursor
import android.net.Uri import android.net.Uri
import org.json.JSONArray
import org.json.JSONObject
/** /**
* Client du ContentProvider Kazeia pour les URI `/config` et `/models`. * Client du ContentProvider Kazeia pour les URI `/config` et `/models`.
@ -17,19 +20,41 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
val MODELS_URI: Uri = Uri.parse("content://$AUTHORITY/models") val MODELS_URI: Uri = Uri.parse("content://$AUTHORITY/models")
} }
/** Jeu de paramètres d'échantillonnage d'un modèle (ou d'un preset). */
data class Sampling(
val temperature: Float = 0.7f,
val topP: Float = 0.85f,
val topK: Int = 40,
val repeatPenalty: Float = 1.1f,
val presencePenalty: Float = 0.0f,
val frequencyPenalty: Float = 0.0f,
val maxTokens: Int = 120
)
/** Preset nommé réutilisable, applicable au Speaker ou au Thinker. */
data class Preset(val name: String, val sampling: Sampling)
data class Config( data class Config(
val cascadeEnabled: Boolean, val cascadeEnabled: Boolean,
val ttsEnabled: Boolean, val ttsEnabled: Boolean,
val speakerModelId: String, val speakerModelId: String,
val speakerSystemPrompt: String, val speakerSystemPrompt: String,
val speakerTemperature: Float, val speakerSampling: Sampling,
val speakerPresetName: String,
val thinkerModelId: String, val thinkerModelId: String,
val thinkerSystemPrompt: String, val thinkerSystemPrompt: String,
val thinkerTemperature: Float, val thinkerSampling: Sampling,
val thinkerPresetName: String,
val presets: List<Preset> = emptyList(),
val ragEnabled: Boolean = false, val ragEnabled: Boolean = false,
val ragThreshold: Float = 0.82f, val ragThreshold: Float = 0.82f,
val ragTopK: Int = 3 val ragTopK: Int = 3,
) val debugEnabled: Boolean = false
) {
// Compat : anciens écrans qui lisaient juste la température.
val speakerTemperature: Float get() = speakerSampling.temperature
val thinkerTemperature: Float get() = thinkerSampling.temperature
}
data class ModelInfo( data class ModelInfo(
val id: String, val id: String,
@ -43,6 +68,23 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
val notes: String val notes: String
) )
private fun Cursor.floatOr(col: String, d: Float) =
getColumnIndex(col).let { if (it >= 0) getFloat(it) else d }
private fun Cursor.intOr(col: String, d: Int) =
getColumnIndex(col).let { if (it >= 0) getInt(it) else d }
private fun Cursor.strOr(col: String, d: String) =
getColumnIndex(col).let { if (it >= 0) getString(it) ?: d else d }
private fun Cursor.sampling(p: String) = Sampling(
temperature = floatOr("${p}temperature", 0.7f),
topP = floatOr("${p}top_p", 0.85f),
topK = intOr("${p}top_k", 40),
repeatPenalty = floatOr("${p}repeat_penalty", 1.1f),
presencePenalty = floatOr("${p}presence_penalty", 0.0f),
frequencyPenalty = floatOr("${p}frequency_penalty", 0.0f),
maxTokens = intOr("${p}max_tokens", 120)
)
fun queryConfig(): Config? = runCatching { fun queryConfig(): Config? = runCatching {
resolver.query(CONFIG_URI, null, null, null, null)?.use { c -> resolver.query(CONFIG_URI, null, null, null, null)?.use { c ->
if (!c.moveToFirst()) return@use null if (!c.moveToFirst()) return@use null
@ -51,17 +93,58 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
ttsEnabled = c.getInt(c.getColumnIndexOrThrow("tts_enabled")) == 1, ttsEnabled = c.getInt(c.getColumnIndexOrThrow("tts_enabled")) == 1,
speakerModelId = c.getString(c.getColumnIndexOrThrow("speaker_model_id")), speakerModelId = c.getString(c.getColumnIndexOrThrow("speaker_model_id")),
speakerSystemPrompt = c.getString(c.getColumnIndexOrThrow("speaker_system_prompt")), speakerSystemPrompt = c.getString(c.getColumnIndexOrThrow("speaker_system_prompt")),
speakerTemperature = c.getFloat(c.getColumnIndexOrThrow("speaker_temperature")), speakerSampling = c.sampling("speaker_"),
speakerPresetName = c.strOr("speaker_preset_name", ""),
thinkerModelId = c.getString(c.getColumnIndexOrThrow("thinker_model_id")), thinkerModelId = c.getString(c.getColumnIndexOrThrow("thinker_model_id")),
thinkerSystemPrompt = c.getString(c.getColumnIndexOrThrow("thinker_system_prompt")), thinkerSystemPrompt = c.getString(c.getColumnIndexOrThrow("thinker_system_prompt")),
thinkerTemperature = c.getFloat(c.getColumnIndexOrThrow("thinker_temperature")), thinkerSampling = c.sampling("thinker_"),
ragEnabled = c.getColumnIndex("rag_enabled").let { if (it >= 0) c.getInt(it) == 1 else false }, thinkerPresetName = c.strOr("thinker_preset_name", ""),
ragThreshold = c.getColumnIndex("rag_threshold").let { if (it >= 0) c.getFloat(it) else 0.82f }, presets = parsePresets(c.strOr("presets_json", "[]")),
ragTopK = c.getColumnIndex("rag_top_k").let { if (it >= 0) c.getInt(it) else 3 } ragEnabled = c.intOr("rag_enabled", 0) == 1,
ragThreshold = c.floatOr("rag_threshold", 0.82f),
ragTopK = c.intOr("rag_top_k", 3),
debugEnabled = c.intOr("debug_enabled", 0) == 1
) )
} }
}.getOrNull() }.getOrNull()
private fun parsePresets(json: String): List<Preset> = runCatching {
val arr = JSONArray(json)
(0 until arr.length()).mapNotNull { i ->
arr.optJSONObject(i)?.let { o ->
Preset(
o.optString("name", ""),
Sampling(
o.optDouble("temperature", 0.7).toFloat(),
o.optDouble("top_p", 0.85).toFloat(),
o.optInt("top_k", 40),
o.optDouble("repeat_penalty", 1.1).toFloat(),
o.optDouble("presence_penalty", 0.0).toFloat(),
o.optDouble("frequency_penalty", 0.0).toFloat(),
o.optInt("max_tokens", 120)
)
)
}
}.filter { it.name.isNotBlank() }
}.getOrDefault(emptyList())
private fun presetsToJson(presets: List<Preset>): String {
val arr = JSONArray()
presets.forEach { p ->
arr.put(JSONObject().apply {
put("name", p.name)
put("temperature", p.sampling.temperature)
put("top_p", p.sampling.topP)
put("top_k", p.sampling.topK)
put("repeat_penalty", p.sampling.repeatPenalty)
put("presence_penalty", p.sampling.presencePenalty)
put("frequency_penalty", p.sampling.frequencyPenalty)
put("max_tokens", p.sampling.maxTokens)
})
}
return arr.toString()
}
fun queryModels(): List<ModelInfo> = runCatching { fun queryModels(): List<ModelInfo> = runCatching {
resolver.query(MODELS_URI, null, null, null, null)?.use { c -> resolver.query(MODELS_URI, null, null, null, null)?.use { c ->
val out = mutableListOf<ModelInfo>() val out = mutableListOf<ModelInfo>()
@ -89,29 +172,49 @@ class KazeiaConfigClient(private val resolver: ContentResolver) {
ttsEnabled: Boolean? = null, ttsEnabled: Boolean? = null,
speakerModelId: String? = null, speakerModelId: String? = null,
speakerSystemPrompt: String? = null, speakerSystemPrompt: String? = null,
speakerTemperature: Float? = null,
thinkerModelId: String? = null, thinkerModelId: String? = null,
thinkerSystemPrompt: String? = null, thinkerSystemPrompt: String? = null,
thinkerTemperature: Float? = null,
ragEnabled: Boolean? = null, ragEnabled: Boolean? = null,
ragThreshold: Float? = null, ragThreshold: Float? = null,
ragTopK: Int? = null ragTopK: Int? = null,
debugEnabled: Boolean? = null
): Boolean { ): Boolean {
val v = ContentValues() val v = ContentValues()
cascadeEnabled?.let { v.put("cascade_enabled", it) } cascadeEnabled?.let { v.put("cascade_enabled", it) }
ttsEnabled?.let { v.put("tts_enabled", it) } ttsEnabled?.let { v.put("tts_enabled", it) }
speakerModelId?.let { v.put("speaker_model_id", it) } speakerModelId?.let { v.put("speaker_model_id", it) }
speakerSystemPrompt?.let { v.put("speaker_system_prompt", it) } speakerSystemPrompt?.let { v.put("speaker_system_prompt", it) }
speakerTemperature?.let { v.put("speaker_temperature", it) }
thinkerModelId?.let { v.put("thinker_model_id", it) } thinkerModelId?.let { v.put("thinker_model_id", it) }
thinkerSystemPrompt?.let { v.put("thinker_system_prompt", it) } thinkerSystemPrompt?.let { v.put("thinker_system_prompt", it) }
thinkerTemperature?.let { v.put("thinker_temperature", it) }
ragEnabled?.let { v.put("rag_enabled", it) } ragEnabled?.let { v.put("rag_enabled", it) }
ragThreshold?.let { v.put("rag_threshold", it) } ragThreshold?.let { v.put("rag_threshold", it) }
ragTopK?.let { v.put("rag_top_k", it) } ragTopK?.let { v.put("rag_top_k", it) }
debugEnabled?.let { v.put("debug_enabled", it) }
return push(v)
}
/** Applique un jeu de sampling (+ nom de preset, "" = custom) à un rôle. */
fun updateSampling(role: String, s: Sampling, presetName: String): Boolean {
val p = "${role}_" // "speaker" | "thinker"
val v = ContentValues().apply {
put("${p}temperature", s.temperature)
put("${p}top_p", s.topP)
put("${p}top_k", s.topK)
put("${p}repeat_penalty", s.repeatPenalty)
put("${p}presence_penalty", s.presencePenalty)
put("${p}frequency_penalty", s.frequencyPenalty)
put("${p}max_tokens", s.maxTokens)
put("${p}preset_name", presetName)
}
return push(v)
}
/** Remplace toute la bibliothèque de presets. */
fun updatePresets(presets: List<Preset>): Boolean =
push(ContentValues().apply { put("presets_json", presetsToJson(presets)) })
private fun push(v: ContentValues): Boolean {
if (v.size() == 0) return true if (v.size() == 0) return true
return runCatching { return runCatching { resolver.update(CONFIG_URI, v, null, null) > 0 }.getOrDefault(false)
resolver.update(CONFIG_URI, v, null, null) > 0
}.getOrDefault(false)
} }
} }

View File

@ -0,0 +1,66 @@
package com.kazeia.admin.data.source
import android.content.ContentResolver
import android.net.Uri
/**
* Client des mises à jour à la demande de l'app patient (ContentProvider
* `call(update_check|update_install)` + query `/updates`). Le provisioning
* (download modèles + APK) s'exécute côté patient ; l'admin déclenche + suit.
*/
class KazeiaUpdateClient(private val resolver: ContentResolver) {
companion object {
private const val AUTHORITY = "com.kazeia.provider"
private val ANY_URI: Uri = Uri.parse("content://$AUTHORITY/updates")
const val CALL_CHECK = "update_check"
const val CALL_INSTALL = "update_install"
}
/** Reflète [com.kazeia.dist.UpdateStatus] côté patient. */
data class UpdateInfo(
val phase: String, // IDLE/CHECKING/AVAILABLE/UPTODATE/DOWNLOADING/INSTALLING/DONE/ERROR
val appUpdateAvailable: Boolean,
val appVersionName: String,
val appSize: Long,
val contentCount: Int,
val contentSize: Long,
val progressDone: Long,
val progressTotal: Long,
val label: String,
val error: String,
val lastCheck: Long
)
/** True si l'app patient est joignable (provider présent). */
fun reachable(): Boolean = query() != null
fun check(): Boolean = call(CALL_CHECK)
fun install(): Boolean = call(CALL_INSTALL)
private fun call(method: String): Boolean = runCatching {
resolver.call(ANY_URI, method, null, null)?.getBoolean("accepted", false) ?: false
}.getOrDefault(false)
fun query(): UpdateInfo? = runCatching {
resolver.query(ANY_URI, null, null, null, null)?.use { c ->
if (!c.moveToFirst()) return@use null
fun s(n: String) = c.getString(c.getColumnIndexOrThrow(n))
fun i(n: String) = c.getInt(c.getColumnIndexOrThrow(n))
fun l(n: String) = c.getLong(c.getColumnIndexOrThrow(n))
UpdateInfo(
phase = s("phase"),
appUpdateAvailable = i("app_update_available") == 1,
appVersionName = s("app_version_name"),
appSize = l("app_size"),
contentCount = i("content_count"),
contentSize = l("content_size"),
progressDone = l("progress_done"),
progressTotal = l("progress_total"),
label = s("label"),
error = s("error"),
lastCheck = l("last_check")
)
}
}.getOrNull()
}

View File

@ -8,7 +8,9 @@ import androidx.compose.material.icons.outlined.Build
import androidx.compose.material.icons.outlined.GraphicEq import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.People import androidx.compose.material.icons.outlined.People
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.RecordVoiceOver import androidx.compose.material.icons.outlined.RecordVoiceOver
import androidx.compose.material.icons.outlined.SystemUpdate
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
/** /**
@ -23,9 +25,11 @@ enum class AdminDestination(
Voices( "voices", com.kazeia.admin.R.string.nav_voices, Icons.Outlined.RecordVoiceOver), Voices( "voices", com.kazeia.admin.R.string.nav_voices, Icons.Outlined.RecordVoiceOver),
Profiles( "profiles", com.kazeia.admin.R.string.nav_profiles, Icons.Outlined.People), Profiles( "profiles", com.kazeia.admin.R.string.nav_profiles, Icons.Outlined.People),
Prompts( "prompts", com.kazeia.admin.R.string.nav_prompts, Icons.AutoMirrored.Outlined.Article), Prompts( "prompts", com.kazeia.admin.R.string.nav_prompts, Icons.AutoMirrored.Outlined.Article),
Parameters("parameters", com.kazeia.admin.R.string.nav_parameters, Icons.Outlined.Tune),
Rag( "rag", com.kazeia.admin.R.string.nav_rag, Icons.Outlined.Book), Rag( "rag", com.kazeia.admin.R.string.nav_rag, Icons.Outlined.Book),
History( "history", com.kazeia.admin.R.string.nav_history, Icons.Outlined.History), History( "history", com.kazeia.admin.R.string.nav_history, Icons.Outlined.History),
Telemetry("telemetry", com.kazeia.admin.R.string.nav_telemetry, Icons.Outlined.Analytics), Telemetry("telemetry", com.kazeia.admin.R.string.nav_telemetry, Icons.Outlined.Analytics),
Updates( "updates", com.kazeia.admin.R.string.nav_updates, Icons.Outlined.SystemUpdate),
System( "system", com.kazeia.admin.R.string.nav_system, Icons.Outlined.Build); System( "system", com.kazeia.admin.R.string.nav_system, Icons.Outlined.Build);
companion object { companion object {

View File

@ -0,0 +1,356 @@
package com.kazeia.admin.ui.parameters
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.kazeia.admin.data.repository.ConfigRepository
import com.kazeia.admin.data.source.KazeiaConfigClient.Preset
import com.kazeia.admin.data.source.KazeiaConfigClient.Sampling
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ParametersScreen() {
val context = LocalContext.current
val repo = remember { ConfigRepository(context) }
val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
var loaded by remember { mutableStateOf(false) }
var reachable by remember { mutableStateOf(true) }
var presets by remember { mutableStateOf<List<Preset>>(emptyList()) }
var speaker by remember { mutableStateOf(Sampling()) }
var thinker by remember { mutableStateOf(Sampling()) }
var speakerPreset by remember { mutableStateOf("") }
var thinkerPreset by remember { mutableStateOf("") }
fun reload() {
val cfg = repo.config()
if (cfg == null) { reachable = false; loaded = true; return }
reachable = true
presets = cfg.presets
speaker = cfg.speakerSampling; speakerPreset = cfg.speakerPresetName
thinker = cfg.thinkerSampling; thinkerPreset = cfg.thinkerPresetName
loaded = true
}
LaunchedEffect(Unit) { reload() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Paramètres d'échantillonnage") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
},
snackbarHost = { SnackbarHost(snackbar) }
) { padding ->
if (!loaded) return@Scaffold
if (!reachable) {
Column(Modifier.fillMaxSize().padding(32.dp), verticalArrangement = Arrangement.Center) {
Text("Kazeia patient non joignable.", style = MaterialTheme.typography.titleLarge)
Text("Lance l'app Kazeia puis reviens ici.", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
return@Scaffold
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(Modifier.height(8.dp))
EngineNoticeCard()
RoleCard(
title = "Speaker", subtitle = "LLM qui répond au patient",
sampling = speaker, presetName = speakerPreset, presets = presets,
onChange = { speaker = it; speakerPreset = "" },
onApplyPreset = { p -> speaker = p.sampling; speakerPreset = p.name },
onSave = {
val ok = repo.updateSampling("speaker", speaker, speakerPreset)
scope.launch { snackbar.showSnackbar(if (ok) "Speaker mis à jour." else "Échec : patient injoignable.", duration = SnackbarDuration.Short) }
}
)
RoleCard(
title = "Thinker", subtitle = "LLM d'analyse (cascade)",
sampling = thinker, presetName = thinkerPreset, presets = presets,
onChange = { thinker = it; thinkerPreset = "" },
onApplyPreset = { p -> thinker = p.sampling; thinkerPreset = p.name },
onSave = {
val ok = repo.updateSampling("thinker", thinker, thinkerPreset)
scope.launch { snackbar.showSnackbar(if (ok) "Thinker mis à jour." else "Échec : patient injoignable.", duration = SnackbarDuration.Short) }
}
)
PresetLibraryCard(
presets = presets,
onSave = { updated ->
val ok = repo.updatePresets(updated)
if (ok) reload()
scope.launch { snackbar.showSnackbar(if (ok) "Bibliothèque de presets enregistrée." else "Échec : patient injoignable.", duration = SnackbarDuration.Short) }
}
)
Spacer(Modifier.height(16.dp))
}
}
}
@Composable
private fun EngineNoticeCard() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f)),
shape = RoundedCornerShape(12.dp)
) {
Column(Modifier.padding(16.dp)) {
Text(" Effet sur la génération", fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(4.dp))
Text(
"Aujourd'hui le moteur n'applique que « Tokens max ». Température, top-p/k et " +
"pénalités sont enregistrés et prendront effet dès que le moteur expose " +
"l'échantillonnage (mise à jour à venir).",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoleCard(
title: String,
subtitle: String,
sampling: Sampling,
presetName: String,
presets: List<Preset>,
onChange: (Sampling) -> Unit,
onApplyPreset: (Preset) -> Unit,
onSave: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
shape = RoundedCornerShape(12.dp)
) {
Column(Modifier.padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(12.dp))
// Appliquer un preset
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = !expanded }) {
OutlinedTextField(
value = if (presetName.isNotBlank()) presetName else "Personnalisé",
onValueChange = {}, readOnly = true,
label = { Text("Preset appliqué") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, true)
.fillMaxWidth()
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
if (presets.isEmpty()) {
DropdownMenuItem(text = { Text("Aucun preset") }, onClick = { expanded = false }, enabled = false)
}
presets.forEach { p ->
DropdownMenuItem(
text = { Text(p.name) },
onClick = { expanded = false; onApplyPreset(p) }
)
}
}
}
Spacer(Modifier.height(8.dp))
SamplingEditor(sampling, onChange)
Spacer(Modifier.height(8.dp))
Row {
Spacer(Modifier.weight(1f))
Button(onClick = onSave) { Text("Appliquer au $title") }
}
}
}
}
@Composable
private fun SamplingEditor(s: Sampling, onChange: (Sampling) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
SliderRow("Température", s.temperature, 0f, 2f) { onChange(s.copy(temperature = it)) }
SliderRow("Top-p", s.topP, 0f, 1f) { onChange(s.copy(topP = it)) }
IntSliderRow("Top-k", s.topK, 0, 100) { onChange(s.copy(topK = it)) }
SliderRow("Pénalité répétition", s.repeatPenalty, 1f, 2f) { onChange(s.copy(repeatPenalty = it)) }
SliderRow("Pénalité présence", s.presencePenalty, 0f, 2f) { onChange(s.copy(presencePenalty = it)) }
SliderRow("Pénalité fréquence", s.frequencyPenalty, 0f, 2f) { onChange(s.copy(frequencyPenalty = it)) }
IntSliderRow("Tokens max ✓", s.maxTokens, 16, 512) { onChange(s.copy(maxTokens = it)) }
}
}
@Composable
private fun SliderRow(label: String, value: Float, min: Float, max: Float, onChange: (Float) -> Unit) {
Column {
Row {
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Text(
String.format("%.2f", value),
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace
)
}
Slider(
value = value.coerceIn(min, max),
onValueChange = { onChange((it * 20).roundToInt() / 20f) }, // pas 0.05
valueRange = min..max
)
}
}
@Composable
private fun IntSliderRow(label: String, value: Int, min: Int, max: Int, onChange: (Int) -> Unit) {
Column {
Row {
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Text("$value", style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace)
}
Slider(
value = value.coerceIn(min, max).toFloat(),
onValueChange = { onChange(it.roundToInt()) },
valueRange = min.toFloat()..max.toFloat()
)
}
}
@Composable
private fun PresetLibraryCard(presets: List<Preset>, onSave: (List<Preset>) -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
shape = RoundedCornerShape(12.dp)
) {
Column(Modifier.padding(16.dp)) {
Text("Bibliothèque de presets", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text("Modèles réutilisables, applicables au Speaker ou au Thinker.",
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(12.dp))
var editing by remember { mutableStateOf<Int?>(null) } // index en édition
presets.forEachIndexed { i, p ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.weight(1f)) {
Text(p.name, fontWeight = FontWeight.Medium)
Text(
"t=${"%.2f".format(p.sampling.temperature)} · top-p=${"%.2f".format(p.sampling.topP)} · " +
"rep=${"%.2f".format(p.sampling.repeatPenalty)} · max=${p.sampling.maxTokens}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace
)
}
TextButton(onClick = { editing = if (editing == i) null else i }) {
Text(if (editing == i) "Fermer" else "Éditer")
}
TextButton(onClick = { onSave(presets.filterIndexed { j, _ -> j != i }) }) {
Text("Suppr.", color = MaterialTheme.colorScheme.error)
}
}
if (editing == i) {
var draft by remember(i) { mutableStateOf(p.sampling) }
var name by remember(i) { mutableStateOf(p.name) }
OutlinedTextField(
value = name, onValueChange = { name = it },
label = { Text("Nom du preset") }, singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(4.dp))
SamplingEditor(draft) { draft = it }
Row {
Spacer(Modifier.weight(1f))
Button(
onClick = {
editing = null
onSave(presets.toMutableList().also { it[i] = Preset(name.ifBlank { p.name }, draft) })
}
) { Text("Enregistrer") }
}
}
Spacer(Modifier.height(8.dp))
}
// Nouveau preset
var creating by remember { mutableStateOf(false) }
if (!creating) {
TextButton(onClick = { creating = true }) { Text("+ Nouveau preset") }
} else {
var newName by remember { mutableStateOf("") }
var newSampling by remember { mutableStateOf(Sampling()) }
OutlinedTextField(
value = newName, onValueChange = { newName = it },
label = { Text("Nom du nouveau preset") }, singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(4.dp))
SamplingEditor(newSampling) { newSampling = it }
Row {
TextButton(onClick = { creating = false }) { Text("Annuler") }
Spacer(Modifier.weight(1f))
Button(
enabled = newName.isNotBlank(),
onClick = {
creating = false
onSave(presets + Preset(newName.trim(), newSampling))
}
) { Text("Créer") }
}
}
}
}
}

View File

@ -138,6 +138,21 @@ fun SystemScreen() {
} }
) )
DebugCard(
enabled = cfg.debugEnabled,
onToggle = { newValue ->
val ok = repo.updateDebug(newValue)
if (ok) config = repo.config()
scope.launch {
snackbar.showSnackbar(
if (ok) "Mode debug ${if (newValue) "activé" else "désactivé"} — appliqué immédiatement côté patient."
else "Échec : Kazeia patient injoignable.",
duration = SnackbarDuration.Short
)
}
}
)
ModelSection( ModelSection(
title = "Speaker", title = "Speaker",
subtitle = "LLM qui répond au patient", subtitle = "LLM qui répond au patient",
@ -373,6 +388,45 @@ private fun TtsCard(enabled: Boolean, onToggle: (Boolean) -> Unit) {
} }
} }
@Composable
private fun DebugCard(enabled: Boolean, onToggle: (Boolean) -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
shape = RoundedCornerShape(12.dp)
) {
Column(Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
"Mode debug",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(4.dp))
Text(
if (enabled)
"ACTIVÉ — le bouton debug (logs + métriques CPU/GPU/NPU/RAM) est visible dans l'app patient."
else
"DÉSACTIVÉ — écran patient épuré, bouton debug masqué.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = enabled,
onCheckedChange = onToggle,
colors = SwitchDefaults.colors(
checkedThumbColor = StateReady,
checkedTrackColor = StateReady.copy(alpha = 0.4f)
)
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ModelSection( private fun ModelSection(

View File

@ -0,0 +1,157 @@
package com.kazeia.admin.ui.updates
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.kazeia.admin.data.source.KazeiaUpdateClient
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UpdatesScreen() {
val context = LocalContext.current
val client = remember { KazeiaUpdateClient(context.contentResolver) }
var info by remember { mutableStateOf<KazeiaUpdateClient.UpdateInfo?>(null) }
var reachable by remember { mutableStateOf(true) }
// Poll continu de l'état (le worker tourne côté patient).
LaunchedEffect(Unit) {
while (true) {
val q = client.query()
reachable = q != null
info = q
delay(1000)
}
}
val phase = info?.phase ?: "IDLE"
val busy = phase == "CHECKING" || phase == "DOWNLOADING" || phase == "INSTALLING"
Scaffold(
topBar = {
TopAppBar(
title = { Text("Mises à jour") },
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.background)
)
}
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (!reachable) {
Text("Kazeia patient non joignable.", style = MaterialTheme.typography.titleMedium)
Text("Lance l'app Kazeia puis reviens ici.", color = MaterialTheme.colorScheme.onSurfaceVariant)
return@Column
}
StatusCard(info, busy)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(enabled = !busy, onClick = { client.check() }) {
Text("Vérifier les mises à jour")
}
val hasUpdate = info?.let { it.appUpdateAvailable || it.contentCount > 0 } == true
Button(enabled = !busy && hasUpdate, onClick = { client.install() }) {
Text("Installer")
}
}
Text(
"Les mises à jour ne se font plus automatiquement : elles se déclenchent " +
"uniquement ici. L'installation de l'application affichera un dialogue " +
"système à confirmer sur la tablette.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun StatusCard(info: KazeiaUpdateClient.UpdateInfo?, busy: Boolean) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
shape = RoundedCornerShape(12.dp)
) {
Column(Modifier.padding(16.dp)) {
val phase = info?.phase ?: "IDLE"
val (title, detail) = when (phase) {
"IDLE" -> "Aucune vérification" to "Touchez « Vérifier » pour interroger le serveur."
"CHECKING" -> "Vérification en cours…" to ""
"UPTODATE" -> "Tout est à jour ✓" to "Aucune mise à jour disponible."
"AVAILABLE" -> "Mises à jour disponibles" to buildAvailable(info)
"DOWNLOADING" -> "Téléchargement…" to (info?.label ?: "")
"INSTALLING" -> "Installation…" to (info?.label ?: "")
"DONE" -> "Mise à jour terminée ✓" to "L'application redémarrera si elle a été mise à jour."
"ERROR" -> "Échec" to (info?.error ?: "Erreur inconnue.")
else -> phase to ""
}
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
if (detail.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(detail, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (busy) {
Spacer(Modifier.height(12.dp))
val total = info?.progressTotal ?: 0L
val done = info?.progressDone ?: 0L
if (total > 0L) {
LinearProgressIndicator(
progress = { (done.toFloat() / total).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(4.dp))
Text("${human(done)} / ${human(total)}", style = MaterialTheme.typography.bodySmall)
} else {
LinearProgressIndicator(Modifier.fillMaxWidth())
}
}
}
}
}
private fun buildAvailable(info: KazeiaUpdateClient.UpdateInfo?): String {
if (info == null) return ""
val parts = mutableListOf<String>()
if (info.appUpdateAvailable) parts += "Application v${info.appVersionName} (${human(info.appSize)})"
if (info.contentCount > 0) parts += "${info.contentCount} composant(s) modèle/voix (${human(info.contentSize)})"
return parts.joinToString("\n")
}
private fun human(bytes: Long): String {
if (bytes < 1024) return "$bytes o"
val kb = bytes / 1024.0
if (kb < 1024) return "%.0f Ko".format(kb)
val mb = kb / 1024.0
if (mb < 1024) return "%.1f Mo".format(mb)
return "%.2f Go".format(mb / 1024.0)
}

View File

@ -36,6 +36,14 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.RadioButton
import androidx.compose.foundation.selection.selectable
import com.kazeia.admin.data.source.KazeiaProfilesClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -87,6 +95,21 @@ fun RecordVoiceScreen(
} }
var qualityReport by remember { mutableStateOf<VoiceQualityValidator.Report?>(null) } var qualityReport by remember { mutableStateOf<VoiceQualityValidator.Report?>(null) }
// Exclusivité (Phase 1) : portée de la voix choisie au moment d'enregistrer.
val profilesClient = remember { KazeiaProfilesClient(context.contentResolver) }
var profiles by remember { mutableStateOf<List<KazeiaProfilesClient.ProfileRow>>(emptyList()) }
var scopeChoice by remember { mutableStateOf("exclusive") } // exclusive|global|pending
var ownerProfileId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
val list = withContext(Dispatchers.IO) { profilesClient.queryAll() }
val active = withContext(Dispatchers.IO) { profilesClient.activeProfileId() }
profiles = list
// Défaut = patient actif (cas courant : enregistrer la voix d'un proche
// POUR le patient actif de cette tablette). Aucun profil → bascule pending.
ownerProfileId = active ?: list.firstOrNull()?.id
if (list.isEmpty()) scopeChoice = "pending"
}
val permLauncher = rememberLauncherForActivityResult( val permLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission()
) { granted -> permissionGranted = granted } ) { granted -> permissionGranted = granted }
@ -139,6 +162,99 @@ fun RecordVoiceScreen(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
// 1.5 Exclusivité : à qui cette voix est destinée (capture record-time,
// écrite dans le manifeste → Kazeia-central applique la politique).
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth()
) {
Column(Modifier.padding(16.dp)) {
Text(
stringResource(R.string.rec_scope_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
val opts = listOf(
"exclusive" to stringResource(R.string.rec_scope_exclusive),
"global" to stringResource(R.string.rec_scope_global),
"pending" to stringResource(R.string.rec_scope_pending)
)
opts.forEach { (value, label) ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = scopeChoice == value,
enabled = state != AudioRecorder.State.RECORDING,
onClick = { scopeChoice = value }
)
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = scopeChoice == value,
onClick = { scopeChoice = value },
enabled = state != AudioRecorder.State.RECORDING
)
Spacer(Modifier.size(8.dp))
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
if (scopeChoice == "exclusive") {
Spacer(Modifier.height(8.dp))
if (profiles.isEmpty()) {
Text(
stringResource(R.string.rec_scope_no_patients),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
} else {
var expanded by remember { mutableStateOf(false) }
val owner = profiles.firstOrNull { it.id == ownerProfileId }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
if (state != AudioRecorder.State.RECORDING) expanded = it
}
) {
OutlinedTextField(
value = owner?.displayName ?: "",
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.rec_scope_patient_label)) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
profiles.forEach { p ->
DropdownMenuItem(
text = { Text(p.displayName) },
onClick = { ownerProfileId = p.id; expanded = false }
)
}
}
}
}
} else if (scopeChoice == "pending") {
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.rec_scope_pending_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// 2. Phrase de référence (vaut consentement enregistré) // 2. Phrase de référence (vaut consentement enregistré)
Card( Card(
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
@ -240,18 +356,23 @@ fun RecordVoiceScreen(
} }
// 5. Bouton save : visible quand audio capturé ET qualité OK // 5. Bouton save : visible quand audio capturé ET qualité OK
val saveEnabled = state == AudioRecorder.State.FINISHED && qualityReport?.ok == true val saveEnabled = state == AudioRecorder.State.FINISHED && qualityReport?.ok == true &&
(scopeChoice != "exclusive" || ownerProfileId != null)
if (state == AudioRecorder.State.FINISHED) { if (state == AudioRecorder.State.FINISHED) {
Button( Button(
onClick = { onClick = {
val samples = recorder.samples() val samples = recorder.samples()
val durSec = samples.size.toFloat() / AudioRecorder.SAMPLE_RATE val durSec = samples.size.toFloat() / AudioRecorder.SAMPLE_RATE
val ownerName = profiles.firstOrNull { it.id == ownerProfileId }?.displayName
val saved = runCatching { val saved = runCatching {
storage.save( storage.save(
displayName = voiceName.trim(), displayName = voiceName.trim(),
samples = samples, samples = samples,
durationSeconds = durSec, durationSeconds = durSec,
referenceText = referenceText referenceText = referenceText,
scope = scopeChoice,
ownerProfileId = if (scopeChoice == "exclusive") ownerProfileId else null,
ownerName = if (scopeChoice == "exclusive") ownerName else null
) )
} }
scope.launch { scope.launch {

View File

@ -45,7 +45,15 @@ class VoiceStorage(private val context: Context) {
displayName: String, displayName: String,
samples: ShortArray, samples: ShortArray,
durationSeconds: Float, durationSeconds: Float,
referenceText: String referenceText: String,
// Exclusivité (Phase 1, capture record-time). scope ∈ {exclusive, global,
// pending}. Lu par Kazeia-central à l'ingestion → localisation de
// déploiement + assignation Profile.voiceId (cf. VOICE_DEPLOYMENT_SPEC §4).
// exclusive → ownerProfileId requis ; pending → propriétaire à lier plus
// tard (ni déployée ni assignable tant que non résolu) ; global → tous.
scope: String = "pending",
ownerProfileId: String? = null,
ownerName: String? = null
): RecordedVoice { ): RecordedVoice {
val ts = TS_FMT.format(System.currentTimeMillis()) val ts = TS_FMT.format(System.currentTimeMillis())
val id = "${slug(displayName)}_$ts" val id = "${slug(displayName)}_$ts"
@ -63,6 +71,9 @@ class VoiceStorage(private val context: Context) {
put("created_at", System.currentTimeMillis()) put("created_at", System.currentTimeMillis())
put("reference_text", referenceText) put("reference_text", referenceText)
put("source_device", "tablet-admin-recording") put("source_device", "tablet-admin-recording")
put("scope", scope)
ownerProfileId?.let { put("owner_profile_id", it) }
ownerName?.let { put("owner_name", it) }
} }
manifest.writeText(json.toString(2)) manifest.writeText(json.toString(2))

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Fond adaptatif Kazeia Admin : dégradé radial violet-ardoise sombre — distingue
l'outil de gestion de l'app patiente (fond plus clair), même famille. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M0,0 L108,0 L108,108 L0,108 Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="54"
android:centerY="46"
android:gradientRadius="82">
<item android:offset="0" android:color="#FF2A2740" />
<item android:offset="0.6" android:color="#FF1C1B2B" />
<item android:offset="1" android:color="#FF100F19" />
</gradient>
</aapt:attr>
</path>
</vector>

View File

@ -1,18 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Avant-plan adaptatif Kazeia Admin : l'orbe Kazeia cerné d'un anneau d'engrenage
(8 dents) — même identité que l'app patiente, signal « console / contrôle ».
Tout reste dans la zone sûre (dents jusqu'au rayon 36 du centre). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- Simple "K" + gear-like halo for admin -->
<!-- Dents de l'engrenage (8, par rotation autour du centre 54,54) -->
<group android:rotation="0" android:pivotX="54" android:pivotY="54">
<path android:fillColor="#FF9C8FC9" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
</group>
<group android:rotation="45" android:pivotX="54" android:pivotY="54">
<path android:fillColor="#FF9C8FC9" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
</group>
<group android:rotation="90" android:pivotX="54" android:pivotY="54">
<path android:fillColor="#FF9C8FC9" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
</group>
<group android:rotation="135" android:pivotX="54" android:pivotY="54">
<path android:fillColor="#FF9C8FC9" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
</group>
<group android:rotation="180" android:pivotX="54" android:pivotY="54">
<path android:fillColor="#FF9C8FC9" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
</group>
<group android:rotation="225" android:pivotX="54" android:pivotY="54">
<path android:fillColor="#FF9C8FC9" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
</group>
<group android:rotation="270" android:pivotX="54" android:pivotY="54">
<path android:fillColor="#FF9C8FC9" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
</group>
<group android:rotation="315" android:pivotX="54" android:pivotY="54">
<path android:fillColor="#FF9C8FC9" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
</group>
<!-- Anneau de l'engrenage -->
<path <path
android:fillColor="#BCA4E8" android:strokeColor="#FF9C8FC9"
android:pathData="M54 28 m-3 0 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0" /> android:strokeWidth="4.5"
<path
android:fillColor="#BCA4E8"
android:pathData="M40 42 L40 80 L48 80 L48 64 L60 80 L70 80 L56 62 L70 42 L60 42 L48 58 L48 42 Z" />
<path
android:strokeColor="#7A6BAA"
android:strokeWidth="1.5"
android:fillColor="#00000000" android:fillColor="#00000000"
android:pathData="M54 16 a38 38 0 1 0 0 76 a38 38 0 1 0 0 -76" /> android:pathData="M24,54 a30,30 0 1,0 60,0 a30,30 0 1,0 -60,0" />
<!-- Orbe central (rayon 22) -->
<path android:pathData="M32,54 a22,22 0 1,0 44,0 a22,22 0 1,0 -44,0">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="46"
android:centerY="44"
android:gradientRadius="34">
<item android:offset="0" android:color="#FFF6F1FF" />
<item android:offset="0.34" android:color="#FFC9B6F0" />
<item android:offset="0.7" android:color="#FF9879D8" />
<item android:offset="1" android:color="#FF6243A0" />
</gradient>
</aapt:attr>
</path>
<!-- Reflet spéculaire -->
<path android:pathData="M42,45 a5,5 0 1,0 10,0 a5,5 0 1,0 -10,0">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="47"
android:centerY="45"
android:gradientRadius="5">
<item android:offset="0" android:color="#E6FFFFFF" />
<item android:offset="1" android:color="#00FFFFFF" />
</gradient>
</aapt:attr>
</path>
</vector> </vector>

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Icône thématisée (Android 13+) : silhouette orbe + engrenage, teintée système. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:rotation="0" android:pivotX="54" android:pivotY="54">
<path android:fillColor="#FFFFFFFF" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
</group>
<group android:rotation="45" android:pivotX="54" android:pivotY="54">
<path android:fillColor="#FFFFFFFF" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
</group>
<group android:rotation="90" android:pivotX="54" android:pivotY="54">
<path android:fillColor="#FFFFFFFF" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
</group>
<group android:rotation="135" android:pivotX="54" android:pivotY="54">
<path android:fillColor="#FFFFFFFF" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
</group>
<group android:rotation="180" android:pivotX="54" android:pivotY="54">
<path android:fillColor="#FFFFFFFF" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
</group>
<group android:rotation="225" android:pivotX="54" android:pivotY="54">
<path android:fillColor="#FFFFFFFF" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
</group>
<group android:rotation="270" android:pivotX="54" android:pivotY="54">
<path android:fillColor="#FFFFFFFF" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
</group>
<group android:rotation="315" android:pivotX="54" android:pivotY="54">
<path android:fillColor="#FFFFFFFF" android:pathData="M50.5,18 L57.5,18 L57.5,24 L50.5,24 Z" />
</group>
<!-- Anneau (annulus, evenOdd) -->
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M23,54 a31,31 0 1,0 62,0 a31,31 0 1,0 -62,0 Z M28,54 a26,26 0 1,0 52,0 a26,26 0 1,0 -52,0 Z" />
<!-- Orbe -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M32,54 a22,22 0 1,0 44,0 a22,22 0 1,0 -44,0 Z" />
</vector>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_admin_background"/> <background android:drawable="@drawable/ic_launcher_admin_background"/>
<foreground android:drawable="@drawable/ic_launcher_admin_foreground"/> <foreground android:drawable="@drawable/ic_launcher_admin_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_admin_foreground"/> <monochrome android:drawable="@drawable/ic_launcher_admin_monochrome"/>
</adaptive-icon> </adaptive-icon>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_admin_background"/> <background android:drawable="@drawable/ic_launcher_admin_background"/>
<foreground android:drawable="@drawable/ic_launcher_admin_foreground"/> <foreground android:drawable="@drawable/ic_launcher_admin_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_admin_foreground"/> <monochrome android:drawable="@drawable/ic_launcher_admin_monochrome"/>
</adaptive-icon> </adaptive-icon>

View File

@ -6,6 +6,8 @@
<string name="nav_voices">Voix</string> <string name="nav_voices">Voix</string>
<string name="nav_profiles">Profils patients</string> <string name="nav_profiles">Profils patients</string>
<string name="nav_prompts">Prompts</string> <string name="nav_prompts">Prompts</string>
<string name="nav_parameters">Paramètres</string>
<string name="nav_updates">Mises à jour</string>
<string name="nav_rag">Base documentaire</string> <string name="nav_rag">Base documentaire</string>
<string name="nav_history">Historique</string> <string name="nav_history">Historique</string>
<string name="nav_telemetry">Telemetry</string> <string name="nav_telemetry">Telemetry</string>
@ -34,8 +36,15 @@
<string name="rec_name_label">Nom de la voix</string> <string name="rec_name_label">Nom de la voix</string>
<string name="rec_name_hint">Ex : Mamie, Pierre, Sophie</string> <string name="rec_name_hint">Ex : Mamie, Pierre, Sophie</string>
<string name="rec_instruction_title">Lisez la phrase suivante d\'une voix posée et naturelle</string> <string name="rec_instruction_title">Lisez la phrase suivante d\'une voix posée et naturelle</string>
<string name="rec_reference_text">Bonjour, je m\'appelle %1$s. J\'autorise l\'application Kazeia à utiliser ma voix dans le seul cadre de mon accompagnement personnel, et je m\'oppose à toute autre réutilisation. Au bord de la mer, le soleil se couche : un moment de calme et de souvenirs heureux.</string> <string name="rec_reference_text">Bonjour, je m\'appelle %1$s. J\'autorise l\'application Kazeia à utiliser ma voix dans le seul cadre de mon accompagnement personnel, et je m\'oppose à toute autre réutilisation. Aujourd\'hui, le ciel est bleu et le vent souffle doucement. J\'aime écouter le chant joyeux des oiseaux, près d\'un feu paisible. Quel bonheur tranquille ! Et vous, qu\'est-ce qui vous rend vraiment heureux ?</string>
<string name="rec_consent_notice">Cette phrase vaut consentement enregistré : votre voix ne sera utilisée que par Kazeia pour vous accompagner. Vous pouvez supprimer la voix à tout moment.</string> <string name="rec_consent_notice">Cette phrase vaut consentement enregistré : votre voix ne sera utilisée que par Kazeia pour vous accompagner. Vous pouvez supprimer la voix à tout moment.</string>
<string name="rec_scope_title">À qui est destinée cette voix ?</string>
<string name="rec_scope_exclusive">Exclusive à un patient</string>
<string name="rec_scope_global">Non-exclusive (tous les patients)</string>
<string name="rec_scope_pending">Lier à un patient plus tard</string>
<string name="rec_scope_patient_label">Patient propriétaire</string>
<string name="rec_scope_no_patients">Aucun patient chargé sur cette tablette — choisissez « Lier à un patient plus tard ».</string>
<string name="rec_scope_pending_hint">La voix sera enregistrée mais ni déployée ni utilisable tant qu\'un patient ne lui sera pas assigné.</string>
<string name="rec_start">Commencer l\'enregistrement</string> <string name="rec_start">Commencer l\'enregistrement</string>
<string name="rec_stop">Arrêter</string> <string name="rec_stop">Arrêter</string>
<string name="rec_replay">Réécouter</string> <string name="rec_replay">Réécouter</string>

View File

@ -44,8 +44,8 @@ android {
applicationId = "com.kazeia" applicationId = "com.kazeia"
minSdk = 28 minSdk = 28
targetSdk = 36 targetSdk = 36
versionCode = 11 versionCode = 15
versionName = "0.1.10" versionName = "0.2.3"
// 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")}\"")
@ -106,6 +106,21 @@ android {
jniLibs.srcDirs("src/main/jniLibs") jniLibs.srcDirs("src/main/jniLibs")
} }
} }
packaging {
resources {
// DJL `tokenizers` embarque des natifs desktop (macOS/Windows) inutiles
// sur Android (~23 Mo) ; seul l'arm64 de `tokenizer-native` sert. On les
// exclut pour alléger l'APK (payload self-update OTA).
excludes += "native/lib/osx-**"
excludes += "native/lib/win-**"
excludes += "native/lib/linux-**"
// JNA (transitif via sqlcipher/DJL) embarque des natifs aix/darwin/win
// (~2,3 Mo) inutiles sur Android — aucun natif android-aarch64 fourni et
// JNA non utilisé dans le code. APK = binaires utiles uniquement.
excludes += "com/sun/jna/**"
}
}
} }
// Reproductibilité des jniLibs : les .so (gitignorés, 5 chaînes de build) doivent // Reproductibilité des jniLibs : les .so (gitignorés, 5 chaînes de build) doivent
@ -146,10 +161,17 @@ 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
// côté Kotlin puis passe les IDs au natif (tokeniseur natif de l'engine cassé pour Qwen).
// L'artefact `tokenizers` ne porte QUE les natifs desktop (osx/win) ; le natif Android
// arm64 (libdjl_tokenizer.so) vit dans l'AAR `ai.djl.android:tokenizer-native`, dont la
// seule version publiée est 0.33.0 → on aligne les DEUX sur 0.33.0 (DJL exige l'égalité).
implementation("ai.djl.huggingface:tokenizers:0.33.0")
implementation("ai.djl.android:tokenizer-native:0.33.0")
// OkHttp — client WebDAV pour téléchargement modèles depuis Nextcloud // OkHttp — client WebDAV pour téléchargement modèles depuis Nextcloud
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")

View File

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

View File

@ -27,23 +27,25 @@ class KazeiaApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
createNotificationChannel() createNotificationChannel()
// QNN HTP sur DSP (.pte LLM ExecuTorch + TTS) : le skel `libQnnHtpV79Skel.so`
// est relayé au CDSP par FastRPC, qui le cherche via ADSP_LIBRARY_PATH. Sans
// ça, le DSP ne trouve pas le skel (errno 2) → device_handle échoue (14001) →
// chargement .pte renvoie handle 0. On pointe sur le dossier natif extrait de
// l'APK (où vit le skel) + chemins DSP système. Doit être posé AVANT toute
// ouverture de session QNN (bench, KazeiaService, TTS). Cf. Qwen3TtsEngine.
try {
val nativeLibDir = applicationInfo.nativeLibraryDir
android.system.Os.setenv(
"ADSP_LIBRARY_PATH",
"$nativeLibDir;/vendor/dsp/cdsp;/vendor/lib/rfsa/adsp;/vendor/dsp",
true
)
} catch (_: Throwable) {}
// Note: Unity native lib preloading was removed because Unity 6 GameActivity // Note: Unity native lib preloading was removed because Unity 6 GameActivity
// requires its own initialization sequence. Loading libs out of order causes // requires its own initialization sequence. Loading libs out of order causes
// native crashes. Unity handles lib loading internally in onCreate(). // native crashes. Unity handles lib loading internally in onCreate().
// Bench QNN options : déclenché par `touch <files>/bench_qnn.trigger` puis relance app.
// Charge WhisperHybridEngine + transcrit /files/bench_audio/*.wav (3×, médiane).
val trig = java.io.File(getExternalFilesDir(null), "bench_qnn.trigger")
if (trig.exists()) {
trig.delete()
Thread {
com.kazeia.stt.BenchQnnHarness.runBench(
ctx = this@KazeiaApplication,
audioDir = java.io.File(getExternalFilesDir(null), "bench_audio"),
modelDir = "$MODELS_DIR/whisper-small-sm8750"
)
}.start()
}
// Bench LLM (lib CPU-only) : `touch <files>/bench_llm.trigger`, relance app. // Bench LLM (lib CPU-only) : `touch <files>/bench_llm.trigger`, relance app.
// Charge EngineLlmEngine + 3 prompts FR thérapeutiques. // Charge EngineLlmEngine + 3 prompts FR thérapeutiques.
val trigLlm = java.io.File(getExternalFilesDir(null), "bench_llm.trigger") val trigLlm = java.io.File(getExternalFilesDir(null), "bench_llm.trigger")
@ -51,13 +53,6 @@ class KazeiaApplication : Application() {
trigLlm.delete() trigLlm.delete()
Thread { com.kazeia.llm.BenchLlmHarness.runBench(this@KazeiaApplication) }.start() Thread { com.kazeia.llm.BenchLlmHarness.runBench(this@KazeiaApplication) }.start()
} }
// Bench TTS (lib CPU-only) : `touch <files>/bench_tts.trigger`, relance app.
// Charge libkazeia_tts + 1 synthèse FR vers WAV.
val trigTts = java.io.File(getExternalFilesDir(null), "bench_tts.trigger")
if (trigTts.exists()) {
trigTts.delete()
Thread { com.kazeia.tts.BenchTtsHarness.runBench(this@KazeiaApplication) }.start()
}
} }
private fun createNotificationChannel() { private fun createNotificationChannel() {

View File

@ -26,7 +26,32 @@ class ConfigStore private constructor(private val file: File) {
data class ModelConfig( data class ModelConfig(
val modelId: String, val modelId: String,
val systemPrompt: String, val systemPrompt: String,
val temperature: Float // Paramètres d'échantillonnage. `temperature` historique conservé ; les
// autres ajoutés par la refonte presets 2026-06-18. ⚠ seul maxTokens est
// honoré par le moteur natif actuel — le reste prend effet quand le JNI
// expose le sampling (cf. docs/SAMPLING_ENGINE_SPEC.md). presetName = nom
// du preset appliqué (vide = réglages custom).
val temperature: Float,
val topP: Float = 0.85f,
val topK: Int = 40,
val repeatPenalty: Float = 1.1f,
val presencePenalty: Float = 0.0f,
val frequencyPenalty: Float = 0.0f,
val maxTokens: Int = 120,
val presetName: String = ""
)
/** Modèle d'échantillonnage nommé, réutilisable, applicable au Speaker ou au
* Thinker. Géré (CRUD) depuis l'app admin, persisté dans la config. */
data class SamplingPreset(
val name: String,
val temperature: Float,
val topP: Float,
val topK: Int,
val repeatPenalty: Float,
val presencePenalty: Float,
val frequencyPenalty: Float,
val maxTokens: Int
) )
data class RuntimeConfig( data class RuntimeConfig(
@ -36,27 +61,21 @@ class ConfigStore private constructor(private val file: File) {
/** Synthèse vocale active. Si false : pas de TTS, réponse affichée /** Synthèse vocale active. Si false : pas de TTS, réponse affichée
* progressivement (token par token). Pilotable depuis l'app admin. */ * progressivement (token par token). Pilotable depuis l'app admin. */
val ttsEnabled: Boolean = true, val ttsEnabled: Boolean = true,
/** Backend STT : "prod" = WhisperHybridEngine gelé, "lib" = libkazeia_stt /** Backend STT : VESTIGIAL depuis le retrait du backend "lib" (2026-06-18,
* (Kazeia-Engine unifié, A/B identique sur set 6 audios mais à valider plus large). * non validé). KazeiaService charge toujours WhisperHybridEngine (NPU, gelé).
* Pilotable depuis l'app admin / provider. Defaults safe = "prod". */ * Champ conservé pour compat config/télémétrie. */
val sttEngine: String = "prod", val sttEngine: String = "prod",
/** Backend LLM : "lib" = EngineLlmAdapter (libkazeia_engine + GGUF Qwen3.5, /** Backend LLM : VESTIGIAL depuis la refonte 2026-06-17. Le dispatch ne passe
* q35-lmq4 par défaut) défaut depuis 2026-06-15 : on n'utilise PLUS le * plus par cette valeur : KazeiaService charge tout LLM via UnifiedLlmAdapter
* .pte avec kazeia-engine (directive). "prod" = ExecuTorchLlmEngine (.pte) * qui auto-détecte le format par MAGIC-BYTE (GGUFCPU i8mm, .pteNPU V79). Le
* conservé en repli d'urgence uniquement ; sur cette plateforme le runner QNN * modèle effectif est choisi par `speaker.modelId` (ModelRegistry). Champ
* du .pte échoue au load (« Failed to load llm runner [1] »), donc à éviter. * conservé pour compat config/télémétrie. */
* "lib" valide device : load q35-lmq4.gguf 2.6s + génération FR cohérente. */
val llmEngine: String = "lib", val llmEngine: String = "lib",
/** Backend TTS : "lib" = libkazeia_tts unifié (SEUL compatible avec le /** Backend TTS : "cosyvoice" = CosyVoiceTtsEngine (clonage vocal 9 langues,
* libllama.so fork ql embarqué depuis 2026-06-02), "prod" = Qwen3TtsEngine * CPU-only) = SEUL moteur depuis la bascule 2026-06-18 (legacy Qwen3-TTS/lib
* legacy CRASHE (SIGSEGV nativeInit, dérive ABI llama_context_params) tant * retirés). Champ conservé pour compat config ; KazeiaService ne charge que
* que les .so legacy ne sont pas recompilés contre les headers fork ql. * CosyVoice. */
* "cosyvoice" = CosyVoiceTtsEngine (clonage vocal 9 langues, CPU-only) val ttsEngine: String = "cosyvoice",
* câblé mais NON activable tant que la collision libggml des libs livrées
* n'est pas levée côté engine (cf CosyVoiceTtsEngine.kt).
* Default "lib" depuis 2026-06-11 : l'ancien défaut "prod" mettait toute
* install fraîche en crash-loop au démarrage du service. */
val ttsEngine: String = "lib",
/** RAG (récupération de contexte injecté au prompt). Default OFF : tant que /** RAG (récupération de contexte injecté au prompt). Default OFF : tant que
* kazeia-engine n'expose pas embedText + qu'aucun corpus n'est ingéré, * kazeia-engine n'expose pas embedText + qu'aucun corpus n'est ingéré,
* l'activer est inerte (l'embedder n'est pas prêt). Pilotable via admin. */ * l'activer est inerte (l'embedder n'est pas prêt). Pilotable via admin. */
@ -65,7 +84,14 @@ class ConfigStore private constructor(private val file: File) {
* pertinent ~0.88). Plus haut = plus strict. Tunable depuis l'admin. */ * pertinent ~0.88). Plus haut = plus strict. Tunable depuis l'admin. */
val ragThreshold: Float = 0.82f, val ragThreshold: Float = 0.82f,
/** Nombre max de chunks récupérés/injectés par tour. */ /** Nombre max de chunks récupérés/injectés par tour. */
val ragTopK: Int = 3 val ragTopK: Int = 3,
/** Mode debug : quand true, l'app patient affiche le bouton qui ouvre le
* panneau de logs + métriques système (CPU/GPU/NPU/RAM). Quand false
* (défaut prod), le bouton est masqué l'écran reste épuré pour le
* patient. Pilotable depuis l'app admin. */
val debugEnabled: Boolean = false,
/** Bibliothèque de presets de sampling réutilisables (gérée depuis l'admin). */
val presets: List<SamplingPreset> = defaultPresets()
) )
companion object { companion object {
@ -107,22 +133,34 @@ class ConfigStore private constructor(private val file: File) {
return store return store
} }
/** Presets de sampling d'usine inspirés du tuning Kaz (binôme) + profils
* thérapeutiques. Repère pour l'admin ; éditables/supprimables. */
fun defaultPresets(): List<SamplingPreset> = listOf(
SamplingPreset("Équilibré (défaut)", 0.7f, 0.85f, 40, 1.1f, 0.0f, 0.0f, 120),
SamplingPreset("Kaz chaleureux", 0.4f, 0.85f, 40, 1.05f, 0.2f, 0.2f, 256),
SamplingPreset("Factuel / froid", 0.3f, 0.70f, 40, 1.2f, 0.1f, 0.1f, 200),
SamplingPreset("Analyse (Thinker)", 0.2f, 0.70f, 40, 1.15f, 0.0f, 0.0f, 128),
SamplingPreset("Créatif", 0.9f, 0.95f, 60, 1.05f, 0.3f, 0.3f, 200)
)
fun defaultConfig(): RuntimeConfig = RuntimeConfig( fun defaultConfig(): RuntimeConfig = RuntimeConfig(
cascadeEnabled = false, // mono-Speaker = contrat FROZEN actuel cascadeEnabled = false, // mono-Speaker = contrat FROZEN actuel
speaker = ModelConfig( speaker = ModelConfig(
modelId = ModelRegistry.defaultSpeaker().id, modelId = ModelRegistry.defaultSpeaker().id,
systemPrompt = DEFAULT_SPEAKER_PROMPT, systemPrompt = DEFAULT_SPEAKER_PROMPT,
temperature = 0.7f temperature = 0.7f,
maxTokens = 450 // réponse Speaker longue (valeur prod historique)
), ),
thinker = ModelConfig( thinker = ModelConfig(
modelId = ModelRegistry.defaultThinker().id, modelId = ModelRegistry.defaultThinker().id,
systemPrompt = DEFAULT_THINKER_PROMPT, systemPrompt = DEFAULT_THINKER_PROMPT,
temperature = 0.0f temperature = 0.0f,
maxTokens = 128 // bloc clinique court
), ),
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
) )
} }
@ -173,19 +211,52 @@ class ConfigStore private constructor(private val file: File) {
root.put("rag_enabled", cfg.ragEnabled) root.put("rag_enabled", cfg.ragEnabled)
root.put("rag_threshold", cfg.ragThreshold.toDouble()) root.put("rag_threshold", cfg.ragThreshold.toDouble())
root.put("rag_top_k", cfg.ragTopK) root.put("rag_top_k", cfg.ragTopK)
root.put("speaker", JSONObject().apply { root.put("debug_enabled", cfg.debugEnabled)
put("model_id", cfg.speaker.modelId) root.put("speaker", modelToJson(cfg.speaker))
put("system_prompt", cfg.speaker.systemPrompt) root.put("thinker", modelToJson(cfg.thinker))
put("temperature", cfg.speaker.temperature) root.put("presets", org.json.JSONArray().apply {
cfg.presets.forEach { p ->
put(JSONObject().apply {
put("name", p.name)
put("temperature", p.temperature)
put("top_p", p.topP)
put("top_k", p.topK)
put("repeat_penalty", p.repeatPenalty)
put("presence_penalty", p.presencePenalty)
put("frequency_penalty", p.frequencyPenalty)
put("max_tokens", p.maxTokens)
}) })
root.put("thinker", JSONObject().apply { }
put("model_id", cfg.thinker.modelId)
put("system_prompt", cfg.thinker.systemPrompt)
put("temperature", cfg.thinker.temperature)
}) })
return root.toString(2) return root.toString(2)
} }
private fun modelToJson(m: ModelConfig) = JSONObject().apply {
put("model_id", m.modelId)
put("system_prompt", m.systemPrompt)
put("temperature", m.temperature)
put("top_p", m.topP)
put("top_k", m.topK)
put("repeat_penalty", m.repeatPenalty)
put("presence_penalty", m.presencePenalty)
put("frequency_penalty", m.frequencyPenalty)
put("max_tokens", m.maxTokens)
put("preset_name", m.presetName)
}
private fun modelFromJson(js: JSONObject, d: ModelConfig) = ModelConfig(
modelId = js.optString("model_id", d.modelId),
systemPrompt = js.optString("system_prompt", d.systemPrompt),
temperature = js.optDouble("temperature", d.temperature.toDouble()).toFloat(),
topP = js.optDouble("top_p", d.topP.toDouble()).toFloat(),
topK = js.optInt("top_k", d.topK),
repeatPenalty = js.optDouble("repeat_penalty", d.repeatPenalty.toDouble()).toFloat(),
presencePenalty = js.optDouble("presence_penalty", d.presencePenalty.toDouble()).toFloat(),
frequencyPenalty = js.optDouble("frequency_penalty", d.frequencyPenalty.toDouble()).toFloat(),
maxTokens = js.optInt("max_tokens", d.maxTokens),
presetName = js.optString("preset_name", d.presetName)
)
private fun fromJson(text: String): RuntimeConfig { private fun fromJson(text: String): RuntimeConfig {
val root = JSONObject(text) val root = JSONObject(text)
val defaults = defaultConfig() val defaults = defaultConfig()
@ -198,20 +269,25 @@ class ConfigStore private constructor(private val file: File) {
ragEnabled = root.optBoolean("rag_enabled", defaults.ragEnabled), ragEnabled = root.optBoolean("rag_enabled", defaults.ragEnabled),
ragThreshold = root.optDouble("rag_threshold", defaults.ragThreshold.toDouble()).toFloat(), ragThreshold = root.optDouble("rag_threshold", defaults.ragThreshold.toDouble()).toFloat(),
ragTopK = root.optInt("rag_top_k", defaults.ragTopK), ragTopK = root.optInt("rag_top_k", defaults.ragTopK),
speaker = root.optJSONObject("speaker")?.let { js -> debugEnabled = root.optBoolean("debug_enabled", defaults.debugEnabled),
ModelConfig( speaker = root.optJSONObject("speaker")?.let { modelFromJson(it, defaults.speaker) } ?: defaults.speaker,
modelId = js.optString("model_id", defaults.speaker.modelId), thinker = root.optJSONObject("thinker")?.let { modelFromJson(it, defaults.thinker) } ?: defaults.thinker,
systemPrompt = js.optString("system_prompt", defaults.speaker.systemPrompt), presets = root.optJSONArray("presets")?.let { arr ->
temperature = js.optDouble("temperature", defaults.speaker.temperature.toDouble()).toFloat() (0 until arr.length()).mapNotNull { i ->
arr.optJSONObject(i)?.let { js ->
SamplingPreset(
name = js.optString("name", ""),
temperature = js.optDouble("temperature", 0.7).toFloat(),
topP = js.optDouble("top_p", 0.85).toFloat(),
topK = js.optInt("top_k", 40),
repeatPenalty = js.optDouble("repeat_penalty", 1.1).toFloat(),
presencePenalty = js.optDouble("presence_penalty", 0.0).toFloat(),
frequencyPenalty = js.optDouble("frequency_penalty", 0.0).toFloat(),
maxTokens = js.optInt("max_tokens", 120)
) )
} ?: defaults.speaker, }
thinker = root.optJSONObject("thinker")?.let { js -> }.filter { it.name.isNotBlank() }
ModelConfig( } ?: defaults.presets
modelId = js.optString("model_id", defaults.thinker.modelId),
systemPrompt = js.optString("system_prompt", defaults.thinker.systemPrompt),
temperature = js.optDouble("temperature", defaults.thinker.temperature.toDouble()).toFloat()
)
} ?: defaults.thinker
) )
} }
} }

View File

@ -54,27 +54,11 @@ object ModelRegistry {
} }
} }
// Catalogue refonte 2026-06-17 (cf dist/LLM_INTEGRATION.md §2). Chargement unifié
// via UnifiedLlmAdapter (magic-byte) : GGUF→CPU i8mm, .pte→NPU V79 (libkazeia_pte).
// .pte en SOUS-DOSSIER <id>/hybrid_llama_qnn.pte (+ tokenizer.json) : le nom du
// dossier porte l'auto-détection decoder_model_version (qwen3/qwen2_5) côté natif.
val ALL: List<ModelInfo> = listOf( val ALL: List<ModelInfo> = listOf(
ModelInfo(
id = "qwen3-4b-seq512",
displayName = "Qwen3-4B (seq=512)",
pteFile = "hybrid_llama_qnn_4b.pte",
tokenizerFile = "tokenizer.json",
role = Role.SPEAKER,
sizeMb = 3000,
maxSeqLen = 512,
notes = "Speaker historique, budget contexte serré"
),
ModelInfo(
id = "qwen3-4b-seq1024",
displayName = "Qwen3-4B (seq=1024) ★",
pteFile = "hybrid_llama_qnn_4b_seq1024.pte",
tokenizerFile = "tokenizer.json",
role = Role.SPEAKER,
sizeMb = 3000,
maxSeqLen = 1024,
notes = "Speaker recommandé — production stable 2026-05-14"
),
ModelInfo( ModelInfo(
id = "qwen3.5-4b", id = "qwen3.5-4b",
displayName = "Qwen3.5-4B (GGUF) ★", displayName = "Qwen3.5-4B (GGUF) ★",
@ -83,43 +67,58 @@ object ModelRegistry {
role = Role.SPEAKER, role = Role.SPEAKER,
sizeMb = 2380, sizeMb = 2380,
maxSeqLen = 4096, maxSeqLen = 4096,
notes = "Speaker GGUF par défaut du moteur lib (q35-lmq4). Thinking-off Qwen3.5.", notes = "Speaker PAR DÉFAUT — hybride GatedDeltaNet (non exportable .pte), GGUF CPU i8mm. Thinking-off.",
backend = Backend.GGUF, backend = Backend.GGUF,
ggufFile = "q35-lmq4.gguf", ggufFile = "q35-lmq4.gguf",
chatTemplate = ChatTemplate.QWEN35_THINKOFF chatTemplate = ChatTemplate.QWEN35_THINKOFF
), ),
ModelInfo( ModelInfo(
id = "qwen2.5-7b-instruct", id = "qwen3-4b",
displayName = "Qwen2.5-7B Instruct (GGUF)", displayName = "Qwen3-4B (.pte NPU)",
pteFile = "", pteFile = "qwen3_4b_seq1024/hybrid_llama_qnn.pte",
tokenizerFile = "", tokenizerFile = "qwen3_4b_seq1024/tokenizer.json",
role = Role.SPEAKER, role = Role.SPEAKER,
sizeMb = 4683, sizeMb = 3100,
maxSeqLen = 4096, maxSeqLen = 1024,
notes = "Speaker GGUF dense (arch qwen2) via moteur lib CPU. Q4_K_M ~4.7 GB.", notes = "Dense Qwen3-4B sur NPU. Prefill 313 tok/s, RAM ÷2, sans root. Decode 15.7.",
backend = Backend.GGUF, backend = Backend.PTE,
ggufFile = "Qwen2.5-7B-Instruct-Q4_K_M.gguf", chatTemplate = ChatTemplate.QWEN35_THINKOFF
),
ModelInfo(
id = "qwen3-8b",
displayName = "Qwen3-8B (.pte NPU)",
pteFile = "qwen3_8b_seq512/hybrid_llama_qnn.pte",
tokenizerFile = "qwen3_8b_seq512/tokenizer.json",
role = Role.SPEAKER,
sizeMb = 5500,
maxSeqLen = 512,
notes = "Dense Qwen3-8B sur NPU. Decode 10.5, prefill 219, RAM ~3.1 G.",
backend = Backend.PTE,
chatTemplate = ChatTemplate.QWEN35_THINKOFF
),
ModelInfo(
id = "qwen2.5-7b",
displayName = "Qwen2.5-7B (.pte NPU)",
pteFile = "qwen2_5_7b_seq512/hybrid_llama_qnn.pte",
tokenizerFile = "qwen2_5_7b_seq512/tokenizer.json",
role = Role.SPEAKER,
sizeMb = 5000,
maxSeqLen = 512,
notes = "Dense Qwen2.5-7B sur NPU (decoder qwen2_5). Decode 8.0 (embeddings non-tied), RAM ~5.1 G.",
backend = Backend.PTE,
chatTemplate = ChatTemplate.PLAIN_CHATML chatTemplate = ChatTemplate.PLAIN_CHATML
), ),
ModelInfo(
id = "guard06b",
displayName = "Qwen3Guard 0.6B",
pteFile = "hybrid_llama_qnn_guard06b.pte",
tokenizerFile = "tokenizer_guard06b.json",
role = Role.THINKER,
sizeMb = 672,
maxSeqLen = 512,
notes = "Thinker léger, ION ~700 MB, cascade safe"
),
ModelInfo( ModelInfo(
id = "guard4b", id = "guard4b",
displayName = "Qwen3Guard 4B", displayName = "Qwen3Guard 4B (.pte NPU)",
pteFile = "hybrid_llama_qnn_guard4b.pte", pteFile = "guard4b/hybrid_llama_qnn.pte",
tokenizerFile = "tokenizer_guard.json", tokenizerFile = "guard4b/tokenizer.json",
role = Role.THINKER, role = Role.THINKER,
sizeMb = 3000, sizeMb = 3100,
maxSeqLen = 512, maxSeqLen = 512,
notes = "Thinker analytique. ⚠ Cascade Guard-4B + Speaker-4B = risque OOM." notes = "Thinker cascade sur NPU. Decode 17.2, prefill 166. Guard-4B + Speaker tiennent en 16 G.",
backend = Backend.PTE,
chatTemplate = ChatTemplate.QWEN35_THINKOFF
) )
) )
@ -127,11 +126,12 @@ object ModelRegistry {
fun byId(id: String): ModelInfo? = ALL.firstOrNull { it.id == id } fun byId(id: String): ModelInfo? = ALL.firstOrNull { it.id == id }
// Défaut = Speaker GGUF (moteur kazeia-engine lib) — on n'utilise plus de .pte // Défaut Speaker = Qwen3.5-4B GGUF (hybride, décision refonte 2026-06-17).
// (directive 2026-06-15). Repli : tout autre Speaker présent sur disque. // Repli : tout autre Speaker présent sur disque.
fun defaultSpeaker(): ModelInfo = byId("qwen3.5-4b") fun defaultSpeaker(): ModelInfo = byId("qwen3.5-4b")
?: ALL.first { it.role == Role.SPEAKER && it.fileExists() } ?: ALL.first { it.role == Role.SPEAKER && it.fileExists() }
fun defaultThinker(): ModelInfo = byId("guard06b") // Défaut Thinker (cascade) = Guard-4B .pte NPU.
fun defaultThinker(): ModelInfo = byId("guard4b")
?: ALL.first { it.role == Role.THINKER && it.fileExists() } ?: ALL.first { it.role == Role.THINKER && it.fileExists() }
} }

View File

@ -22,7 +22,12 @@ data class SamplingParams(
val temperature: Float = 0.7f, val temperature: Float = 0.7f,
val topP: Float = 0.85f, val topP: Float = 0.85f,
val topK: Int = 40, val topK: Int = 40,
val repetitionPenalty: Float = 1.2f val repetitionPenalty: Float = 1.2f,
// Pénalités OpenAI-style (présence/fréquence). Réglables via les presets admin.
// ⚠ effectives seulement quand le moteur natif expose le sampling (cf. docs
// SAMPLING_ENGINE_SPEC) ; aujourd'hui le JNI n'honore que maxNewTokens.
val presencePenalty: Float = 0.0f,
val frequencyPenalty: Float = 0.0f
) )
data class GenerationResult( data class GenerationResult(

View File

@ -34,6 +34,15 @@ class PipelineMetrics(val tag: String = "Pipeline") {
private var inputKind: String = "" private var inputKind: String = ""
private var inputPayload: String = "" private var inputPayload: String = ""
/** Récepteur optionnel du récap par tour (1 ligne lisible). Branché par
* KazeiaService sur son `log()` visible dans le panneau debug patient,
* pas seulement dans logcat. Découple PipelineMetrics du service. */
@Volatile var summarySink: ((String) -> Unit)? = null
/** Récepteur du bloc temps COMPLET (multi-lignes, 1 string) pour un encart
* debug PERSISTANT (vs les logs qui défilent). Mis à jour à chaque fin de tour. */
@Volatile var timingsSink: ((String) -> Unit)? = null
fun begin(kind: String, payload: String) { fun begin(kind: String, payload: String) {
t0 = System.currentTimeMillis() t0 = System.currentTimeMillis()
inputKind = kind inputKind = kind
@ -94,29 +103,67 @@ class PipelineMetrics(val tag: String = "Pipeline") {
.append('\n') .append('\n')
} }
// Derived KPIs that matter for perceived latency. // Derived KPIs that matter for perceived latency. Per-stage durations
// (and not just cumulative timestamps) so STT / Thinker / Speaker / TTS
// are each isolable.
val sttStart = firstT("STT.start") ?: 0L
val sttEnd = firstT("STT.end") val sttEnd = firstT("STT.end")
val thinkerStart = firstT("THINKER.start")
val thinkerEnd = firstT("THINKER.end")
val llmStart = firstT("LLM.start") val llmStart = firstT("LLM.start")
val llmFirstTok = firstT("LLM.first_token") val llmFirstTok = firstT("LLM.first_token")
val llmEnd = firstT("LLM.end") val llmEnd = firstT("LLM.end")
val firstAudio = firstT("PIPELINE.first_audio") ?: firstT("TTS.seg.play_start") val firstAudio = firstT("PIPELINE.first_audio") ?: firstT("TTS.seg.play_start")
val lastAudio = lastT("TTS.seg.play_end") val lastAudio = lastT("TTS.seg.play_end")
sb.appendLine("[PIPELINE] ---- KPIs (ms since begin) ----") val sttDur = sttEnd?.let { it - sttStart }
if (sttEnd != null) sb.appendLine("[PIPELINE] STT duration : $sttEnd") val thinkerDur = if (thinkerStart != null && thinkerEnd != null) thinkerEnd - thinkerStart else null
if (llmStart != null && llmFirstTok != null) val speakerTtft = if (llmStart != null && llmFirstTok != null) llmFirstTok - llmStart else null
sb.appendLine("[PIPELINE] LLM time-to-first-token: ${llmFirstTok - llmStart}") val speakerGen = if (llmStart != null && llmEnd != null) llmEnd - llmStart else null
if (llmStart != null && llmEnd != null) // TTS « synthèse 1ʳᵉ phrase » = Speaker fini → 1er son audible.
sb.appendLine("[PIPELINE] LLM generation : ${llmEnd - llmStart}") val ttsSynth = if (llmEnd != null && firstAudio != null) firstAudio - llmEnd else null
if (firstAudio != null) sb.appendLine("[PIPELINE] Time-to-first-audio : $firstAudio ← user waits this long") val playback = if (firstAudio != null && lastAudio != null) lastAudio - firstAudio else null
if (firstAudio != null && lastAudio != null)
sb.appendLine("[PIPELINE] Audio playback window : ${lastAudio - firstAudio}") sb.appendLine("[PIPELINE] ---- KPIs (durées par étape, ms) ----")
sb.appendLine("[PIPELINE] Total wall time : $total") if (sttDur != null) sb.appendLine("[PIPELINE] STT : $sttDur")
if (thinkerDur != null) sb.appendLine("[PIPELINE] Thinker (cascade) : $thinkerDur")
if (speakerTtft != null) sb.appendLine("[PIPELINE] Speaker time-to-1st-tok: $speakerTtft")
if (speakerGen != null) sb.appendLine("[PIPELINE] Speaker génération : $speakerGen")
if (ttsSynth != null) sb.appendLine("[PIPELINE] TTS → 1er son : $ttsSynth")
if (firstAudio != null) sb.appendLine("[PIPELINE] Time-to-first-audio : $firstAudio ← attente perçue")
if (playback != null) sb.appendLine("[PIPELINE] Lecture audio : $playback")
sb.appendLine("[PIPELINE] Total : $total")
// Single log call so it shows up contiguously even if the logger // Single log call so it shows up contiguously even if the logger
// interleaves other threads. // interleaves other threads.
Log.i(tag, sb.toString().trimEnd()) Log.i(tag, sb.toString().trimEnd())
// Bloc temps par étape (ne liste que les étapes réellement mesurées).
// Construit une fois, envoyé aux DEUX puits : summarySink (flot de logs) et
// timingsSink (encart persistant qui ne défile pas).
// GÉNÉRATION seule : on ne compte que le temps pour PRODUIRE la réponse
// (STT · Thinker · Speaker · TTS jusqu'au 1er son). La durée de LECTURE
// audio est exclue (ce n'est pas de la génération, elle dépend de la
// longueur du texte). Les lignes s'additionnent exactement au TOTAL gén.
// Le TTFT est un REPÈRE inclus dans Speaker, pas un poste à additionner.
run {
val b = StringBuilder("⏱─── GÉNÉRATION ($status) ───\n")
fun row(name: String, value: String) { b.append(" ${name.padEnd(11)} $value\n") }
var gen = 0L
if (sttDur != null) { row("STT", "$sttDur ms"); gen += sttDur }
if (thinkerDur != null) { row("Thinker", "$thinkerDur ms"); gen += thinkerDur }
if (speakerGen != null) {
row("Speaker", "$speakerGen ms" + (speakerTtft?.let { " (dont 1er token $it)" } ?: ""))
gen += speakerGen
}
if (ttsSynth != null) { row("TTS→son", "$ttsSynth ms"); gen += ttsSynth }
b.append(" ──────────\n")
b.append(" ${"TOTAL gén.".padEnd(11)} $gen ms")
val block = b.toString()
runCatching { timingsSink?.invoke(block) }
summarySink?.let { sink -> block.lineSequence().forEach { runCatching { sink(it) } } }
}
// Push a structured snapshot to TelemetryHolder so the admin app // Push a structured snapshot to TelemetryHolder so the admin app
// can pull it via ContentProvider. Best-effort — if anything goes // can pull it via ContentProvider. Best-effort — if anything goes
// wrong here it must not affect the pipeline. // wrong here it must not affect the pipeline.
@ -129,15 +176,14 @@ class PipelineMetrics(val tag: String = "Pipeline") {
inputKind = inputKind, inputKind = inputKind,
status = status, status = status,
totalMs = total, totalMs = total,
sttDurationMs = sttEnd, sttDurationMs = sttDur,
llmTtftMs = if (llmStart != null && llmFirstTok != null) thinkerMs = thinkerDur,
llmFirstTok - llmStart else null, llmTtftMs = speakerTtft,
llmGenerationMs = if (llmStart != null && llmEnd != null) llmGenerationMs = speakerGen,
llmEnd - llmStart else null,
llmTokens = tokensMatch, llmTokens = tokensMatch,
ttsSynthMs = ttsSynth,
timeToFirstAudioMs = firstAudio, timeToFirstAudioMs = firstAudio,
audioPlaybackMs = if (firstAudio != null && lastAudio != null) audioPlaybackMs = playback
lastAudio - firstAudio else null
) )
) )
} catch (e: Throwable) { } catch (e: Throwable) {

View File

@ -11,7 +11,17 @@ interface TtsEngine {
text: String, text: String,
language: String = "fr", language: String = "fr",
onStart: (() -> Unit)? = null, onStart: (() -> Unit)? = null,
onComplete: (() -> Unit)? = null onComplete: (() -> Unit)? = null,
// Émis quand CHAQUE phrase commence à jouer, avec son texte, sa durée
// audio, et les features visuelles (enveloppe RMS + spectrogramme N
// bandes) calculées sur son PCM. Pilote l'affichage progressif de la
// bulle et l'orbe AudioVisualizerView. Null = pas de retour par phrase.
onSegment: ((
sentence: String,
durationMs: Long,
rmsEnvelope: FloatArray,
spectrogram: Array<FloatArray>
) -> Unit)? = null
) )
fun stop() fun stop()
fun release() fun release()

View File

@ -65,12 +65,12 @@ class ProvisioningManager(context: Context) {
* provisionnée permet à l'onboarding de court-circuiter sans réseau. * provisionnée permet à l'onboarding de court-circuiter sans réseau.
*/ */
fun hasCoreModels(): Boolean { fun hasCoreModels(): Boolean {
val llm = File(KazeiaPaths.llmDir, "hybrid_llama_qnn_4b.pte").exists() || // Cœur post-refonte 2026-06-17 : Speaker GGUF (q35-lmq4) + Whisper STT.
File(KazeiaPaths.llmDir, "hybrid_llama_qnn_4b_seq1024.pte").exists() // (Le LLM .pte est devenu une alternative admin ; le TTS est provisionné
val talker = File(KazeiaPaths.modelsDir, "talker_f32.gguf").exists() || // à part — cf #286 productisation CosyVoice.)
File(KazeiaPaths.modelsDir, "talker_f16.gguf").exists() val llm = File(KazeiaPaths.modelsDir, "q35-lmq4.gguf").exists()
val whisper = File(KazeiaPaths.modelsDir, "whisper-small-sm8750").isDirectory val whisper = File(KazeiaPaths.modelsDir, "whisper-small-sm8750").isDirectory
return llm && talker && whisper return llm && whisper
} }
/** Composants à (re)télécharger selon le diff catalogue ↔ état local. */ /** Composants à (re)télécharger selon le diff catalogue ↔ état local. */

View File

@ -0,0 +1,30 @@
package com.kazeia.dist
/**
* État live des mises à jour, piloté à la demande depuis l'app admin (plus
* d'update automatique au lancement cf OnboardingActivity).
*
* Singleton en mémoire : le [UpdateWorker] (process patient) écrit, le
* ContentProvider lit (même process). Si le process meurt entre deux étapes,
* l'état repart à IDLE l'admin n'a qu'à relancer « Vérifier ». Suffisant
* pour le flux check poll install poll piloté côté admin.
*/
object UpdateStatus {
enum class Phase { IDLE, CHECKING, AVAILABLE, UPTODATE, DOWNLOADING, INSTALLING, DONE, ERROR }
@Volatile var phase: Phase = Phase.IDLE
@Volatile var appUpdateAvailable: Boolean = false
@Volatile var appVersionName: String = ""
@Volatile var appSize: Long = 0L
@Volatile var contentCount: Int = 0 // composants modèles/voix à (re)télécharger
@Volatile var contentSize: Long = 0L
@Volatile var progressDone: Long = 0L
@Volatile var progressTotal: Long = 0L
@Volatile var label: String = ""
@Volatile var error: String = ""
@Volatile var lastCheck: Long = 0L
fun resetForRun() {
progressDone = 0L; progressTotal = 0L; label = ""; error = ""
}
}

View File

@ -0,0 +1,102 @@
package com.kazeia.dist
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
/**
* Worker de mise à jour À LA DEMANDE (déclenché par l'app admin via le
* ContentProvider `call("update_check")` / `call("update_install")`).
*
* - mode CHECK : récupère le catalogue, calcule ce qui est dispo (APK +
* composants modèles/voix périmés) publie dans [UpdateStatus].
* - mode INSTALL : télécharge les composants périmés, puis (si dispo) télécharge
* l'APK et lance l'installation système (PackageInstaller).
*
* Réutilise [ProvisioningManager] (contenu) et [ApkUpdater] (APK). Aucune
* mise à jour automatique au lancement : tout passe par ici.
*/
class UpdateWorker(
private val appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params) {
private val manager = ProvisioningManager(appContext)
private val apk = ApkUpdater(appContext)
override suspend fun doWork(): Result {
val install = inputData.getString(KEY_MODE) == MODE_INSTALL
UpdateStatus.resetForRun()
UpdateStatus.phase = UpdateStatus.Phase.CHECKING
val catalog = manager.fetchCatalog() ?: run {
UpdateStatus.phase = UpdateStatus.Phase.ERROR
UpdateStatus.error = "Serveur de mise à jour injoignable."
return Result.failure()
}
val voices = manager.fetchVoicesCatalog()
val appUpd = apk.availableUpdate(catalog)
val pending = manager.pending(catalog) + (voices?.let { manager.pending(it) } ?: emptyList())
UpdateStatus.appUpdateAvailable = appUpd != null
UpdateStatus.appVersionName = appUpd?.versionName ?: ""
UpdateStatus.appSize = appUpd?.size ?: 0L
UpdateStatus.contentCount = pending.size
UpdateStatus.contentSize = pending.sumOf { it.totalSize }
UpdateStatus.lastCheck = System.currentTimeMillis()
if (!install) {
UpdateStatus.phase = if (appUpd == null && pending.isEmpty())
UpdateStatus.Phase.UPTODATE else UpdateStatus.Phase.AVAILABLE
return Result.success()
}
// ── INSTALL ──────────────────────────────────────────────────────────
// 1. Contenu (modèles + voix), s'il y en a.
if (pending.isNotEmpty()) {
UpdateStatus.phase = UpdateStatus.Phase.DOWNLOADING
val res = manager.provision(catalog) { p ->
UpdateStatus.label = p.componentLabel
UpdateStatus.progressDone = p.overallDone
UpdateStatus.progressTotal = p.overallTotal
}
if (res is ProvisioningManager.Outcome.Failed) {
UpdateStatus.phase = UpdateStatus.Phase.ERROR
UpdateStatus.error = res.reason
return Result.failure()
}
if (voices != null) manager.provision(voices) { p ->
UpdateStatus.label = p.componentLabel
UpdateStatus.progressDone = p.overallDone
UpdateStatus.progressTotal = p.overallTotal
}
}
// 2. APK — téléchargement + installation système (dialogue PackageInstaller).
if (appUpd != null) {
UpdateStatus.phase = UpdateStatus.Phase.INSTALLING
UpdateStatus.label = "Téléchargement de l'application v${appUpd.versionName}"
val file = apk.downloadApk(appUpd) { done, total ->
UpdateStatus.progressDone = done; UpdateStatus.progressTotal = total
}
if (file == null) {
UpdateStatus.phase = UpdateStatus.Phase.ERROR
UpdateStatus.error = "Échec du téléchargement de l'APK."
return Result.failure()
}
UpdateStatus.label = "Installation — confirmez le dialogue système."
apk.install(file) // ouvre le dialogue d'installation sur la tablette
}
UpdateStatus.phase = UpdateStatus.Phase.DONE
return Result.success()
}
companion object {
const val NAME = "kazeia_update"
const val KEY_MODE = "mode"
const val MODE_CHECK = "check"
const val MODE_INSTALL = "install"
}
}

View File

@ -1,11 +1,16 @@
// Harness LLM ADB-pilotable, mirror de BenchQnnHarness STT. // Harness LLM ADB-pilotable (bench LLM in-app).
// Déclenchement : `touch <files>/bench_llm.trigger`, relance app. // Déclenchement : `touch <files>/bench_llm.trigger`, relance app.
// Charge EngineLlmEngine (lib CPU-only) + génère 3 prompts FR, log texte + tps + nThreads honoré. // Valide le chemin refonte 2026-06-17 : UnifiedLlmAdapter (magic-byte) sur
// 1) le GGUF par défaut (Qwen3.5-4B, CPU i8mm + session cache-préfixe)
// 2) CHAQUE .pte présent sur disque (NPU V79 : libkazeia_pte + tokeniseur DJL arm64)
// Log : texte généré + prefill/decode + tok/s, pour chaque modèle.
package com.kazeia.llm package com.kazeia.llm
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import com.kazeia.KazeiaApplication import com.kazeia.config.ModelRegistry
import com.kazeia.core.SamplingParams
import kotlinx.coroutines.runBlocking
object BenchLlmHarness { object BenchLlmHarness {
private const val TAG = "BenchLlm" private const val TAG = "BenchLlm"
@ -16,30 +21,61 @@ object BenchLlmHarness {
"Je n'arrive plus à parler à ma femme" "Je n'arrive plus à parler à ma femme"
) )
private val SYS = "Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français : " + private val SYS = "Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français : " +
"UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle simple." "UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle simple. /no_think"
fun runBench(ctx: Context) = runBlocking {
Log.i(TAG, "########## BENCH LLM (UnifiedLlmAdapter, refonte 2026-06-17) ##########")
// Modèles à tester : GGUF par défaut + tous les .pte présents sur disque.
// ORDRE DIAG (2026-06-18) : on charge les gros .pte multi-contextes (8B/7B) EN PREMIER,
// dans un process frais, pour isoler "contention in-process après modèles précédents"
// vs "limite in-app fondamentale". Trigger fichier bench_llm_pte_first pour activer.
val pteFirst = java.io.File(ctx.getExternalFilesDir(null), "bench_llm_pte_first").exists()
val models = ModelRegistry.availableOnDisk().let { all ->
if (pteFirst) all.sortedBy { m ->
when (m.id) { "qwen3-8b" -> 0; "qwen2.5-7b" -> 1; "qwen3-4b" -> 2; "guard4b" -> 3; else -> 9 }
} else all
}
if (pteFirst) Log.i(TAG, ">>> ORDRE DIAG : .pte multi-contexte EN PREMIER (process frais)")
if (models.isEmpty()) { Log.e(TAG, "aucun modèle sur disque (ni GGUF ni .pte)"); return@runBlocking }
for (m in models) {
val path = if (m.backend == ModelRegistry.Backend.GGUF) m.ggufPath() else m.ptePath()
val tkPath = if (m.backend == ModelRegistry.Backend.GGUF) null else m.tokenizerPath()
Log.i(TAG, "===== ${m.id} (${m.backend}, seq=${m.maxSeqLen}) : $path =====")
fun runBench(ctx: Context) {
val gguf = "${KazeiaApplication.MODELS_DIR}/q35-lmq4.gguf"
Log.i(TAG, "=== load q35-lmq4 ===")
val t0 = System.currentTimeMillis() val t0 = System.currentTimeMillis()
val eng = try { val eng = try {
EngineLlmEngine(gguf, ctx = 2048, nThreads = 6) UnifiedLlmAdapter(
modelPath = path,
systemPrompt = SYS,
ctx = m.maxSeqLen,
nThreads = 6,
tokenizerPath = tkPath,
onLog = { Log.i(TAG, it) }
).also { it.load(path, com.kazeia.core.LlmConfig()) }
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, "load FAIL: ${e.message}"); return Log.e(TAG, " LOAD FAIL ${m.id}: ${e.message}", e); continue
} }
Log.i(TAG, "load OK (${System.currentTimeMillis() - t0}ms)") Log.i(TAG, " load OK (${System.currentTimeMillis() - t0}ms)")
// warmup
try { eng.generate(SYS, "Bonjour", 16) } catch (_: Exception) {} // warmup (1 tour court, hors mesure)
try { eng.generate("Bonjour", SamplingParams(maxNewTokens = 16)) { true } } catch (_: Throwable) {}
for ((i, p) in PROMPTS.withIndex()) { for ((i, p) in PROMPTS.withIndex()) {
val ts = System.currentTimeMillis() val ts = System.currentTimeMillis()
val out = try { eng.generate(SYS, p, 96) } catch (e: Exception) { "[ERR ${e.message}]" } val r = try {
eng.generate(p, SamplingParams(maxNewTokens = 96, temperature = 0.7f)) { true }
} catch (e: Throwable) {
Log.e(TAG, " [${i + 1}] gen FAIL: ${e.message}", e); continue
}
val ms = System.currentTimeMillis() - ts val ms = System.currentTimeMillis() - ts
val nWords = out.trim().split(Regex("\\s+")).size Log.i(TAG, " [${i + 1}/${PROMPTS.size}] '$p' → ${ms}ms (${"%.1f".format(r.tokensPerSecond)} tok/s)")
val tps = if (ms > 0) nWords * 1000f / ms else 0f Log.i(TAG, " « ${r.text} »")
Log.i(TAG, "[${i+1}/${PROMPTS.size}] '${p}' → ${ms}ms (~${"%.2f".format(tps)} mots/s) :")
Log.i(TAG, " '${out.trim()}'")
} }
eng.release() eng.release()
Log.i(TAG, "=== Bench end ===") Log.i(TAG, "===== fin ${m.id} =====")
}
Log.i(TAG, "########## BENCH LLM end ##########")
} }
} }

View File

@ -1,97 +0,0 @@
package com.kazeia.llm
import com.kazeia.core.GenerationResult
import com.kazeia.core.LlmConfig
import com.kazeia.core.LlmEngine
import com.kazeia.core.SamplingParams
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Adapter qui expose `com.kazeia.llm.EngineLlmEngine` (façade dist Kazeia-Engine,
* GGUF Qwen3.5/Qwen3 via libkazeia_engine.so + libllama fork ql + Hexagon)
* derrière l'interface prod `com.kazeia.core.LlmEngine`. Sert au swap réversible
* piloté par le flag config `llmEngine` ("prod" = ExecuTorch .pte | "lib" = engine GGUF).
*
* Pré-requis runtime : `libllama.so` fork ql + `libggml*.so` cohérents dans jniLibs.
* Si la chaîne libllama est encore en legacy (f86d), `System.loadLibrary("kazeia_engine")`
* jette UnsatisfiedLinkError au 1er accès à `EngineJni` KazeiaService doit catch
* et tomber en repli sur le path prod.
*
* Modèle par défaut : Qwen3.5-4B `q35-lmq4.gguf` (upgrade vs Qwen3 dense en prod).
*/
class EngineLlmAdapter(
private val modelPath: String,
private val systemPrompt: String,
private val ctxLen: Int = 4096,
private val nThreads: Int = 6,
// true = ChatML standard sans <think> (Qwen2.5…) via generateRaw ;
// false = bloc thinking-off Qwen3.5 injecté côté natif (generate).
private val plainChatml: Boolean = false,
private val onLog: ((String) -> Unit)? = null
) : LlmEngine {
private var engine: EngineLlmEngine? = null
// Session = cache de préfixe KV : le system prompt n'est prefillé QU'UNE fois
// (à la création), puis chaque tour ne prefille que le message user → ~2,5×
// moins de latence/tour (mesuré ~2991→~1190 ms sur q35-lmq4) + streaming.
// Pas créée pour plainChatml (Qwen2.5 = template ChatML différent → generateRaw).
// Stateless : reset() avant chaque ask (comportement historique sans mémoire
// LLM) — retirer ce reset activerait la mémoire conversationnelle.
private var session: LlmSession? = null
override suspend fun load(modelPath: String, config: LlmConfig) = withContext(Dispatchers.IO) {
if (engine != null) return@withContext
val t0 = System.currentTimeMillis()
val e = EngineLlmEngine(this@EngineLlmAdapter.modelPath, ctx = ctxLen, nThreads = nThreads)
engine = e
if (!plainChatml) session = e.newSession(systemPrompt) // prefille le system 1×
onLog?.invoke("[ENGINE] load OK (${System.currentTimeMillis() - t0}ms) path=${this@EngineLlmAdapter.modelPath} t=${nThreads} session=${session != null}")
}
override fun isLoaded(): Boolean = engine != null
override suspend fun generate(
prompt: String,
params: SamplingParams,
onToken: ((String) -> Boolean)?
): GenerationResult = withContext(Dispatchers.IO) {
val e = engine ?: throw IllegalStateException("EngineLlmAdapter not loaded")
val t0 = System.currentTimeMillis()
val s = session
val out: String = if (s != null) {
// Cache de préfixe + MÉMOIRE conversationnelle : le system reste prefillé,
// chaque ask ne prefille que le user et conserve l'historique en KV.
// (NB : on NE reset PAS par tour — sessionReset+ask renvoie vide,
// bug natif n_past non restauré ; reset boundary à rétablir côté engine.)
val sb = StringBuilder()
s.ask(prompt, params.maxNewTokens) { piece ->
sb.append(piece); onToken?.invoke(piece) ?: true
}
sb.toString().trim()
} else {
// plainChatml (Qwen2.5) : ChatML manuel, generateRaw bloquant.
val p = buildString {
append("<|im_start|>system\n").append(systemPrompt).append("<|im_end|>\n")
append("<|im_start|>user\n").append(prompt).append("<|im_end|>\n")
append("<|im_start|>assistant\n")
}
e.generateRaw(p, params.maxNewTokens).trim().also { onToken?.invoke(it) }
}
// Timing réel fourni par l'engine (prefill/decode séparés). tps = débit
// DÉCODE (fini l'artefact "prefill inclus" qui donnait ~3 tok/s).
val st = e.lastStats()
val n = if (st.nTokens > 0) st.nTokens.toInt() else out.split(Regex("\\s+")).size
val ms: Long = (st.prefillMs + st.decodeMs).takeIf { it > 0 } ?: (System.currentTimeMillis() - t0)
onLog?.invoke("[ENGINE] prefill=${st.prefillMs}ms decode=${st.decodeMs}ms tok=${st.nTokens} (${"%.1f".format(st.decodeTokPerSec)} tok/s decode)")
GenerationResult(out, n, ms, st.decodeTokPerSec.toFloat())
}
/** Démarre une conversation VIERGE (garde le system prefillé). À appeler aux
* bornes de conversation (changement de patient, effacement du chat) sinon
* la mémoire d'un patient fuiterait vers le suivant. sessionReset natif corrigé
* 2026-06-15 (clear + re-prefill system sur archi hybride le rewind partiel
* échoue), donc on l'utilise directement. */
fun resetSession() { session?.reset() }
override fun release() { session = null; engine?.release(); engine = null }
}

View File

@ -22,15 +22,7 @@ class EngineJni {
external fun sessionReset(h: Long) // repart du checkpoint système external fun sessionReset(h: Long) // repart du checkpoint système
external fun getLastStats(h: Long): LongArray // [prefill_ms, decode_ms, n_tokens] external fun getLastStats(h: Long): LongArray // [prefill_ms, decode_ms, n_tokens]
// -- TTS Talker (Qwen3-TTS) : I/O en embeddings, pas en tokens (vocab=3072 codes audio, pas de BPE). // (Bindings TTS-Talker Qwen3-TTS retirés 2026-06-18 : TTS = CosyVoice, lib dédiée.)
// Le caller (cf. TalkerEngine.kt) construit les embeds de prefill (text+x-vector) et de step
// (sum 16 codecs + tts_pad + trailing_text_hidden), fait l'échantillonnage, et compose le
// pipeline Talker→CP→Decoder. Retours conventionnels : 0 = OK, négatif = erreur.
external fun nEmbd(h: Long): Int // taille d'un embed (1024 pour Talker-0.6B)
external fun nVocab(h: Long): Int // 3072 pour le Talker
external fun resetEmbeds(h: Long) // KV clear + pos=0, à appeler en début de génération TTS
external fun prefillEmbeds(h: Long, embdFlat: FloatArray, t: Int, outHidden: FloatArray): Int
external fun decodeEmbed(h: Long, embd: FloatArray, outLogits: FloatArray, outHidden: FloatArray): Int
// -- Embeddings (RAG) : modèle dédié BERT-like (e5/bge), handle SÉPARÉ du LLM/TTS, CPU pur. // -- Embeddings (RAG) : modèle dédié BERT-like (e5/bge), handle SÉPARÉ du LLM/TTS, CPU pur.
// pooling: -1 = défaut du modèle (recommandé) ; 1 = MEAN (e5) ; 2 = CLS (bge). // pooling: -1 = défaut du modèle (recommandé) ; 1 = MEAN (e5) ; 2 = CLS (bge).

View File

@ -1,326 +0,0 @@
package com.kazeia.llm
import android.content.Context
import android.util.Log
import com.kazeia.core.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import org.pytorch.executorch.extension.llm.LlmCallback
import org.pytorch.executorch.extension.llm.LlmModule
/**
* LLM Engine using ExecuTorch LlmModule in-process **no root required**.
*
* Runs Qwen3-4B via `org.pytorch.executorch.extension.llm.LlmModule`, which
* wraps the same C++ TextLlmRunner as the standalone qnn_llama_runner binary
* but inside the app's own process. The QNN HTP backend works because the
* DSP fastrpc service accepts the Zygote-forked app process (unlike
* ProcessBuilder-spawned subprocesses which lose supplementary GIDs on exec
* and get rejected by the fastrpc credential checks).
*
* Model + tokenizer live in /data/local/tmp/kazeia-et/ (readable by the app
* on this device's permissive SELinux policy). libexecutorch.so + QNN libs
* are bundled in jniLibs.
*
* Current tablet config: Qwen3-4B KV-mode, ~18-22 tok/s on Hexagon V79
* (Snapdragon 8 Elite), TTFT 0.9 s, RSS 1.76 GB.
*/
/**
* Cascade Option E (in-process multi-LlmModule) :
* Le même process app peut instancier 2 ExecuTorchLlmEngine
* (Speaker + Thinker) qui partagent automatiquement le `QnnBackendBundle`
* via le singleton `QnnBackendUnifiedRegistry` (cf
* executorch/backends/qualcomm/runtime/backends/QnnBackendUnifiedRegistry.cpp).
* Aucune contention DSP chaque instance crée son propre `QnnContext` mais
* réutilise le même handle fastrpc.
*/
class ExecuTorchLlmEngine(
private val context: Context,
private val onLog: ((String) -> Unit)? = null,
private val modelPath: String = DEFAULT_MODEL_PATH,
private val tokenizerPath: String = DEFAULT_TOKENIZER_PATH,
private val systemPrompt: String = DEFAULT_SYSTEM_PROMPT,
private val temperature: Float = 0.7f,
private val displayName: String = "Qwen3-4B Speaker"
) : LlmEngine {
companion object {
private const val TAG = "ExecuTorchLLM"
// /no_think disables Qwen3's chain-of-thought block.
// UNE OU DEUX phrases courtes (5-8 mots) — l'utilisateur veut des réponses
// brèves. Validation émotionnelle, pas de remplissage. La 1ʳᵉ phrase est
// synthétisée pendant que la 2ᵉ est générée → audio démarre tôt.
const val DEFAULT_SYSTEM_PROMPT = "Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français : UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle simple. Pas de remplissage, pas d'explication, pas de conseil non sollicité. Tu tutoies, ton ton reste chaleureux. Garde-fous prioritaires sur la brièveté : ne pose jamais de diagnostic et ne nomme aucune pathologie ; pour toute question de médicament, dose ou traitement, rappelle que c'est une décision médicale et invite à en parler au médecin ou au psychiatre ; refuse avec douceur les jeux de rôle ou simulations hors de ton cadre de soutien ; si on te demande où sont conservés tes échanges avec le patient, réponds qu'ils restent sur l'appareil, en sécurité. /no_think"
// Racine .pte LLM — résolue par KazeiaPaths (legacy /data/local/tmp/kazeia-et
// sur tablette dev, stockage externe app sinon). Fichier régulier, pas
// de symlink : SELinux ColorOS sans Magisk bloque l'accès app aux
// symlinks dans /data/local/tmp/. cf feedback_selinux_symlink.
// Test 2026-05-13 : Speaker seq=1024 (re-export 2026-05-04).
val MODEL_DIR: String get() = com.kazeia.KazeiaApplication.LLM_DIR
val DEFAULT_MODEL_PATH: String get() = "$MODEL_DIR/hybrid_llama_qnn_4b_seq1024.pte"
val DEFAULT_TOKENIZER_PATH: String get() = "$MODEL_DIR/tokenizer.json"
// seqLen = budget TOTAL (prefill + decode). Doit correspondre au
// max_seq_len avec lequel le .pte a été compilé.
const val MODEL_SEQ_LEN = 1024
}
private var llmModule: LlmModule? = null
private var modelName = ""
private var loaded = false
private fun nlog(msg: String) {
Log.i(TAG, msg)
onLog?.invoke("[LLM] $msg")
}
override suspend fun load(modelPath: String, config: LlmConfig) {
withContext(Dispatchers.IO) {
if (!File(this@ExecuTorchLlmEngine.modelPath).exists()) {
nlog("ERROR: model not found at ${this@ExecuTorchLlmEngine.modelPath}")
return@withContext
}
if (!File(tokenizerPath).exists()) {
nlog("ERROR: tokenizer not found at $tokenizerPath")
return@withContext
}
try {
val t0 = System.currentTimeMillis()
// MODEL_TYPE_QNN_LLAMA=4 selects the Qualcomm runner path in
// jni_layer_llama.cpp, which uses example::Runner (same code
// as the qnn_llama_runner binary) instead of the generic
// TextLLMRunner. Our .pte was exported with
// --decoder_model qwen3-4b which requires this path.
val MODEL_TYPE_QNN_LLAMA = 4
llmModule = LlmModule(MODEL_TYPE_QNN_LLAMA, this@ExecuTorchLlmEngine.modelPath, tokenizerPath, temperature)
nlog("[$displayName] LlmModule instantiated in ${System.currentTimeMillis() - t0}ms")
// Load the PTE into QNN HTP (calls the native load()).
val loadResult = llmModule!!.load()
if (loadResult != 0) {
nlog("ERROR: LlmModule.load() returned $loadResult")
llmModule = null
return@withContext
}
nlog("[$displayName] LlmModule loaded in ${System.currentTimeMillis() - t0}ms total")
loaded = true
modelName = displayName
nlog("[$displayName] Ready")
} catch (e: Throwable) {
nlog("ERROR: LlmModule init failed: ${e.javaClass.simpleName}: ${e.message}")
llmModule = null
}
}
}
override fun isLoaded(): Boolean = loaded && llmModule != null
override suspend fun generate(
prompt: String,
params: SamplingParams,
onToken: ((String) -> Boolean)?
): GenerationResult = generateWithSystem(prompt, null, params, onToken, tag = "SPEAKER")
/**
* Cascade Option B : permet de passer un system prompt différent par appel
* (par ex. SYS_THINKER pour la passe d'analyse, puis le system prompt par
* défaut pour la passe Speaker). `null` = utilise le `systemPrompt`
* d'instance. Le `tag` préfixe les logs pour distinguer les passes
* cascadées dans logcat.
*/
suspend fun generateWithSystem(
prompt: String,
systemPromptOverride: String?,
params: SamplingParams = SamplingParams(),
onToken: ((String) -> Boolean)? = null,
tag: String = "LLM"
): GenerationResult = withContext(Dispatchers.IO) {
val mod = llmModule ?: throw IllegalStateException("Model not loaded")
// Logger local taggué pour cette passe (THINKER, SPEAKER, …).
val nl: (String) -> Unit = { msg ->
Log.i(TAG, "[$tag] $msg")
onLog?.invoke("[$tag] $msg")
}
val startTime = System.currentTimeMillis()
val fullPrompt = buildChatTemplate(prompt, systemPromptOverride ?: systemPrompt)
nl("Prompt: '${prompt.take(80)}'")
// Clear KV cache → cur_pos_ = 0. Indispensable pour le cascade B :
// sans ça, le cur_pos_ accumule entre passes (Thinker → Speaker) et
// entre tours, et l'assertion ExecuTorch
// `cur_pos_+num_prompt_tokens < seq_len` finit par exploser après
// 2-3 messages. Le prompt est repassé en entier à chaque appel donc
// clear le KV n'altère pas le contexte conversationnel.
try { mod.resetContext() } catch (e: Throwable) {
nl("WARN: resetContext() failed: ${e.message}")
}
val responseBuilder = StringBuilder()
var firstTokenMs = -1L
// Track whether we're inside a <think>…</think> block so the upstream
// SentenceStreamer / TTS doesn't get fed reasoning tokens. Even with
// /no_think in the system prompt Qwen3 still emits empty <think></think>
// wrappers for ~3 tokens before the real answer.
var inThink = false
val tokenScan = StringBuilder() // small lookahead to spot tag boundaries
// Singleton special tokens that should never reach the TTS streamer
// (they leak when the model wraps its reply or signals end-of-turn).
val stripTokens = listOf("<|im_start|>", "<|im_end|>", "<|endoftext|>")
val maxTagLen = listOf("<think>", "</think>", "<|im_start|>", "<|im_end|>", "<|endoftext|>")
.maxOf { it.length }
val cb = object : LlmCallback {
override fun onResult(result: String) {
if (firstTokenMs < 0) firstTokenMs = System.currentTimeMillis() - startTime
responseBuilder.append(result)
// Forward to caller only outside <think> blocks, and strip
// singleton special tokens. We accumulate a tiny lookahead buffer
// so tag tokens that arrive split ("<thi", "nk>") still match.
tokenScan.append(result)
while (true) {
if (!inThink) {
val open = tokenScan.indexOf("<think>")
if (open < 0) {
// No <think> open pending — strip any singleton tokens
// that fully landed in the buffer, then flush prose
// up to a safe point preserving lookahead.
for (tok in stripTokens) {
var idx = tokenScan.indexOf(tok)
while (idx >= 0) {
tokenScan.delete(idx, idx + tok.length)
idx = tokenScan.indexOf(tok)
}
}
val safe = tokenScan.length - maxTagLen
if (safe > 0) {
onToken?.invoke(tokenScan.substring(0, safe))
tokenScan.delete(0, safe)
}
break
}
// Flush the prose before the <think> tag, then enter think mode.
if (open > 0) onToken?.invoke(tokenScan.substring(0, open))
tokenScan.delete(0, open + "<think>".length)
inThink = true
} else {
val close = tokenScan.indexOf("</think>")
if (close < 0) {
// Drop all buffered chars except a small tail in case
// the closing tag is split across tokens.
val keep = "</think>".length - 1
if (tokenScan.length > keep) tokenScan.delete(0, tokenScan.length - keep)
break
}
tokenScan.delete(0, close + "</think>".length)
inThink = false
}
}
}
override fun onStats(stats: String) {
nl("stats: ${stats.take(200)}")
}
}
// seqLen est le budget TOTAL (prefill + decode) pour QNN runner.
// Doit matcher le max_seq_len du .pte compilé (cf MODEL_SEQ_LEN).
// Test 2026-05-13 : seq=1024 + reload-after-turn (Speaking state →
// release+reload caché derrière TTS playback) pour éviter
// l'accumulation cur_pos qui causait l'OOM précédent.
val seqLen = MODEL_SEQ_LEN
val rc = try {
// echo=false so onResult() only receives the generated completion,
// not the prompt tokens echoed back — otherwise the sentence
// streamer would feed '<|im_start|>user …' to the TTS.
mod.generate(fullPrompt, seqLen, cb, /* echo */ false)
} catch (e: Throwable) {
nl("generate() threw: ${e.message}")
-1
}
// Drain any leftover prose buffered during <think>-suppression so the
// last sentence reaches the TTS even if it ran past the closing tag.
if (!inThink && tokenScan.isNotEmpty()) {
onToken?.invoke(tokenScan.toString())
tokenScan.clear()
}
val elapsed = System.currentTimeMillis() - startTime
val rawText = responseBuilder.toString()
val responseText = cleanResponse(rawText)
val tokenCount = rawText.length / 4 // rough estimate without a tokenizer
val rate = if (elapsed > 0) (tokenCount * 1000f) / elapsed else 0f
nl("Response: '${responseText.take(80)}'")
nl("Stats: rc=$rc ~${tokenCount}tok ~${"%.1f".format(rate)}tok/s TTFT=${firstTokenMs}ms total=${elapsed}ms")
GenerationResult(
text = responseText,
tokenCount = tokenCount,
timeMs = elapsed,
tokensPerSecond = rate
)
}
/**
* Qwen3 chat template matching qnn_llama_runner.cpp's get_formatted_prompt()
* for DecoderModelVersion::kQwen3. Note the user-first-then-system ordering
* (quirky but required the runner binary produces the same layout and our
* .pte was trained with it). Terminates with `<|im_start|>assistant` with
* no trailing newline, matching the binary exactly.
*/
private fun buildChatTemplate(userInput: String, sysPrompt: String = systemPrompt): String {
val sb = StringBuilder()
sb.append("<|im_start|>user\n").append(userInput).append("<|im_end|>\n")
if (sysPrompt.isNotEmpty()) {
sb.append("<|im_start|>system\n").append(sysPrompt).append("<|im_end|>\n")
}
sb.append("<|im_start|>assistant")
return sb.toString()
}
/** Strip <think>…</think>, special tokens, and leading/trailing whitespace. */
private fun cleanResponse(raw: String): String {
var text = raw
val thinkEnd = text.indexOf("</think>")
if (thinkEnd >= 0) {
text = text.substring(thinkEnd + "</think>".length)
} else if (text.indexOf("<think>") >= 0) {
nlog("WARN: <think> block never closed")
return ""
}
return text
.replace("<|im_start|>", "")
.replace("<|im_end|>", "")
.replace("<|endoftext|>", "")
.replace("<think>", "")
.replace("</think>", "")
.trim()
}
override fun release() {
try { llmModule?.resetNative() } catch (_: Throwable) {}
llmModule = null
loaded = false
}
/**
* @deprecated reload-after-turn workaround. Test 2026-05-13 sur seq=1024 .pte
* a montré que (1) resetContext() au début de generate() suffit (cur_pos
* remis à 0 via Runner::reset côté C++), et (2) le release()+load()
* échoue avec ExecutorchInvalidArgumentException "Failed to load llm runner: [1]"
* sur la 2 instanciation (probable bug singleton QnnBackendUnifiedRegistry).
* Conservé pour rétro-compat ; ne plus appeler.
*/
@Deprecated("Use resetContext() — already called at start of generate()")
suspend fun reloadAfterTurn() {
release()
load("", com.kazeia.core.LlmConfig())
}
}

View File

@ -1,53 +0,0 @@
package com.kazeia.llm
/**
* JNI bridge to Qualcomm Genie SDK (libGenie.so).
* Native implementation in jni/genie_jni.cpp
*/
object GenieJni {
init {
System.loadLibrary("Genie")
System.loadLibrary("genie_jni")
}
/**
* Initialize Genie dialog from a JSON config file.
* @param configPath path to genie_config.json
* @return handle (pointer) to the dialog, or 0 on failure
*/
external fun createDialog(configPath: String): Long
/**
* Send a query to the dialog and get a response.
* @param dialogHandle handle from createDialog
* @param prompt the text prompt
* @param callback called with each decoded token; return false to stop
* @return full response string
*/
external fun query(dialogHandle: Long, prompt: String, callback: TokenCallback?): String
/**
* Set a stop sequence for the dialog.
* @param dialogHandle handle from createDialog
* @param stopSequence the stop sequence string
*/
external fun setStopSequence(dialogHandle: Long, stopSequence: String)
/**
* Free the dialog resources.
* @param dialogHandle handle from createDialog
*/
external fun freeDialog(dialogHandle: Long)
/**
* Get Genie API version.
* @return "major.minor"
*/
external fun getVersion(): String
interface TokenCallback {
/** Called for each generated token. Return false to stop generation. */
fun onToken(token: String): Boolean
}
}

View File

@ -1,74 +0,0 @@
package com.kazeia.llm
import android.util.Log
import com.kazeia.core.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class GenieLlmEngine : LlmEngine {
companion object {
private const val TAG = "GenieLlmEngine"
}
private var dialogHandle: Long = 0
private var loaded = false
override suspend fun load(modelPath: String, config: LlmConfig) {
withContext(Dispatchers.IO) {
Log.i(TAG, "Loading Genie model from $modelPath")
val configFile = "$modelPath/genie_config.json"
dialogHandle = GenieJni.createDialog(configFile)
if (dialogHandle == 0L) {
throw RuntimeException("Failed to create Genie dialog from $configFile")
}
// Set stop sequences for chat
GenieJni.setStopSequence(dialogHandle, "Patient:")
GenieJni.setStopSequence(dialogHandle, "\nPatient")
loaded = true
Log.i(TAG, "Genie model loaded, handle=$dialogHandle, version=${GenieJni.getVersion()}")
}
}
override fun isLoaded(): Boolean = loaded
override suspend fun generate(
prompt: String,
params: SamplingParams,
onToken: ((String) -> Boolean)?
): GenerationResult = withContext(Dispatchers.IO) {
if (!loaded) throw IllegalStateException("Model not loaded")
val startTime = System.currentTimeMillis()
var tokenCount = 0
val callback = if (onToken != null) {
object : GenieJni.TokenCallback {
override fun onToken(token: String): Boolean {
tokenCount++
return onToken(token)
}
}
} else null
val response = GenieJni.query(dialogHandle, prompt, callback)
val elapsed = System.currentTimeMillis() - startTime
if (tokenCount == 0) tokenCount = response.split(" ").size
GenerationResult(
text = response,
tokenCount = tokenCount,
timeMs = elapsed,
tokensPerSecond = if (elapsed > 0) tokenCount * 1000f / elapsed else 0f
)
}
override fun release() {
if (dialogHandle != 0L) {
GenieJni.freeDialog(dialogHandle)
dialogHandle = 0
loaded = false
Log.i(TAG, "Genie model released")
}
}
}

View File

@ -0,0 +1,106 @@
package com.kazeia.llm
import ai.djl.huggingface.tokenizers.HuggingFaceTokenizer
import java.io.File
import java.nio.file.Paths
// Chargeur LLM agnostique au format. Détecte par magic-byte et instancie le bon moteur :
// - GGUF -> GgufLlmEngine (libkazeia_engine / libllama, CPU-i8mm). TOUS les modèles,
// dont l'hybride Qwen3.5-4B (GatedDeltaNet) qui ne s'exporte PAS en .pte.
// - .pte -> PteLlmEngine (ExecuTorch + QNN, NPU V79, lib kazeia_pte). Denses UNIQUEMENT
// (Qwen3-4B, Qwen3-8B, Qwen2.5-7B, Qwen3Guard-4B). Chemin in-app autorisé
// (libQnn Maven, sans root). RAM ~÷2, prefill NPU rapide ; decode ~= CPU.
//
// Parc validé on-device (17/06, SM8750/V79, decode tok/s) :
// Qwen3-4B .pte 15.7 | Qwen3-8B .pte 10.5 | Qwen3Guard-4B .pte 17.2 | Qwen2.5-7B .pte 8.0
// Qwen3.5-4B GGUF 16.6 (hybride, CPU-i8mm)
interface LlmEngine {
fun generate(sys: String, usr: String, max: Int = 96): String
fun generateStream(sys: String, usr: String, max: Int = 96, onToken: (String) -> Boolean)
fun lastStats(): GenStats
fun reset()
fun release()
}
object LlmLoader {
private fun magic(path: String): ByteArray =
ByteArray(8).also { b -> File(path).inputStream().use { it.read(b) } }
private fun ByteArray.has(off: Int, tag: String): Boolean =
tag.indices.all { i -> off + i < size && this[off + i] == tag[i].code.toByte() }
// GGUF -> "GGUF" aux octets 0-3. ExecuTorch .pte -> "ET12" à l'octet 4.
// tokenizerPath : requis pour .pte (tokeniseur HF externe). Défaut = tokenizer.json voisin du .pte.
fun load(path: String, ctx: Int = 4096, nThreads: Int = 6, tokenizerPath: String? = null): LlmEngine {
val m = magic(path)
return when {
m.has(0, "GGUF") -> GgufLlmEngine(path, ctx, nThreads)
m.has(4, "ET12") -> {
val tk = tokenizerPath ?: (File(path).parent ?: ".") + "/tokenizer.json"
PteLlmEngine(path, tk, seqLen = ctx)
}
else -> error("Format LLM inconnu (ni GGUF ni ExecuTorch .pte) : $path")
}
}
}
// Adaptateur GGUF : délègue au moteur natif existant (libkazeia_engine / libllama, CPU-i8mm).
class GgufLlmEngine(model: String, ctx: Int = 4096, nThreads: Int = 6) : LlmEngine {
private val e = EngineLlmEngine(model, ctx, nThreads)
override fun generate(sys: String, usr: String, max: Int) = e.generate(sys, usr, max)
override fun generateStream(sys: String, usr: String, max: Int, onToken: (String) -> Boolean) =
e.generateStream(sys, usr, max, onToken)
override fun lastStats() = e.lastStats()
override fun reset() = e.reset()
override fun release() = e.release()
}
// JNI ExecuTorch+QNN. Lib `kazeia_pte` = wrapper natif du runner ExecuTorch (rebuild QAIRT 2.42).
// jniLibs/arm64-v8a doit aussi contenir : libqnn_executorch_backend.so (2.42) +
// libQnnHtp/System/Prepare/HtpV79Stub/HtpNetRunExtensions.so (QAIRT 2.42) + libQnnHtpV79Skel.so.
// Set figé : /opt/Kazeia/pte_prep_work/ship_runtime/.
internal object ExecuTorchJni {
external fun load(ptePath: String, tokenizerPath: String, seqLen: Int, evalMode: Int): Long
external fun generateFromIds(handle: Long, promptIds: LongArray, maxNewTokens: Int): LongArray
external fun lastStats(handle: Long): LongArray // [prefillMs, decodeMs, nGenerated]
external fun free(handle: Long)
init { System.loadLibrary("kazeia_pte") }
}
// Adaptateur ExecuTorch+QNN (.pte) pour les denses sur NPU V79.
// La tokenisation est faite ICI (côté Kotlin) car le tokeniseur natif est cassé pour Qwen
// (regex lookahead RE2/PCRE2) : on applique le template ChatML, on encode en IDs, et on passe
// les IDs au natif (eval_mode 1 hybride). Le natif décode greedy jusqu'à EOS et renvoie les IDs générés.
class PteLlmEngine(ptePath: String, tokenizerJson: String, private val seqLen: Int = 4096) : LlmEngine {
private val tok = HuggingFaceTokenizer.newInstance(Paths.get(tokenizerJson))
private val h = ExecuTorchJni.load(ptePath, tokenizerJson, seqLen, /*evalMode=hybrid*/ 1)
init { require(h != 0L) { "échec chargement .pte : $ptePath" } }
// Template ChatML Qwen ; les balises sont dans le texte -> encode sans special tokens auto.
private fun chatml(sys: String, usr: String): LongArray {
val p = buildString {
if (sys.isNotEmpty()) append("<|im_start|>system\n").append(sys).append("<|im_end|>\n")
append("<|im_start|>user\n").append(usr).append("<|im_end|>\n<|im_start|>assistant\n")
}
return tok.encode(p, /*addSpecialTokens=*/false, /*withOverflowingTokens=*/false).ids
}
override fun generate(sys: String, usr: String, max: Int): String {
val outIds = ExecuTorchJni.generateFromIds(h, chatml(sys, usr), max)
return tok.decode(outIds, /*skipSpecialTokens=*/true).trim()
}
// Streaming réel = variante native à callback de token (TODO côté JNI).
// En attendant : génération bloquante puis émission en un bloc.
override fun generateStream(sys: String, usr: String, max: Int, onToken: (String) -> Boolean) {
onToken(generate(sys, usr, max))
}
override fun lastStats(): GenStats =
ExecuTorchJni.lastStats(h).let { GenStats(it[0], it[1], it[2]) }
override fun reset() { /* runner sans état persistant entre appels */ }
override fun release() { ExecuTorchJni.free(h); tok.close() }
}

View File

@ -0,0 +1,139 @@
package com.kazeia.llm
import com.kazeia.core.GenerationResult
import com.kazeia.core.LlmConfig
import com.kazeia.core.SamplingParams
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
/**
* Pont unique app moteur LLM unifié (refonte 2026-06-17, cf dist/LLM_INTEGRATION.md).
* Expose l'interface app `com.kazeia.core.LlmEngine` au-dessus des 2 moteurs de l'engine,
* choisis par MAGIC-BYTE (comme LlmLoader) :
*
* - **GGUF** ("GGUF"@0) `EngineLlmEngine` (libkazeia_engine, CPU i8mm) AVEC session
* cache-préfixe KV (system prefillé 1×, streaming) pour le hybride Qwen3.5-4B + tout GGUF.
* - **.pte** ("ET12"@4) `PteLlmEngine` (ExecuTorch+QNN, NPU V79, libkazeia_pte, tokenis. DJL)
* pour les denses Qwen3-4B/8B, Qwen2.5-7B, Qwen3Guard-4B. Chemin NPU autorisé sans root.
*
* Remplace ExecuTorchLlmEngine (ancien .pte) + EngineLlmAdapter (GGUF). `generateWithSystem`
* porte le prompt système PAR TOUR (cascade : bullets du Thinker ; hot-reload prompt Speaker).
*/
class UnifiedLlmAdapter(
private val modelPath: String,
private val systemPrompt: String,
private val ctx: Int = 4096,
private val nThreads: Int = 6,
private val tokenizerPath: String? = null,
private val onLog: ((String) -> Unit)? = null
) : com.kazeia.core.LlmEngine {
private companion object {
// Bloc de raisonnement Qwen3.5 en tête de réponse (thinking-off => vide). À strip côté .pte.
val THINK_BLOCK = Regex("""^\s*<think>.*?</think>\s*""", RegexOption.DOT_MATCHES_ALL)
}
private enum class Fmt { GGUF, PTE }
@Volatile private var fmt: Fmt? = null
private var gguf: EngineLlmEngine? = null
private var session: LlmSession? = null
private var pte: PteLlmEngine? = null
private fun detect(path: String): Fmt {
val b = ByteArray(8)
File(path).inputStream().use { it.read(b) }
fun at(off: Int, s: String) = s.indices.all { i -> off + i < b.size && b[off + i] == s[i].code.toByte() }
return when {
at(0, "GGUF") -> Fmt.GGUF
at(4, "ET12") -> Fmt.PTE
else -> error("Format LLM inconnu (ni GGUF ni ET12 .pte) : $path")
}
}
override suspend fun load(modelPath: String, config: LlmConfig) = withContext(Dispatchers.IO) {
if (fmt != null) return@withContext
val t0 = System.currentTimeMillis()
val name = File(this@UnifiedLlmAdapter.modelPath).name
when (detect(this@UnifiedLlmAdapter.modelPath).also { fmt = it }) {
Fmt.GGUF -> {
val e = EngineLlmEngine(this@UnifiedLlmAdapter.modelPath, ctx, nThreads)
gguf = e
session = e.newSession(systemPrompt) // cache-préfixe KV (system prefillé 1×)
onLog?.invoke("[LLM] GGUF chargé en ${System.currentTimeMillis() - t0}ms (session) : $name")
}
Fmt.PTE -> {
val tk = tokenizerPath ?: (File(this@UnifiedLlmAdapter.modelPath).parent ?: ".") + "/tokenizer.json"
pte = PteLlmEngine(this@UnifiedLlmAdapter.modelPath, tk, seqLen = ctx)
onLog?.invoke("[LLM] .pte NPU chargé en ${System.currentTimeMillis() - t0}ms : $name")
}
}
}
override fun isLoaded(): Boolean = fmt != null
override suspend fun generate(
prompt: String,
params: SamplingParams,
onToken: ((String) -> Boolean)?
): GenerationResult = generateWithSystem(prompt, null, params, onToken)
/** Génère avec prompt système optionnel PAR TOUR. null = prompt système par défaut
* (chemin nominal GGUF = session cache-préfixe). Non-null = override (cascade). */
suspend fun generateWithSystem(
prompt: String,
systemPromptOverride: String?,
params: SamplingParams,
onToken: ((String) -> Boolean)?,
tag: String = "" // étiquette de log (compat call-sites SPEAKER/THINKER)
): GenerationResult = withContext(Dispatchers.IO) {
// Anti-effondrement decode CPU (GGUF, doc §7.2) : priorité grands cœurs.
try { android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_FOREGROUND) } catch (_: Exception) {}
val t0 = System.currentTimeMillis()
val out: String
val stats: GenStats
when (fmt) {
Fmt.GGUF -> {
val e = gguf ?: error("UnifiedLlmAdapter GGUF non chargé")
val sb = StringBuilder()
if (systemPromptOverride == null) {
// nominal : session (cache-préfixe), stateless par tour
val s = session!!
s.reset()
s.ask(prompt, params.maxNewTokens) { p -> sb.append(p); onToken?.invoke(p) ?: true }
} else {
// override par tour (cascade) : mono-tour avec le system fourni
e.generateStream(systemPromptOverride, prompt, params.maxNewTokens) { p -> sb.append(p); onToken?.invoke(p) ?: true }
}
out = sb.toString().trim()
stats = e.lastStats()
}
Fmt.PTE -> {
val e = pte ?: error("UnifiedLlmAdapter .pte non chargé")
val raw = e.generate(systemPromptOverride ?: systemPrompt, prompt, params.maxNewTokens)
// Le décode .pte conserve le bloc <think>…</think> (thinking-off => bloc vide) :
// ce ne sont PAS des special tokens Qwen3.5, donc skipSpecialTokens ne les retire pas.
// Le moteur GGUF, lui, le strippe nativement. On normalise ici pour que les 2 backends
// se comportent pareil (sinon le TTS lit « <think> </think> » et la cascade parse faux).
out = THINK_BLOCK.replace(raw, "").trim()
onToken?.invoke(out) // streaming .pte = un bloc (génération bloquante, doc §10)
stats = e.lastStats()
}
null -> error("UnifiedLlmAdapter non chargé")
}
val n = if (stats.nTokens > 0) stats.nTokens.toInt() else out.split(Regex("\\s+")).size
val ms = (stats.prefillMs + stats.decodeMs).takeIf { it > 0 } ?: (System.currentTimeMillis() - t0)
onLog?.invoke("[LLM] prefill=${stats.prefillMs}ms decode=${stats.decodeMs}ms tok=${stats.nTokens} (${"%.1f".format(stats.decodeTokPerSec)} tok/s)")
GenerationResult(out, n, ms, stats.decodeTokPerSec.toFloat())
}
/** Conversation vierge (bornes : changement de patient, CLEAR_CHAT). GGUF (cache) ; no-op .pte. */
fun resetSession() { session?.reset() }
override fun release() {
session = null
gguf?.release(); gguf = null
pte?.release(); pte = null
fmt = null
}
}

View File

@ -160,26 +160,13 @@ 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) { // onSegment propage le retour par-phrase (texte + features visuelles)
qwen.onSegmentPlaying = onSegmentPlaying // pour le reveal mot-par-mot et l'orbe — perdu lors de la bascule
qwen.startStreamingSession() // CosyVoice quand la branche streaming Qwen3 a été retirée (bug #2/#3).
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 },
) onSegment = onSegmentPlaying)
}
} catch (e: Exception) { } catch (e: Exception) {
log("TTS error: ${e.message}") log("TTS error: ${e.message}")
} }

View File

@ -20,8 +20,6 @@ import com.kazeia.conversation.ConversationManager
import com.kazeia.conversation.PromptBuilder import com.kazeia.conversation.PromptBuilder
import com.kazeia.conversation.StoppingCriteria import com.kazeia.conversation.StoppingCriteria
import com.kazeia.core.* import com.kazeia.core.*
import com.kazeia.llm.ExecuTorchLlmEngine
import com.kazeia.llm.GenieLlmEngine
import com.kazeia.profiles.ConversationDao import com.kazeia.profiles.ConversationDao
import com.kazeia.profiles.ConversationDb import com.kazeia.profiles.ConversationDb
import com.kazeia.profiles.ProfileStore import com.kazeia.profiles.ProfileStore
@ -56,15 +54,10 @@ class KazeiaService : Service() {
Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think""" Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think"""
} }
private lateinit var llm: LlmEngine private lateinit var llm: LlmEngine // Speaker (UnifiedLlmAdapter : GGUF défaut ou .pte)
// Cascade Option E v2 : 2 ExecuTorchLlmEngine cohabitants in-process. // Cascade (off par défaut) : 2ᵉ moteur Thinker via UnifiedLlmAdapter,
// - llm = Speaker Qwen3-4B (.pte ~3.3 GB ION, ~4.4 GB total) // lazy au 1er tour (défaut Guard-4B .pte). nThreads réduit pour le Speaker.
// - thinkerEngine = Guard-0.6B (.pte ~700 MB ION) lazy-loaded au 1er PTT private var thinkerEngine: com.kazeia.llm.UnifiedLlmAdapter? = null
// Total ION ~5.1 GB, marge OK pour TTS pic. QnnBackendUnifiedRegistry
// singleton partage le QnnBackendBundle (handle fastrpc DSP) entre les 2
// contextes — pas de collision DSP, pas de subprocess. Cf
// project_kazeia_cascade_option_e dans la mémoire persistante.
private var thinkerEngine: com.kazeia.llm.ExecuTorchLlmEngine? = null
private val thinkerLock = Object() private val thinkerLock = Object()
// Config runtime (cascade, prompts, modèles) pilotée par l'admin app via // Config runtime (cascade, prompts, modèles) pilotée par l'admin app via
@ -124,14 +117,10 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
log("[LLM] loaded in ${System.currentTimeMillis() - t0}ms") log("[LLM] loaded in ${System.currentTimeMillis() - t0}ms")
} }
private suspend fun reloadLlmAfterTurn() = llmMutex.withLock { // reloadAfterTurn supprimé avec la refonte LLM 2026-06-17 : l'ancien hack
val ll = llm as? com.kazeia.llm.ExecuTorchLlmEngine ?: return@withLock // ExecuTorchLlmEngine (reset cur_pos) n'a plus lieu d'être. UnifiedLlmAdapter
if (!ll.isLoaded()) return@withLock // gère l'état par tour (session reset GGUF ; .pte sans état persistant).
val t0 = System.currentTimeMillis() private suspend fun reloadLlmAfterTurn() { /* no-op */ }
log("[LLM] reload pour reset cur_pos (état propre prochain tour)…")
ll.reloadAfterTurn()
log("[LLM] reloaded in ${System.currentTimeMillis() - t0}ms")
}
private lateinit var tts: TtsEngine private lateinit var tts: TtsEngine
private lateinit var audioPlayback: AudioPlaybackManager private lateinit var audioPlayback: AudioPlaybackManager
@ -143,7 +132,19 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
// Per-interaction timing tracer. One instance, reset at every begin(). // Per-interaction timing tracer. One instance, reset at every begin().
// All stage timings for a single user turn (STT+LLM+TTS+playback) end // All stage timings for a single user turn (STT+LLM+TTS+playback) end
// up in a single grep-friendly log section tagged "[PIPELINE]". // up in a single grep-friendly log section tagged "[PIPELINE]".
private val metrics = com.kazeia.core.PipelineMetrics(TAG) private val metrics = com.kazeia.core.PipelineMetrics(TAG).apply {
// Récap par tour (STT · Thinker · Speaker · TTS · total) routé vers les logs
// du service → visible dans le panneau debug patient, pas que dans logcat.
summarySink = { msg -> log(msg) }
// + encart PERSISTANT (ne défile pas) : input · output · temps du dernier tour.
timingsSink = { block ->
_lastTurnPanel.value = buildString {
append("🗣 Vous : "); append(lastTurnInput.ifBlank { "" }); append("\n\n")
append("🤖 Kazeia : "); append(lastTurnOutput.ifBlank { "" }); append("\n\n")
append(block)
}
}
}
private lateinit var voiceCommands: com.kazeia.conversation.VoiceCommandProcessor private lateinit var voiceCommands: com.kazeia.conversation.VoiceCommandProcessor
// New modular pipeline // New modular pipeline
@ -158,6 +159,12 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
private val _logs = MutableStateFlow<List<String>>(emptyList()) private val _logs = MutableStateFlow<List<String>>(emptyList())
val logs: StateFlow<List<String>> = _logs val logs: StateFlow<List<String>> = _logs
// Encart « dernier tour » persistant (input · output · temps par étape).
private val _lastTurnPanel = MutableStateFlow("⏱ dernier tour : —")
val lastTurnPanel: StateFlow<String> = _lastTurnPanel
@Volatile private var lastTurnInput = ""
@Volatile private var lastTurnOutput = ""
// AI workload tracking: which component is active (for monitoring) // AI workload tracking: which component is active (for monitoring)
data class AiWorkload( data class AiWorkload(
val sttActive: Boolean = false, val sttActive: Boolean = false,
@ -214,10 +221,9 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
val loadingState: StateFlow<LoadingState> = _loadingState val loadingState: StateFlow<LoadingState> = _loadingState
/** /**
* Cascade E v2 : crée la 2 ExecuTorchLlmEngine (Thinker = Guard-0.6B) dans * Cascade : crée le 2 moteur (Thinker, défaut Guard-4B .pte NPU) via
* le MÊME process app. Lazy au 1er PTT (~1-2 s de load 0.6B). Le * UnifiedLlmAdapter dans le même process, lazy au 1er tour. Off par défaut
* QnnBackendUnifiedRegistry singleton réutilise le QnnBackendBundle du * (cascadeEnabled=false). nThreads réduit pour laisser des cœurs au Speaker.
* Speaker un seul handle fastrpc DSP, 2 contextes/graphes coexistent.
*/ */
private fun ensureThinkerEngine() { private fun ensureThinkerEngine() {
synchronized(thinkerLock) { synchronized(thinkerLock) {
@ -228,14 +234,15 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
log("[THINKER] loading ${info.displayName} engine in-process (lazy first PTT)…") log("[THINKER] loading ${info.displayName} engine in-process (lazy first PTT)…")
val tStart = System.currentTimeMillis() val tStart = System.currentTimeMillis()
val memBefore = readMemAvailableMb() val memBefore = readMemAvailableMb()
val e = com.kazeia.llm.ExecuTorchLlmEngine( // Thinker via UnifiedLlmAdapter : Guard-4B .pte (NPU) ou tout GGUF du registre.
context = this@KazeiaService, val isPte = info.backend == com.kazeia.config.ModelRegistry.Backend.PTE
onLog = { msg -> log("[THINKER] $msg") }, val e = com.kazeia.llm.UnifiedLlmAdapter(
modelPath = info.ptePath(), modelPath = if (isPte) info.ptePath() else info.ggufPath(),
tokenizerPath = info.tokenizerPath(),
systemPrompt = cfg.thinker.systemPrompt, systemPrompt = cfg.thinker.systemPrompt,
temperature = cfg.thinker.temperature, ctx = if (isPte) info.maxSeqLen else 4096,
displayName = "${info.displayName} Thinker" nThreads = 4, // cascade : laisser des cœurs au Speaker
tokenizerPath = if (isPte) info.tokenizerPath() else null,
onLog = { msg -> log("[THINKER] $msg") }
) )
try { try {
runBlocking { e.load("", com.kazeia.core.LlmConfig()) } runBlocking { e.load("", com.kazeia.core.LlmConfig()) }
@ -282,290 +289,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) {
@ -708,6 +431,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
// (admin app → ContentProvider.update → ConfigStore.save → listener) // (admin app → ContentProvider.update → ConfigStore.save → listener)
val store = com.kazeia.config.ConfigStore.get(this) val store = com.kazeia.config.ConfigStore.get(this)
runtimeConfig = store.current() runtimeConfig = store.current()
_debugMode.value = runtimeConfig.debugEnabled // gate UI debug (bouton logs/métriques)
store.addListener { new -> handleConfigChange(new) } store.addListener { new -> handleConfigChange(new) }
registerConfigReloadReceiver() registerConfigReloadReceiver()
registerRagReloadReceiver() registerRagReloadReceiver()
@ -755,37 +479,20 @@ 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)")
} // Pré-chauffe le cache voix EN TÂCHE DE FOND (chevauche le reste du
"lib" -> { // chargement + la 1ʳᵉ phrase de l'usager) → le 1er vrai tour part
_loadingState.value = LoadingState(15, "TTS engine…") // chaud (~3 s) au lieu de ~10 s (remplissage de cache à froid).
val libTts = com.kazeia.tts.KazeiaTtsEngine(this@KazeiaService) { msg -> log("[TTS] $msg") } serviceScope.launch(Dispatchers.IO) {
libTts.load() runCatching { cv.warmup() }
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")
@ -797,18 +504,13 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
// pendant TTS (caché derrière le playback) et release pendant // pendant TTS (caché derrière le playback) et release pendant
// les phases LLM/TTS où Whisper ne sert pas. // les phases LLM/TTS où Whisper ne sert pas.
_loadingState.value = LoadingState(25, "STT Whisper (lazy)…") _loadingState.value = LoadingState(25, "STT Whisper (lazy)…")
// Backend STT pilotable via ConfigStore.sttEngine. Default "prod" = // STT = WhisperHybridEngine (NPU, gelé) — SEUL backend depuis le retrait
// WhisperHybridEngine gelé. "lib" = KazeiaSttAdapter (libkazeia_stt.so). // du backend "lib" (KazeiaSttAdapter/libkazeia_stt) non validé, 2026-06-18.
val sttBackend = runtimeConfig.sttEngine // ConfigStore.sttEngine est désormais vestigial (compat config).
val npuStt: com.kazeia.core.SttEngine = when (sttBackend) { val npuStt: com.kazeia.core.SttEngine = WhisperHybridEngine(nativeLibDir) { msg -> log("[STT] $msg") }
"lib" -> com.kazeia.stt.KazeiaSttAdapter(
"$modelsDir/whisper-small-sm8750"
) { msg -> log("[STT] $msg") }
else -> WhisperHybridEngine(nativeLibDir) { msg -> log("[STT] $msg") }
}
sttModelPath = "$modelsDir/whisper-small-sm8750" sttModelPath = "$modelsDir/whisper-small-sm8750"
stt = npuStt stt = npuStt
log("Whisper créé backend='$sttBackend' (lazy load au 1er PTT)") log("Whisper créé (lazy load au 1er PTT)")
addMessage(ChatMessage( addMessage(ChatMessage(
role = ChatMessage.Role.SYSTEM, role = ChatMessage.Role.SYSTEM,
text = "STT: Whisper-Small NPU" text = "STT: Whisper-Small NPU"
@ -817,47 +519,28 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
// LLM = ExecuTorch QNN. Modèle choisi dans la config runtime // LLM = ExecuTorch QNN. Modèle choisi dans la config runtime
// (admin app peut le changer à chaud). Fallback sur le // (admin app peut le changer à chaud). Fallback sur le
// Speaker par défaut du registre si l'id config est inconnu. // Speaker par défaut du registre si l'id config est inconnu.
// LLM = Kazeia-Engine GGUF (llama.cpp upstream + Hexagon), Speaker // LLM unifié (refonte 2026-06-17, cf dist/LLM_INTEGRATION.md) :
// Qwen3.5-9B prefill NPU / decode CPU. Plus de .pte (-30% RAM). // UnifiedLlmAdapter détecte le format par magic-byte et charge le bon
// Backend LLM pilotable via ConfigStore.llmEngine. Default "prod" = // moteur — GGUF (Qwen3.5-4B hybride, défaut) sur CPU i8mm avec cache-
// ExecuTorch .pte (Qwen3 dense). "lib" = EngineLlmAdapter (libkazeia_engine, // préfixe, ou .pte (denses Qwen3-4B/8B, Qwen2.5-7B) sur NPU V79 (sans
// GGUF Qwen3.5). Fallback gracieux sur prod si la lib échoue au load // root). Le flag ConfigStore.llmEngine n'est plus déterminant (auto-détecté).
// (libllama fork ql encore absente du jniLibs tant que swap coordonné non fait).
val llmBackend = runtimeConfig.llmEngine
_loadingState.value = LoadingState(50, "LLM backend=$llmBackend")
val sp = com.kazeia.config.ModelRegistry.byId(runtimeConfig.speaker.modelId) ?: com.kazeia.config.ModelRegistry.defaultSpeaker() val sp = com.kazeia.config.ModelRegistry.byId(runtimeConfig.speaker.modelId) ?: com.kazeia.config.ModelRegistry.defaultSpeaker()
val llmLib: com.kazeia.core.LlmEngine? = if (llmBackend == "lib") try { _loadingState.value = LoadingState(50, "LLM ${sp.id}")
// Si le Speaker sélectionné est un GGUF du registre (ex. Qwen2.5-7B), val isPte = sp.backend == com.kazeia.config.ModelRegistry.Backend.PTE
// on le charge ; sinon fallback sur le GGUF moteur par défaut (q35-lmq4). val mPath = if (isPte) sp.ptePath() else sp.ggufPath()
val gguf = if (sp.backend == com.kazeia.config.ModelRegistry.Backend.GGUF && sp.fileExists()) val tkPath = if (isPte) sp.tokenizerPath() else null
sp.ggufPath() val ctxLen = if (isPte) sp.maxSeqLen else 4096
else log("[LLM] speaker=${sp.id} backend=${sp.backend} path=$mPath ctx=$ctxLen")
"${KazeiaApplication.MODELS_DIR}/q35-lmq4.gguf" llm = com.kazeia.llm.UnifiedLlmAdapter(
val plain = sp.chatTemplate == com.kazeia.config.ModelRegistry.ChatTemplate.PLAIN_CHATML mPath, runtimeConfig.speaker.systemPrompt,
log("[LLM] lib GGUF=$gguf (speaker=${sp.id}, plainChatml=$plain)") ctx = ctxLen, nThreads = 6, tokenizerPath = tkPath,
com.kazeia.llm.EngineLlmAdapter(gguf, runtimeConfig.speaker.systemPrompt, onLog = { msg -> log(msg) }
plainChatml = plain, onLog = { msg -> log(msg) }) )
} catch (e: Throwable) {
log("[LLM] lib instantiation failed (${e.message}) — fallback .pte")
null
} else null
llm = llmLib ?: ExecuTorchLlmEngine(this@KazeiaService, { msg -> log(msg) },
sp.ptePath(), sp.tokenizerPath(), runtimeConfig.speaker.systemPrompt,
runtimeConfig.speaker.temperature, sp.displayName)
try { try {
llm.load("", com.kazeia.core.LlmConfig()) llm.load("", com.kazeia.core.LlmConfig())
log("LLM créé backend='${if (llmLib != null) "lib" else "prod"}'") log("LLM créé : ${sp.id} (${sp.backend})")
} catch (e: Exception) { } catch (e: Throwable) {
log("LLM load failed (${e.message}) — fallback echo") log("LLM load failed (${e.message}) — mode écho jusqu'au prochain essai")
if (llmLib != null) {
// lib loadLibrary("kazeia_engine") a échoué (libllama fork ql absente) ;
// on retombe sur .pte pour ne pas perdre l'app
log("[LLM] retry on prod .pte")
llm = ExecuTorchLlmEngine(this@KazeiaService, { msg -> log(msg) },
sp.ptePath(), sp.tokenizerPath(), runtimeConfig.speaker.systemPrompt,
runtimeConfig.speaker.temperature, sp.displayName)
try { llm.load("", com.kazeia.core.LlmConfig()) } catch (_: Exception) {}
}
} }
// RAG (optionnel, default OFF). Embedder = kazeia-engine. Inerte si la // RAG (optionnel, default OFF). Embedder = kazeia-engine. Inerte si la
@ -1010,7 +693,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
val nowId = ProfileStore.get(applicationContext).activeProfile()?.id val nowId = ProfileStore.get(applicationContext).activeProfile()?.id
if (nowId != lastActiveProfileId) { if (nowId != lastActiveProfileId) {
lastActiveProfileId = nowId lastActiveProfileId = nowId
(llm as? com.kazeia.llm.EngineLlmAdapter)?.resetSession() (llm as? com.kazeia.llm.UnifiedLlmAdapter)?.resetSession()
log("[LLM] profil actif changé → mémoire de session réinitialisée") log("[LLM] profil actif changé → mémoire de session réinitialisée")
} }
} }
@ -1029,11 +712,8 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
* ../voix/<id>.wav. Hot-swap : effet au prochain segment synthétisé. */ * ../voix/<id>.wav. Hot-swap : effet au prochain segment synthétisé. */
fun setVoiceId(voiceId: String) { fun setVoiceId(voiceId: String) {
currentVoiceName = voiceId currentVoiceName = voiceId
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")
} }
} }
@ -1043,8 +723,24 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
fun setVoice(voicePathOrId: String) = fun setVoice(voicePathOrId: String) =
setVoiceId(voicePathOrId.substringAfterLast('/').substringBeforeLast('.')) setVoiceId(voicePathOrId.substringAfterLast('/').substringBeforeLast('.'))
// True dès que le 1er son de la réponse est audible (la 1ʳᵉ phrase TTS joue).
// Pendant la génération LLM + la synthèse TTS (avant tout son), l'entrée est
// VERROUILLÉE : un tap/bruit ne doit pas perturber le pipeline (half-duplex).
@Volatile private var audiblePlayback = false
/** Le pipeline produit la réponse mais rien n'est encore audible (LLM ou TTS
* en cours) on ignore toute entrée utilisateur jusqu'au 1er son / fin de tour. */
private fun producingBeforeAudio(): Boolean {
val s = _pipelineState.value
val busy = s is PipelineState.Thinking || s is PipelineState.Transcribing ||
s is PipelineState.Transcribed || s is PipelineState.TokenGenerated ||
s is PipelineState.Speaking
return busy && !audiblePlayback
}
fun interruptTts() { fun interruptTts() {
if (_pipelineState.value is PipelineState.Speaking) { // N'interrompre QUE si la réponse est réellement en train de jouer.
if (_pipelineState.value is PipelineState.Speaking && audiblePlayback) {
log("TTS interrupted by user") log("TTS interrupted by user")
tts.stop() tts.stop()
_pipelineState.value = if (_isListening.value) PipelineState.Listening else PipelineState.Idle _pipelineState.value = if (_isListening.value) PipelineState.Listening else PipelineState.Idle
@ -1054,7 +750,15 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
@Volatile private var pttRecording = false @Volatile private var pttRecording = false
fun toggleListening() { fun toggleListening() {
// If TTS is speaking, interrupt it // Verrou half-duplex : pendant la génération LLM / synthèse TTS (avant le 1er
// son), on ignore le bouton micro — sinon un tap casse la synthèse en cours
// (« play() on uninitialized AudioTrack », tour avorté). On réaccepte l'entrée
// une fois l'audio audible (l'usager peut alors interrompre).
if (producingBeforeAudio()) {
log("toggleListening ignoré — génération/synthèse en cours (entrée verrouillée)")
return
}
// Audio en cours → l'usager a le droit d'interrompre.
if (_pipelineState.value is PipelineState.Speaking) { if (_pipelineState.value is PipelineState.Speaking) {
log("Interrupting TTS") log("Interrupting TTS")
tts.stop() tts.stop()
@ -1284,6 +988,13 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
var wasMuted = false var wasMuted = false
var lastBusyMs = 0L var lastBusyMs = 0L
// Pré-roll : ~300 ms de frames sous le seuil gardées en anneau pour
// capturer l'attaque consonantique (« b », « p », « t »). Sans ça le
// buffer speech ne démarrait qu'APRÈS le franchissement du seuil RMS,
// mangeant le début de chaque phrase (bug #1, « 1ʳᵉ phrase ratée »).
val preRoll = ArrayDeque<ShortArray>()
val preRollMax = 3
while (_isListening.value) { while (_isListening.value) {
val read = recorder.read(frame, 0, frameSize) val read = recorder.read(frame, 0, frameSize)
if (read != frameSize) continue if (read != frameSize) continue
@ -1302,13 +1013,16 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|| st is PipelineState.Transcribed || st is PipelineState.Transcribed
|| st is PipelineState.TokenGenerated || st is PipelineState.TokenGenerated
val nowMs = System.currentTimeMillis() val nowMs = System.currentTimeMillis()
// Tail buffer : 800 ms après la fin du Speaking pour // Tail buffer : marge anti-écho après la fin du Speaking. CosyVoice
// absorber la reverb / queue MediaPlayer qui peut continuer // joue via AudioTrack et suspend jusqu'au marker (drain RÉEL des
// à émettre du son alors que pipelineState est passé Idle. // échantillons) — il n'y a plus de queue MediaPlayer à absorber.
// 800 ms (calibré pour l'ancien MediaPlayer) mangeait le début de
// la réponse de l'usager juste après que Kazeia a fini de parler
// (bug #1). 300 ms suffit pour la latence HP/DAC résiduelle.
if (machineBusy) { if (machineBusy) {
lastBusyMs = nowMs lastBusyMs = nowMs
} }
val inTailBuffer = (nowMs - lastBusyMs) < 800 val inTailBuffer = (nowMs - lastBusyMs) < 300
if (machineBusy || inTailBuffer) { if (machineBusy || inTailBuffer) {
if (isSpeechActive) { if (isSpeechActive) {
@ -1354,6 +1068,12 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
if (isSpeech && !inWarmup) { if (isSpeech && !inWarmup) {
silenceFrameCount = 0 silenceFrameCount = 0
if (speechFrameCount == 0 && preRoll.isNotEmpty()) {
// Début d'un candidat speech : injecter l'attaque captée
// juste avant le franchissement du seuil (sinon clippée).
for (pf in preRoll) speechBuffer.add(pf)
preRoll.clear()
}
speechFrameCount++ speechFrameCount++
speechBuffer.add(frame.copyOf()) speechBuffer.add(frame.copyOf())
@ -1459,6 +1179,13 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
// No speech, reset // No speech, reset
speechBuffer.clear() speechBuffer.clear()
speechFrameCount = 0 speechFrameCount = 0
// Alimente le pré-roll (hors warmup) : on garde les
// dernières frames calmes pour préfixer l'attaque de la
// prochaine phrase (bug #1).
if (!inWarmup) {
preRoll.addLast(frame.copyOf())
while (preRoll.size > preRollMax) preRoll.removeFirst()
}
} }
} }
@ -1543,7 +1270,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
"CLEAR_CHAT" -> { "CLEAR_CHAT" -> {
_messages.value = emptyList() _messages.value = emptyList()
// Vide aussi la mémoire conversationnelle du LLM (session engine). // Vide aussi la mémoire conversationnelle du LLM (session engine).
(llm as? com.kazeia.llm.EngineLlmAdapter)?.resetSession() (llm as? com.kazeia.llm.UnifiedLlmAdapter)?.resetSession()
addMessage(ChatMessage( addMessage(ChatMessage(
role = ChatMessage.Role.SYSTEM, role = ChatMessage.Role.SYSTEM,
text = "Conversation effacée." text = "Conversation effacée."
@ -1656,41 +1383,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
@ -1702,6 +1394,8 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
private suspend fun processLlmResponse(patientMessage: String) { private suspend fun processLlmResponse(patientMessage: String) {
_pipelineState.value = PipelineState.Thinking _pipelineState.value = PipelineState.Thinking
lastTurnInput = patientMessage // encart « dernier tour »
lastTurnOutput = ""
conversationManager.onNewTurn() conversationManager.onNewTurn()
// Historique : tour patient persisté avant toute génération (no-op // Historique : tour patient persisté avant toute génération (no-op
@ -1765,9 +1459,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
val result = tE.generateWithSystem( val result = tE.generateWithSystem(
prompt = "Message du patient : $patientMessage", prompt = "Message du patient : $patientMessage",
systemPromptOverride = cfg.thinker.systemPrompt, systemPromptOverride = cfg.thinker.systemPrompt,
params = com.kazeia.core.SamplingParams( params = samplingFrom(cfg.thinker),
maxNewTokens = 128, temperature = cfg.thinker.temperature
),
onToken = null, onToken = null,
tag = "THINKER" tag = "THINKER"
) )
@ -1802,7 +1494,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
// À 4 chars/tok ≈ 200 chars max user message safe avec cascade. // À 4 chars/tok ≈ 200 chars max user message safe avec cascade.
// Si message plus long → on tombe en mono Speaker (pas de bullets). // Si message plus long → on tombe en mono Speaker (pas de bullets).
val speakerSystemPrompt: String? = if (cascadeBullets.isNotBlank() && patientMessage.length <= 200) { val speakerSystemPrompt: String? = if (cascadeBullets.isNotBlank() && patientMessage.length <= 200) {
com.kazeia.llm.ExecuTorchLlmEngine.DEFAULT_SYSTEM_PROMPT + com.kazeia.config.ConfigStore.DEFAULT_SPEAKER_PROMPT +
"\n\nIndices d'analyse interne (NE PAS RÉPÉTER, NE PAS CITER, sert uniquement à orienter le ton de ta réponse) :\n" + "\n\nIndices d'analyse interne (NE PAS RÉPÉTER, NE PAS CITER, sert uniquement à orienter le ton de ta réponse) :\n" +
cascadeBullets cascadeBullets
} else { } else {
@ -1831,19 +1523,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.
@ -1867,6 +1550,9 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
metrics.mark("PIPELINE.first_audio", metrics.mark("PIPELINE.first_audio",
"seg=$segIdx dur=${durationMs}ms") "seg=$segIdx dur=${durationMs}ms")
_pipelineState.value = PipelineState.Speaking _pipelineState.value = PipelineState.Speaking
// 1er son audible → on déverrouille l'interruption (l'usager peut
// désormais couper la réponse). Cf. producingBeforeAudio().
audiblePlayback = true
} }
metrics.mark("TTS.seg.play_start", metrics.mark("TTS.seg.play_start",
"seg=$segIdx sentence=\"${sentence.take(40)}\" dur=${durationMs}ms") "seg=$segIdx sentence=\"${sentence.take(40)}\" dur=${durationMs}ms")
@ -1904,23 +1590,11 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
// as soon as the user finishes speaking, covering the LLM's TTFT. // as soon as the user finishes speaking, covering the LLM's TTFT.
addMessage(bubble) addMessage(bubble)
val llmStreamStart = System.currentTimeMillis() val llmStreamStart = System.currentTimeMillis()
val sentenceCounter = java.util.concurrent.atomic.AtomicInteger(0) // TTS coupé (llmOnly) → pas de session ; la bulle se remplit token par token.
// TTS coupé (llmOnly) → pas de session de synthèse ouverte ; la bulle // CosyVoice (CPU) : pas de streaming LLM→TTS (contention CPU). Le LLM décode
// se remplit token par token dans onToken (voir plus bas). // vite, puis synthèse batch via pipeline.speakText (qui streame déjà
// Streaming LLM→TTS réservé à Qwen3 (NPU/Hexagon : pas de contention CPU avec // phrase-par-phrase EN INTERNE). ttsStreamer reste donc null.
// le LLM). CosyVoice (CPU) passe par le chemin batch (voir note plus haut). val ttsStreamer: com.kazeia.tts.SentenceStreamer? = null
val ttsStreamer: com.kazeia.tts.SentenceStreamer? =
if (qwen != null && !llmOnly) {
qwen.onSegmentPlaying = onSegPlay
qwen.startStreamingSession()
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
@ -1970,17 +1644,15 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
} }
!stoppingCriteria.shouldStop(responseBuilder.toString()) !stoppingCriteria.shouldStop(responseBuilder.toString())
} }
val sParams = SamplingParams( // Sampling Speaker depuis la config (preset admin). maxTokens honoré par
maxNewTokens = 450, // le moteur ; les autres knobs prendront effet avec l'API sampling natif.
temperature = conversationManager.currentTemperature() val sParams = samplingFrom(cfg.speaker)
) // generateWithSystem propage le prompt système Speaker par tour (bullets
// Si bullets cascade dispo → injection dans system prompt via // cascade ou hot-reload admin). UnifiedLlmAdapter le gère pour GGUF
// generateWithSystem (cast vers ExecuTorchLlmEngine). Sinon // (mono-tour si override) comme pour .pte.
// generateWithSystem aussi pour propager le prompt système Speaker
// de la config runtime (hot-reload depuis admin app).
val llmRef = llm val llmRef = llm
val speakerPromptToUse = speakerSystemPrompt ?: cfg.speaker.systemPrompt val speakerPromptToUse = speakerSystemPrompt ?: cfg.speaker.systemPrompt
val result = if (llmRef is com.kazeia.llm.ExecuTorchLlmEngine) { val result = if (llmRef is com.kazeia.llm.UnifiedLlmAdapter) {
llmRef.generateWithSystem( llmRef.generateWithSystem(
prompt = prompt, prompt = prompt,
systemPromptOverride = speakerPromptToUse, systemPromptOverride = speakerPromptToUse,
@ -1997,6 +1669,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
_aiWorkload.value = _aiWorkload.value.copy(llmActive = false) _aiWorkload.value = _aiWorkload.value.copy(llmActive = false)
val responseText = result.text.trim() val responseText = result.text.trim()
lastTurnOutput = responseText // encart « dernier tour »
log("LLM prompt: '$prompt'") log("LLM prompt: '$prompt'")
log("LLM raw response: '${result.text}'") log("LLM raw response: '${result.text}'")
log("LLM clean response: '$responseText'") log("LLM clean response: '$responseText'")
@ -2008,15 +1681,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)
@ -2033,9 +1699,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")
} }
@ -2044,7 +1708,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,
@ -2052,14 +1715,17 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
)) ))
} finally { } finally {
firstSegmentSeen.set(true) firstSegmentSeen.set(true)
audiblePlayback = false // fin de tour : ré-verrouille pour le tour suivant
try { typingJob.cancel() } catch (_: Exception) {} try { typingJob.cancel() } catch (_: Exception) {}
_pipelineState.value = if (_isListening.value) _pipelineState.value = if (_isListening.value)
PipelineState.Listening else PipelineState.Idle PipelineState.Listening else PipelineState.Idle
if (!_isListening.value) { // L'orbe DOIT quitter Thinking/Speaking en fin de tour, y compris en
// écoute continue — sinon il reste figé coloré (« réfléchit » alors
// qu'il a fini). La boucle de capture réémettra Listening(micRms) au
// fil des frames si on écoute encore. (Bug #3, reset jadis conditionnel.)
_visualizerSignal.value = VisualizerSignal.Idle _visualizerSignal.value = VisualizerSignal.Idle
} }
} }
}
private fun addMessage(message: ChatMessage) { private fun addMessage(message: ChatMessage) {
_messages.value = _messages.value + message _messages.value = _messages.value + message
@ -2195,9 +1861,27 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
* libération immédiate. * libération immédiate.
* - Thinker model_id changé en cascade ON release, sera lazy-loadé. * - Thinker model_id changé en cascade ON release, sera lazy-loadé.
*/ */
/** ModelConfig (preset admin) SamplingParams pour la génération. seul
* maxNewTokens est honoré par le moteur natif actuel ; le reste est prêt et
* prendra effet dès que le JNI expose le sampling (docs/SAMPLING_ENGINE_SPEC). */
private fun samplingFrom(m: com.kazeia.config.ConfigStore.ModelConfig) =
com.kazeia.core.SamplingParams(
maxNewTokens = m.maxTokens,
temperature = m.temperature,
topP = m.topP,
topK = m.topK,
repetitionPenalty = m.repeatPenalty,
presencePenalty = m.presencePenalty,
frequencyPenalty = m.frequencyPenalty
)
private fun handleConfigChange(new: com.kazeia.config.ConfigStore.RuntimeConfig) { private fun handleConfigChange(new: com.kazeia.config.ConfigStore.RuntimeConfig) {
val old = runtimeConfig val old = runtimeConfig
runtimeConfig = new runtimeConfig = new
if (new.debugEnabled != old.debugEnabled) {
_debugMode.value = new.debugEnabled // affiche/masque le bouton debug côté patient
log("[CONFIG] debug ${old.debugEnabled}${new.debugEnabled}")
}
log("[CONFIG] applied : cascade ${old.cascadeEnabled}${new.cascadeEnabled}, " + log("[CONFIG] applied : cascade ${old.cascadeEnabled}${new.cascadeEnabled}, " +
"speaker ${old.speaker.modelId}${new.speaker.modelId}, " + "speaker ${old.speaker.modelId}${new.speaker.modelId}, " +
"thinker ${old.thinker.modelId}${new.thinker.modelId}") "thinker ${old.thinker.modelId}${new.thinker.modelId}")

View File

@ -1,104 +0,0 @@
// A/B harness : WhisperHybridEngine (prod) vs SttEngine (Kazeia-Engine lib unifiée)
// sur les mêmes WAVs PCM16 mono 16 kHz. 3 runs/audio, médiane.
// Déclenchement : `touch <files>/bench_qnn.trigger`, relance app.
package com.kazeia.stt
import android.content.Context
import android.util.Log
import kotlinx.coroutines.runBlocking
import java.io.File
object BenchQnnHarness {
private const val TAG = "BenchQnn"
fun runBench(ctx: Context, audioDir: File, modelDir: String) {
val wavs = audioDir.listFiles { _, n -> n.endsWith(".wav") }?.sortedBy { it.name }
?: run { Log.e(TAG, "no WAVs in $audioDir"); return }
// -------- A/ WhisperHybridEngine (prod patché QNN) --------
Log.i(TAG, "=== A/ WhisperHybridEngine (prod) ===")
val prodTimes = mutableMapOf<String, Long>()
val prodTexts = mutableMapOf<String, String>()
run {
val eng = WhisperHybridEngine(nativeLibDir = ctx.applicationInfo.nativeLibraryDir) { Log.i(TAG, it) }
runBlocking { eng.load(modelPath = modelDir) }
if (!eng.isLoaded()) { Log.e(TAG, "WhisperHybridEngine load FAILED"); return@run }
wavs.firstOrNull()?.let { readWavPcm16(it)?.let { p -> runBlocking { eng.transcribe(p, "fr") } } }
for (f in wavs) {
val pcm = readWavPcm16(f) ?: continue
val t = mutableListOf<Long>(); var text = ""
repeat(3) {
val t0 = System.currentTimeMillis()
text = runBlocking { eng.transcribe(pcm, "fr") }.text
t.add(System.currentTimeMillis() - t0)
}
t.sort()
prodTimes[f.name] = t[1]; prodTexts[f.name] = text
Log.i(TAG, "[PROD] ${f.name}: median=${t[1]}ms '${text}'")
}
eng.release()
}
// -------- B/ SttEngine (libkazeia_stt) --------
Log.i(TAG, "=== B/ SttEngine (libkazeia_stt) ===")
val libTimes = mutableMapOf<String, Long>()
val libTexts = mutableMapOf<String, String>()
val libSteps = mutableMapOf<String, Triple<Int, Int, Int>>()
run {
val eng = try { SttEngine(modelDir, useHtp = true, nThreads = 6) }
catch (e: Exception) { Log.e(TAG, "SttEngine load FAILED: ${e.message}"); return@run }
wavs.firstOrNull()?.let { readWavPcm16(it)?.let { p -> try { eng.transcribe(p, "fr") } catch (_: Exception) {} } }
for (f in wavs) {
val pcm = readWavPcm16(f) ?: continue
val t = mutableListOf<Long>(); var r: SttResult? = null
repeat(3) {
val t0 = System.currentTimeMillis()
try { r = eng.transcribe(pcm, "fr") } catch (e: Exception) { Log.e(TAG, "transcribe fail ${f.name}: ${e.message}"); return@repeat }
t.add(System.currentTimeMillis() - t0)
}
if (r == null) continue
t.sort()
val rr = r!!
libTimes[f.name] = t[1]; libTexts[f.name] = rr.text
libSteps[f.name] = Triple(rr.melMs, rr.encoderMs, rr.decoderMs)
Log.i(TAG, "[LIB ] ${f.name}: median=${t[1]}ms mel=${rr.melMs} enc=${rr.encoderMs} dec=${rr.decoderMs} ntok=${rr.nTokens} '${rr.text}'")
}
eng.release()
}
// -------- A/B récap --------
Log.i(TAG, "=== A/B récap ===")
var prodTot = 0L; var libTot = 0L; var audioTot = 0L
var same = 0; var near = 0
for (f in wavs) {
val a = prodTimes[f.name] ?: continue
val b = libTimes[f.name] ?: continue
val durMs = (readWavPcm16(f)?.size ?: 0) * 1000L / 16000
prodTot += a; libTot += b; audioTot += durMs
val ta = (prodTexts[f.name] ?: "").trim().lowercase()
val tb = (libTexts[f.name] ?: "").trim().lowercase()
val isSame = ta == tb
val isNear = !isSame && ta.replace(Regex("\\W+"), "") == tb.replace(Regex("\\W+"), "")
if (isSame) same++ else if (isNear) near++
val mark = if (isSame) "" else if (isNear) "~" else ""
Log.i(TAG, " ${f.name.padEnd(14)} prod=${a}ms lib=${b}ms Δ=${b - a}ms $mark")
Log.i(TAG, " prod='${prodTexts[f.name]}'")
Log.i(TAG, " lib ='${libTexts[f.name]}'")
}
if (audioTot > 0) {
Log.i(TAG, "TOTAL prod=${prodTot}ms (RTF ${"%.3f".format(prodTot.toDouble() / audioTot)}) lib=${libTot}ms (RTF ${"%.3f".format(libTot.toDouble() / audioTot)})")
}
Log.i(TAG, "ACCURACY identique=$same/${wavs.size} proche(ponct.)=$near diff=${wavs.size - same - near}")
Log.i(TAG, "=== Bench end ===")
}
private fun readWavPcm16(f: File): ShortArray? = try {
val b = f.readBytes()
var off = 44
for (i in 12 until b.size - 8)
if (b[i] == 'd'.code.toByte() && b[i+1] == 'a'.code.toByte() &&
b[i+2] == 't'.code.toByte() && b[i+3] == 'a'.code.toByte()) { off = i + 8; break }
val n = (b.size - off) / 2
ShortArray(n) { i -> ((b[off + 2*i].toInt() and 0xFF) or (b[off + 2*i + 1].toInt() shl 8)).toShort() }
} catch (e: Exception) { Log.e(TAG, "wav fail ${f.name}: ${e.message}"); null }
}

View File

@ -1,51 +0,0 @@
package com.kazeia.stt
import com.kazeia.core.SttEngine as CoreSttEngine
import com.kazeia.core.TranscriptionResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Adapter qui expose la lib unifiée Kazeia-Engine STT
* (com.kazeia.stt.SttEngine, façade native de libkazeia_stt.so) derrière
* l'interface prod com.kazeia.core.SttEngine. Sert au swap réversible piloté
* par le flag config `sttEngine` ("prod" | "lib").
*
* Mesuré 2026-06-01, A/B sur 6 audios FR 3 s mono 16k vs WhisperHybridEngine :
* - accuracy 6/6 identique au texte prod
* - RTF global 21 % (gain decodeur-lourd, mel DFT 400 plus cher que prod FFT 512)
* - RAM ~30 % (379 MB vs 545)
* Cf [[project-stt-engine-ab-in-app]] pour le détail.
*/
class KazeiaSttAdapter(
private val modelDir: String,
private val useHtp: Boolean = true,
private val nThreads: Int = 6,
private val onLog: ((String) -> Unit)? = null
) : CoreSttEngine {
private var engine: SttEngine? = null
override suspend fun load(modelPath: String?) = withContext(Dispatchers.IO) {
if (engine != null) return@withContext
val t0 = System.currentTimeMillis()
val dir = modelPath ?: modelDir
engine = SttEngine(dir, useHtp = useHtp, nThreads = nThreads)
onLog?.invoke("KazeiaSttAdapter loaded in ${System.currentTimeMillis() - t0}ms (path=$dir)")
}
override fun isLoaded(): Boolean = engine != null
override suspend fun transcribe(audioData: ShortArray, language: String): TranscriptionResult =
withContext(Dispatchers.IO) {
val e = engine ?: throw IllegalStateException("KazeiaSttAdapter not loaded")
val r = e.transcribe(audioData, language = language)
TranscriptionResult(
text = r.text,
confidence = 1.0f,
language = language,
durationMs = r.totalMs.toLong()
)
}
override fun release() { engine?.release(); engine = null }
}

View File

@ -1,132 +0,0 @@
package com.kazeia.stt
// Engine STT Whisper-Small (Qualcomm AI Hub HfWhisper KV-cache) in-process.
// Backend : ONNX Runtime + QNN ExecutionProvider (HTP V79 sur SM8750).
// Empreinte RAM : ~545 MB (encoder ctx 201 MB + decoder ctx 345 MB).
//
// Bench prod (Whisper-Small, FR, audio 1.6s) : ~825 ms, RTF 0.51.
// Bench port C++ (audio 5s, FR) : ~2000 ms, RTF 0.40 — voir CLAUDE.md/STT.
//
// Le bridge natif est dans dist/jni/kazeia_stt_jni.cpp ; build via
// dist/build_kazeia_stt.sh.
class SttJni {
external fun nativeLoad(
modelDir: String,
useHtp: Boolean,
nThreads: Int,
maxDecodeSteps: Int
): Long
/** Renvoie une chaîne : "err|text|mel_ms|enc_ms|dec_ms|total_ms|n_tokens|rtf" */
external fun nativeTranscribe(
handle: Long,
pcm: ShortArray,
sampleRate: Int,
language: String,
forceTranscribe: Boolean
): String
external fun nativeFree(handle: Long)
external fun nativeVadNew(
sampleRate: Int, frameSize: Int, rmsThreshold: Int,
minSpeechFrames: Int, silenceEndFrames: Int
): Long
/** 0 = silence, 1 = speech in progress, 2 = end_of_speech declenched */
external fun nativeVadPush(handle: Long, pcm: ShortArray): Int
external fun nativeVadReset(handle: Long)
external fun nativeVadFree(handle: Long)
companion object { init { System.loadLibrary("kazeia_stt") } }
}
data class SttResult(
val text: String,
val melMs: Int,
val encoderMs: Int,
val decoderMs: Int,
val totalMs: Int,
val nTokens: Int,
val rtf: Float
)
class SttEngine(
modelDir: String,
useHtp: Boolean = true,
nThreads: Int = 6,
maxDecodeSteps: Int = 200
) {
private val jni = SttJni()
private val h = jni.nativeLoad(modelDir, useHtp, nThreads, maxDecodeSteps)
init { require(h != 0L) { "SttEngine: load FAILED (modelDir=$modelDir, useHtp=$useHtp)" } }
/**
* Transcrit un buffer PCM16 mono. Le sample rate doit être 16000 (resample externe sinon).
* @param language "fr", "en", "auto", ... (cf Whisper langues, 99 codes ISO).
* @param forceTranscribe true -> override <|translate|> en <|transcribe|>.
*/
@Throws(IllegalStateException::class)
fun transcribe(
pcm: ShortArray,
language: String = "fr",
sampleRate: Int = 16000,
forceTranscribe: Boolean = true
): SttResult {
val raw = jni.nativeTranscribe(h, pcm, sampleRate, language, forceTranscribe)
// Format : "err|text|mel|enc|dec|total|n_tokens|rtf"
// limit=8 conserve un éventuel '|' dans le texte (au cas où) dans le 2e champ.
val parts = raw.split('|', limit = 8)
if (parts.size < 8) error("SttEngine.transcribe: format inattendu '$raw'")
val err = parts[0].toInt()
if (err != 0) error("SttEngine.transcribe err=$err raw='$raw'")
return SttResult(
text = parts[1],
melMs = parts[2].toInt(),
encoderMs = parts[3].toInt(),
decoderMs = parts[4].toInt(),
totalMs = parts[5].toInt(),
nTokens = parts[6].toInt(),
rtf = parts[7].toFloat()
)
}
fun release() { jni.nativeFree(h) }
}
/**
* VAD RMS énergie compatible drop-in avec VadStage.kt prod.
* Paramètres par défaut : 16 kHz, frame 100 ms (1600 samples), seuil PCM16 brut 150,
* 3 frames pour déclencher parole (300 ms), 8 frames pour fin (800 ms).
*
* Usage typique :
* val vad = SttVad()
* audioRecord.read(buf) { pcm ->
* when (vad.push(pcm)) {
* SttVad.SILENCE -> { ... }
* SttVad.SPEECH -> { ... // bufferise }
* SttVad.END_OF_SPEECH -> { stt.transcribe(buffer) ; vad.reset() ; buffer.clear() }
* }
* }
*/
class SttVad(
sampleRate: Int = 16000,
frameSize: Int = 1600,
rmsThreshold: Int = 150,
minSpeechFrames: Int = 3,
silenceEndFrames: Int = 8
) {
private val jni = SttJni()
private val h = jni.nativeVadNew(sampleRate, frameSize, rmsThreshold,
minSpeechFrames, silenceEndFrames)
fun push(pcm: ShortArray): Int = jni.nativeVadPush(h, pcm)
fun reset() { jni.nativeVadReset(h) }
fun release() { jni.nativeVadFree(h) }
companion object {
const val SILENCE = 0
const val SPEECH = 1
const val END_OF_SPEECH = 2
}
}

View File

@ -6,6 +6,7 @@ import android.content.UriMatcher
import android.database.Cursor import android.database.Cursor
import android.database.MatrixCursor import android.database.MatrixCursor
import android.net.Uri import android.net.Uri
import android.os.Bundle
import android.os.Debug import android.os.Debug
import android.os.Process import android.os.Process
import java.io.File import java.io.File
@ -42,6 +43,10 @@ class KazeiaTelemetryProvider : ContentProvider() {
const val PATH_RAG = "rag" const val PATH_RAG = "rag"
const val PATH_RAG_STATUS = "rag_status" const val PATH_RAG_STATUS = "rag_status"
const val PATH_RAG_QUERY = "rag_query" const val PATH_RAG_QUERY = "rag_query"
const val PATH_UPDATES = "updates"
// Méthodes ContentProvider.call() pour les MAJ à la demande (pilotées admin).
const val CALL_UPDATE_CHECK = "update_check"
const val CALL_UPDATE_INSTALL = "update_install"
const val ACTION_RELOAD_CONFIG = "com.kazeia.action.RELOAD_CONFIG" const val ACTION_RELOAD_CONFIG = "com.kazeia.action.RELOAD_CONFIG"
const val ACTION_RELOAD_PROFILES = "com.kazeia.action.RELOAD_PROFILES" const val ACTION_RELOAD_PROFILES = "com.kazeia.action.RELOAD_PROFILES"
const val ACTION_RELOAD_RAG = "com.kazeia.action.RELOAD_RAG" const val ACTION_RELOAD_RAG = "com.kazeia.action.RELOAD_RAG"
@ -64,6 +69,7 @@ class KazeiaTelemetryProvider : ContentProvider() {
private const val CODE_RAG_ITEM = 16 private const val CODE_RAG_ITEM = 16
private const val CODE_RAG_STATUS = 17 private const val CODE_RAG_STATUS = 17
private const val CODE_RAG_QUERY = 18 private const val CODE_RAG_QUERY = 18
private const val CODE_UPDATES = 19
// Emplacements canoniques — résolus par KazeiaPaths (legacy /data/local/tmp // Emplacements canoniques — résolus par KazeiaPaths (legacy /data/local/tmp
// sur tablette dev, stockage externe app sinon). Non-const. // sur tablette dev, stockage externe app sinon). Non-const.
@ -73,6 +79,11 @@ class KazeiaTelemetryProvider : ContentProvider() {
get() = "${com.kazeia.KazeiaApplication.MODELS_DIR}/qwen3-tts-npu" get() = "${com.kazeia.KazeiaApplication.MODELS_DIR}/qwen3-tts-npu"
private const val PREFIX_SUFFIX = "_voice_prefix.bin" private const val PREFIX_SUFFIX = "_voice_prefix.bin"
private const val SUFFIX_SUFFIX = "_voice_suffix.bin" private const val SUFFIX_SUFFIX = "_voice_suffix.bin"
// Voix CosyVoice (prod) : artefacts `.cvps` enrôlés. C'est l'inventaire réel
// depuis la bascule ; les `_voice_*.bin` ci-dessus sont des vestiges Qwen3.
private val VOICE_CVPS_DIR: String
get() = "${com.kazeia.KazeiaApplication.MODELS_DIR}/cosyvoice"
private const val CVPS_SUFFIX = ".cvps"
} }
private val matcher = UriMatcher(UriMatcher.NO_MATCH).apply { private val matcher = UriMatcher(UriMatcher.NO_MATCH).apply {
@ -97,6 +108,7 @@ class KazeiaTelemetryProvider : ContentProvider() {
addURI(AUTHORITY, "$PATH_RAG/*", CODE_RAG_ITEM) addURI(AUTHORITY, "$PATH_RAG/*", CODE_RAG_ITEM)
addURI(AUTHORITY, PATH_RAG_STATUS, CODE_RAG_STATUS) addURI(AUTHORITY, PATH_RAG_STATUS, CODE_RAG_STATUS)
addURI(AUTHORITY, PATH_RAG_QUERY, CODE_RAG_QUERY) addURI(AUTHORITY, PATH_RAG_QUERY, CODE_RAG_QUERY)
addURI(AUTHORITY, PATH_UPDATES, CODE_UPDATES)
} }
override fun onCreate(): Boolean = true override fun onCreate(): Boolean = true
@ -124,9 +136,49 @@ class KazeiaTelemetryProvider : ContentProvider() {
CODE_RAG_ITEM -> ragDocCursor(uri.lastPathSegment ?: "") CODE_RAG_ITEM -> ragDocCursor(uri.lastPathSegment ?: "")
CODE_RAG_STATUS -> ragStatusCursor() CODE_RAG_STATUS -> ragStatusCursor()
CODE_RAG_QUERY -> ragQueryCursor(selectionArgs?.getOrNull(0) ?: selection) CODE_RAG_QUERY -> ragQueryCursor(selectionArgs?.getOrNull(0) ?: selection)
CODE_UPDATES -> updatesCursor()
else -> null else -> null
} }
/** État live des mises à jour (lu par l'admin pendant check/install). */
private fun updatesCursor(): Cursor {
val s = com.kazeia.dist.UpdateStatus
val cols = arrayOf(
"phase", "app_update_available", "app_version_name", "app_size",
"content_count", "content_size", "progress_done", "progress_total",
"label", "error", "last_check"
)
return MatrixCursor(cols).apply {
addRow(arrayOf(
s.phase.name,
if (s.appUpdateAvailable) 1 else 0,
s.appVersionName, s.appSize,
s.contentCount, s.contentSize,
s.progressDone, s.progressTotal,
s.label, s.error, s.lastCheck
))
}
}
/** RPC pilotées admin : déclenche un check ou une installation de MAJ. */
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
val ctx = context ?: return null
val mode = when (method) {
CALL_UPDATE_CHECK -> com.kazeia.dist.UpdateWorker.MODE_CHECK
CALL_UPDATE_INSTALL -> com.kazeia.dist.UpdateWorker.MODE_INSTALL
else -> return null
}
val req = androidx.work.OneTimeWorkRequestBuilder<com.kazeia.dist.UpdateWorker>()
.setInputData(androidx.work.workDataOf(com.kazeia.dist.UpdateWorker.KEY_MODE to mode))
.build()
androidx.work.WorkManager.getInstance(ctx).enqueueUniqueWork(
com.kazeia.dist.UpdateWorker.NAME,
androidx.work.ExistingWorkPolicy.REPLACE,
req
)
return Bundle().apply { putBoolean("accepted", true) }
}
private fun ragStatusCursor(): Cursor { private fun ragStatusCursor(): Cursor {
val ctx = context ?: return MatrixCursor(arrayOf<String>()) val ctx = context ?: return MatrixCursor(arrayOf<String>())
val cols = arrayOf("enabled", "ready", "model", "dim", "doc_count", "chunk_count", "pending") val cols = arrayOf("enabled", "ready", "model", "dim", "doc_count", "chunk_count", "pending")
@ -256,23 +308,51 @@ class KazeiaTelemetryProvider : ContentProvider() {
val cols = arrayOf( val cols = arrayOf(
"cascade_enabled", "tts_enabled", "cascade_enabled", "tts_enabled",
"stt_engine", "llm_engine", "tts_engine", "rag_enabled", "stt_engine", "llm_engine", "tts_engine", "rag_enabled",
"rag_threshold", "rag_top_k", "rag_threshold", "rag_top_k", "debug_enabled",
"speaker_model_id", "speaker_system_prompt", "speaker_temperature", "speaker_model_id", "speaker_system_prompt", "speaker_temperature",
"thinker_model_id", "thinker_system_prompt", "thinker_temperature" "speaker_top_p", "speaker_top_k", "speaker_repeat_penalty",
"speaker_presence_penalty", "speaker_frequency_penalty",
"speaker_max_tokens", "speaker_preset_name",
"thinker_model_id", "thinker_system_prompt", "thinker_temperature",
"thinker_top_p", "thinker_top_k", "thinker_repeat_penalty",
"thinker_presence_penalty", "thinker_frequency_penalty",
"thinker_max_tokens", "thinker_preset_name",
"presets_json"
) )
val cursor = MatrixCursor(cols) val cursor = MatrixCursor(cols)
val s = cfg.speaker; val t = cfg.thinker
cursor.addRow(arrayOf( cursor.addRow(arrayOf(
if (cfg.cascadeEnabled) 1 else 0, if (cfg.cascadeEnabled) 1 else 0,
if (cfg.ttsEnabled) 1 else 0, if (cfg.ttsEnabled) 1 else 0,
cfg.sttEngine, cfg.llmEngine, cfg.ttsEngine, cfg.sttEngine, cfg.llmEngine, cfg.ttsEngine,
if (cfg.ragEnabled) 1 else 0, if (cfg.ragEnabled) 1 else 0,
cfg.ragThreshold, cfg.ragTopK, cfg.ragThreshold, cfg.ragTopK,
cfg.speaker.modelId, cfg.speaker.systemPrompt, cfg.speaker.temperature, if (cfg.debugEnabled) 1 else 0,
cfg.thinker.modelId, cfg.thinker.systemPrompt, cfg.thinker.temperature s.modelId, s.systemPrompt, s.temperature,
s.topP, s.topK, s.repeatPenalty, s.presencePenalty, s.frequencyPenalty, s.maxTokens, s.presetName,
t.modelId, t.systemPrompt, t.temperature,
t.topP, t.topK, t.repeatPenalty, t.presencePenalty, t.frequencyPenalty, t.maxTokens, t.presetName,
presetsToJson(cfg.presets)
)) ))
return cursor return cursor
} }
/** Sérialise la bibliothèque de presets en JSON (1 colonne `presets_json`). */
private fun presetsToJson(presets: List<com.kazeia.config.ConfigStore.SamplingPreset>): String {
val arr = org.json.JSONArray()
presets.forEach { p ->
arr.put(org.json.JSONObject().apply {
put("name", p.name); put("temperature", p.temperature)
put("top_p", p.topP); put("top_k", p.topK)
put("repeat_penalty", p.repeatPenalty)
put("presence_penalty", p.presencePenalty)
put("frequency_penalty", p.frequencyPenalty)
put("max_tokens", p.maxTokens)
})
}
return arr.toString()
}
private fun modelsCursor(): Cursor { private fun modelsCursor(): Cursor {
val cols = arrayOf( val cols = arrayOf(
"id", "display_name", "pte_path", "tokenizer_path", "id", "display_name", "pte_path", "tokenizer_path",
@ -361,20 +441,11 @@ class KazeiaTelemetryProvider : ContentProvider() {
values.getAsBoolean("rag_enabled") else cur.ragEnabled, values.getAsBoolean("rag_enabled") else cur.ragEnabled,
ragThreshold = values.getAsFloat("rag_threshold") ?: cur.ragThreshold, ragThreshold = values.getAsFloat("rag_threshold") ?: cur.ragThreshold,
ragTopK = values.getAsInteger("rag_top_k") ?: cur.ragTopK, ragTopK = values.getAsInteger("rag_top_k") ?: cur.ragTopK,
speaker = com.kazeia.config.ConfigStore.ModelConfig( debugEnabled = if (values.containsKey("debug_enabled"))
modelId = values.getAsString("speaker_model_id") ?: cur.speaker.modelId, values.getAsBoolean("debug_enabled") else cur.debugEnabled,
systemPrompt = values.getAsString("speaker_system_prompt") speaker = mergeModel(values, "speaker_", cur.speaker),
?: cur.speaker.systemPrompt, thinker = mergeModel(values, "thinker_", cur.thinker),
temperature = values.getAsFloat("speaker_temperature") presets = values.getAsString("presets_json")?.let { presetsFromJson(it) } ?: cur.presets
?: cur.speaker.temperature
),
thinker = com.kazeia.config.ConfigStore.ModelConfig(
modelId = values.getAsString("thinker_model_id") ?: cur.thinker.modelId,
systemPrompt = values.getAsString("thinker_system_prompt")
?: cur.thinker.systemPrompt,
temperature = values.getAsFloat("thinker_temperature")
?: cur.thinker.temperature
)
) )
store.save(newCfg) store.save(newCfg)
ctx.contentResolver.notifyChange(uri, null) ctx.contentResolver.notifyChange(uri, null)
@ -382,6 +453,42 @@ class KazeiaTelemetryProvider : ContentProvider() {
return 1 return 1
} }
/** Fusionne les colonnes `<prefix>*` d'un ContentValues dans un ModelConfig
* (merge partiel : un champ absent reste inchangé). */
private fun mergeModel(
v: ContentValues, p: String, cur: com.kazeia.config.ConfigStore.ModelConfig
) = com.kazeia.config.ConfigStore.ModelConfig(
modelId = v.getAsString("${p}model_id") ?: cur.modelId,
systemPrompt = v.getAsString("${p}system_prompt") ?: cur.systemPrompt,
temperature = v.getAsFloat("${p}temperature") ?: cur.temperature,
topP = v.getAsFloat("${p}top_p") ?: cur.topP,
topK = v.getAsInteger("${p}top_k") ?: cur.topK,
repeatPenalty = v.getAsFloat("${p}repeat_penalty") ?: cur.repeatPenalty,
presencePenalty = v.getAsFloat("${p}presence_penalty") ?: cur.presencePenalty,
frequencyPenalty = v.getAsFloat("${p}frequency_penalty") ?: cur.frequencyPenalty,
maxTokens = v.getAsInteger("${p}max_tokens") ?: cur.maxTokens,
presetName = v.getAsString("${p}preset_name") ?: cur.presetName
)
private fun presetsFromJson(text: String): List<com.kazeia.config.ConfigStore.SamplingPreset> =
runCatching {
val arr = org.json.JSONArray(text)
(0 until arr.length()).mapNotNull { i ->
arr.optJSONObject(i)?.let { js ->
com.kazeia.config.ConfigStore.SamplingPreset(
name = js.optString("name", ""),
temperature = js.optDouble("temperature", 0.7).toFloat(),
topP = js.optDouble("top_p", 0.85).toFloat(),
topK = js.optInt("top_k", 40),
repeatPenalty = js.optDouble("repeat_penalty", 1.1).toFloat(),
presencePenalty = js.optDouble("presence_penalty", 0.0).toFloat(),
frequencyPenalty = js.optDouble("frequency_penalty", 0.0).toFloat(),
maxTokens = js.optInt("max_tokens", 120)
)
}
}.filter { it.name.isNotBlank() }
}.getOrDefault(emptyList())
private fun upsertProfile(values: ContentValues): Int { private fun upsertProfile(values: ContentValues): Int {
val ctx = context ?: return 0 val ctx = context ?: return 0
val store = com.kazeia.profiles.ProfileStore.get(ctx) val store = com.kazeia.profiles.ProfileStore.get(ctx)
@ -424,26 +531,32 @@ class KazeiaTelemetryProvider : ContentProvider() {
private fun voicesCursor(): Cursor { private fun voicesCursor(): Cursor {
val cols = arrayOf( val cols = arrayOf(
"id", "name", "state", "id", "name", "state",
"wav_exists", "prefix_exists", "suffix_exists", "wav_exists", "prefix_exists", "suffix_exists", "cvps_exists",
"wav_size_bytes", "wav_duration_seconds", "wav_path", "created_at" "wav_size_bytes", "wav_duration_seconds", "wav_path", "created_at"
) )
val cursor = MatrixCursor(cols) val cursor = MatrixCursor(cols)
// Set des ids = union (wav basenames) (prefix bin basenames) // Inventaire = réalité CosyVoice : union (WAV = « à convertir »)
// (`.cvps` = « prête »). Les embeddings Qwen3 (`_voice_*.bin`) NE sont plus
// comptés dans l'union : vestiges morts depuis la bascule, ils faisaient
// apparaître des voix « prête » fantômes sans aucun `.cvps`.
val wavDir = File(VOICE_WAV_DIR) val wavDir = File(VOICE_WAV_DIR)
val embDir = File(VOICE_EMB_DIR) val cvpsDir = File(VOICE_CVPS_DIR)
val wavIds = (wavDir.listFiles { f -> f.name.endsWith(".wav") } ?: emptyArray()) val wavIds = (wavDir.listFiles { f -> f.name.endsWith(".wav") } ?: emptyArray())
.associateBy { it.nameWithoutExtension.lowercase() } .associateBy { it.nameWithoutExtension.lowercase() }
val prefixIds = (embDir.listFiles { f -> f.name.endsWith(PREFIX_SUFFIX) } ?: emptyArray()) val cvpsIds = (cvpsDir.listFiles { f -> f.name.endsWith(CVPS_SUFFIX) } ?: emptyArray())
.associateBy { it.name.removeSuffix(PREFIX_SUFFIX).lowercase() } .associateBy { it.name.removeSuffix(CVPS_SUFFIX).lowercase() }
val ids = (wavIds.keys + prefixIds.keys).sorted() val ids = (wavIds.keys + cvpsIds.keys).sorted()
for (id in ids) { for (id in ids) {
val wav = wavIds[id] val wav = wavIds[id]
val cvps = cvpsIds[id]
val prefix = File(VOICE_EMB_DIR, "${id}$PREFIX_SUFFIX") val prefix = File(VOICE_EMB_DIR, "${id}$PREFIX_SUFFIX")
val suffix = File(VOICE_EMB_DIR, "${id}$SUFFIX_SUFFIX") val suffix = File(VOICE_EMB_DIR, "${id}$SUFFIX_SUFFIX")
// « ready » = `.cvps` déployé (jouable par CosyVoice) ; « recorded » =
// WAV seul (à convertir/enrôler par Kazeia-central) ; sinon « error ».
val state = when { val state = when {
prefix.exists() && suffix.exists() -> "ready" cvps != null -> "ready"
wav != null -> "recorded" wav != null -> "recorded"
else -> "error" else -> "error"
} }
@ -455,10 +568,11 @@ class KazeiaTelemetryProvider : ContentProvider() {
if (wav != null) 1 else 0, if (wav != null) 1 else 0,
if (prefix.exists()) 1 else 0, if (prefix.exists()) 1 else 0,
if (suffix.exists()) 1 else 0, if (suffix.exists()) 1 else 0,
if (cvps != null) 1 else 0,
wav?.length() ?: 0L, wav?.length() ?: 0L,
durSec, durSec,
wav?.absolutePath ?: "", wav?.absolutePath ?: "",
wav?.lastModified() ?: prefix.lastModified() wav?.lastModified() ?: cvps?.lastModified() ?: 0L
)) ))
} }
return cursor return cursor
@ -617,15 +731,15 @@ class KazeiaTelemetryProvider : ContentProvider() {
private fun turnsCursor(): Cursor { private fun turnsCursor(): Cursor {
val cols = arrayOf( val cols = arrayOf(
"timestamp", "input_kind", "status", "total_ms", "timestamp", "input_kind", "status", "total_ms",
"stt_ms", "llm_ttft_ms", "llm_gen_ms", "llm_tokens", "stt_ms", "thinker_ms", "llm_ttft_ms", "llm_gen_ms", "llm_tokens",
"time_to_first_audio_ms", "audio_playback_ms" "tts_ms", "time_to_first_audio_ms", "audio_playback_ms"
) )
val cursor = MatrixCursor(cols) val cursor = MatrixCursor(cols)
for (t in TelemetryHolder.recentTurns()) { for (t in TelemetryHolder.recentTurns()) {
cursor.addRow(arrayOf( cursor.addRow(arrayOf(
t.timestamp, t.inputKind, t.status, t.totalMs, t.timestamp, t.inputKind, t.status, t.totalMs,
t.sttDurationMs, t.llmTtftMs, t.llmGenerationMs, t.llmTokens, t.sttDurationMs, t.thinkerMs, t.llmTtftMs, t.llmGenerationMs, t.llmTokens,
t.timeToFirstAudioMs, t.audioPlaybackMs t.ttsSynthMs, t.timeToFirstAudioMs, t.audioPlaybackMs
)) ))
} }
return cursor return cursor

View File

@ -22,9 +22,11 @@ object TelemetryHolder {
val status: String, // "ok" | "llm_only" | "empty_response" | "error" val status: String, // "ok" | "llm_only" | "empty_response" | "error"
val totalMs: Long, val totalMs: Long,
val sttDurationMs: Long?, val sttDurationMs: Long?,
val llmTtftMs: Long?, val thinkerMs: Long? = null, // cascade Thinker (analyse silencieuse), si actif
val llmGenerationMs: Long?, val llmTtftMs: Long?, // Speaker : temps jusqu'au 1er token
val llmGenerationMs: Long?, // Speaker : génération complète
val llmTokens: Int?, val llmTokens: Int?,
val ttsSynthMs: Long? = null, // TTS : Speaker fini → 1er son (synthèse 1ʳᵉ phrase)
val timeToFirstAudioMs: Long?, val timeToFirstAudioMs: Long?,
val audioPlaybackMs: Long? val audioPlaybackMs: Long?
) )

View File

@ -0,0 +1,133 @@
package com.kazeia.tts
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.ln
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Calcule les features visuelles d'un buffer PCM float mono pour piloter l'orbe
* [com.kazeia.ui.AudioVisualizerView] en mode Speaking :
* - enveloppe RMS (1 valeur par frame, 0..1) pulsation du contour ;
* - spectrogramme [BANDS] bandes log-espacées (0..1) modes de Fourier du bord.
*
* Sans dépendance externe (FFT radix-2 maison). Coût négligeable face à la
* synthèse neuronale CosyVoice (~quelques centaines de FFT de 1024 par phrase).
*
* Remplace l'ancien calcul de l'engine Qwen3 (archivé lors de la bascule
* CosyVoice) ; le callback par-phrase qui l'alimentait avait été perdu, ce qui
* laissait l'orbe figé et masquait le texte (cf. fix bug #2/#3).
*/
object AudioFeatures {
/** Doit rester aligné sur AudioVisualizerView.SPECTRUM_BANDS. */
const val BANDS = 12
/** @return Pair(envelope[nFrames] 0..1, spectrogram[nFrames][BANDS] 0..1). */
fun analyze(
pcm: FloatArray,
sampleRate: Int,
windowMs: Int = 40,
hopMs: Int = 20
): Pair<FloatArray, Array<FloatArray>> {
if (pcm.isEmpty()) return FloatArray(0) to emptyArray()
val hop = (sampleRate * hopMs / 1000).coerceAtLeast(1)
var win = 1
val want = (sampleRate * windowMs / 1000).coerceAtLeast(2)
while (win < want) win = win shl 1
val half = win / 2
val nFrames = ((pcm.size - 1) / hop) + 1
if (nFrames <= 0) return FloatArray(0) to emptyArray()
// Bornes de bandes log-espacées sur les bins [1 .. half].
val edges = IntArray(BANDS + 1)
val lnLo = ln(1.0); val lnHi = ln(half.toDouble())
for (b in 0..BANDS) {
val frac = b.toFloat() / BANDS
edges[b] = Math.exp(lnLo + frac * (lnHi - lnLo)).toInt().coerceIn(1, half)
}
val hann = DoubleArray(win) { 0.5 - 0.5 * cos(2.0 * PI * it / (win - 1)) }
val re = DoubleArray(win)
val im = DoubleArray(win)
val envelope = FloatArray(nFrames)
val spectro = Array(nFrames) { FloatArray(BANDS) }
var envMax = 1e-6f
var bandMax = 1e-6f
for (f in 0 until nFrames) {
val start = f * hop
var sumSq = 0.0; var count = 0
for (i in 0 until win) {
val idx = start + i
val x = if (idx < pcm.size) pcm[idx].toDouble() else 0.0
re[i] = x * hann[i]; im[i] = 0.0
if (idx < pcm.size) { sumSq += x * x; count++ }
}
val rms = if (count > 0) sqrt(sumSq / count).toFloat() else 0f
envelope[f] = rms
if (rms > envMax) envMax = rms
fft(re, im)
val row = spectro[f]
for (b in 0 until BANDS) {
val k0 = edges[b]
val k1 = edges[b + 1].coerceAtLeast(k0 + 1)
var acc = 0.0; var n = 0
var k = k0
while (k < k1 && k <= half) {
acc += sqrt(re[k] * re[k] + im[k] * im[k]); n++; k++
}
val v = if (n > 0) (acc / n).toFloat() else 0f
row[b] = v
if (v > bandMax) bandMax = v
}
}
val envInv = 1f / envMax
val bInv = 1f / bandMax
for (f in 0 until nFrames) {
envelope[f] = (envelope[f] * envInv).coerceIn(0f, 1f)
val row = spectro[f]
for (b in 0 until BANDS) row[b] = (row[b] * bInv).coerceIn(0f, 1f)
}
return envelope to spectro
}
/** FFT radix-2 in-place (Cooley-Tukey), `re.size` doit être une puissance de 2. */
private fun fft(re: DoubleArray, im: DoubleArray) {
val n = re.size
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
j = j or bit
if (i < j) {
val tr = re[i]; re[i] = re[j]; re[j] = tr
val ti = im[i]; im[i] = im[j]; im[j] = ti
}
}
var len = 2
while (len <= n) {
val ang = -2.0 * PI / len
val wlr = cos(ang); val wli = sin(ang)
var i = 0
while (i < n) {
var wr = 1.0; var wi = 0.0
val halfLen = len / 2
for (k in 0 until halfLen) {
val ur = re[i + k]; val ui = im[i + k]
val a = re[i + k + halfLen]; val b = im[i + k + halfLen]
val vr = a * wr - b * wi
val vi = a * wi + b * wr
re[i + k] = ur + vr; im[i + k] = ui + vi
re[i + k + halfLen] = ur - vr; im[i + k + halfLen] = ui - vi
val nwr = wr * wlr - wi * wli
wi = wr * wli + wi * wlr; wr = nwr
}
i += len
}
len = len shl 1
}
}
}

View File

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

View File

@ -19,7 +19,8 @@ import android.media.AudioTrack
import com.kazeia.core.TtsEngine import com.kazeia.core.TtsEngine
import com.kazeia.core.TtsResult import com.kazeia.core.TtsResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import kotlin.coroutines.resume import kotlin.coroutines.resume
@ -58,6 +59,10 @@ class CosyVoiceTtsEngine(
private val voices = HashMap<String, Long>() private val voices = HashMap<String, Long>()
@Volatile private var currentVoiceId: String = defaultVoiceId @Volatile private var currentVoiceId: String = defaultVoiceId
@Volatile private var audioTrack: AudioTrack? = null @Volatile private var audioTrack: AudioTrack? = null
// Sérialise nativeSynthesize : le pré-chauffage (warmup) et une vraie synthèse
// ne doivent jamais tourner en parallèle sur le même handle voix.
private val synthMutex = Mutex()
@Volatile private var warmedVoices = HashSet<String>()
private fun base(): String = com.kazeia.KazeiaApplication.MODELS_DIR private fun base(): String = com.kazeia.KazeiaApplication.MODELS_DIR
private fun cvpsPath(voiceId: String): String = "${base()}/cosyvoice/$voiceId.cvps" private fun cvpsPath(voiceId: String): String = "${base()}/cosyvoice/$voiceId.cvps"
@ -104,8 +109,18 @@ class CosyVoiceTtsEngine(
private fun voiceHandleOrThrow(): Long { private fun voiceHandleOrThrow(): Long {
require(handle != 0L) { "CosyVoice: not loaded" } require(handle != 0L) { "CosyVoice: not loaded" }
val v = voices[currentVoiceId] ?: ensureVoice(currentVoiceId) var v = voices[currentVoiceId] ?: ensureVoice(currentVoiceId)
require(v != 0L) { "CosyVoice: aucune voix chargée ('$currentVoiceId')" } // Repli gracieux : une voix configurée mais non déployée (.cvps absent) ne
// doit JAMAIS rendre le TTS muet — on bascule sur la voix par défaut
// (toujours livrée). Sinon un profil pointant une voix manquante coupe
// toute parole en silence (cf. incident "zelda" non déployé).
if (v == 0L && currentVoiceId != defaultVoiceId) {
log("voix '$currentVoiceId' indisponible → repli sur défaut '$defaultVoiceId'")
v = voices[defaultVoiceId] ?: ensureVoice(defaultVoiceId)
}
require(v != 0L) {
"CosyVoice: aucune voix chargée ('$currentVoiceId' ni défaut '$defaultVoiceId')"
}
return v return v
} }
@ -114,31 +129,45 @@ class CosyVoiceTtsEngine(
val v = voiceHandleOrThrow() val v = voiceHandleOrThrow()
val out = ArrayList<Float>() val out = ArrayList<Float>()
for (s in sentences(text)) { for (s in sentences(text)) {
val pcm = CosyVoiceJni.nativeSynthesize(v, s, 1.0f) ?: continue val pcm = synthMutex.withLock { CosyVoiceJni.nativeSynthesize(v, s, 1.0f) } ?: continue
pcm.forEach { out.add(it) } pcm.forEach { out.add(it) }
} }
val shorts = ShortArray(out.size) { floatToPcm16(out[it]) } val shorts = ShortArray(out.size) { floatToPcm16(out[it]) }
TtsResult(audioData = shorts, sampleRate = SR, durationMs = shorts.size * 1000L / SR) TtsResult(audioData = shorts, sampleRate = SR, durationMs = shorts.size * 1000L / SR)
} }
override suspend fun synthesizeAndPlay( /**
text: String, * Pré-chauffe le cache K/V du prompt voix : la 1ʳᵉ synthèse d'une voix remplit
language: String, * ce cache (~70 % du coût flow), d' un 1er énoncé lent (RTF ~1.9, mesuré
onStart: (() -> Unit)?, * 1085 s à mesure que le cache chauffe), puis RTF ~1. En synthétisant une
onComplete: (() -> Unit)? * phrase courte JETÉE en tâche de fond au démarrage, le 1er VRAI tour part déjà
) { * chaud. Idempotent par voix ; sérialisé par synthMutex (pas de course avec une
val v = voiceHandleOrThrow() * vraie synthèse). Best-effort : un échec ne casse rien.
val segs = sentences(text) */
if (segs.isEmpty()) { onComplete?.invoke(); return } suspend fun warmup(voiceId: String? = null) {
if (handle == 0L) return
val vid = voiceId ?: currentVoiceId
if (vid in warmedVoices) return
val v = voices[vid] ?: ensureVoice(vid)
if (v == 0L) return
withContext(Dispatchers.Default) {
val t0 = System.currentTimeMillis()
val ok = synthMutex.withLock {
runCatching { CosyVoiceJni.nativeSynthesize(v, "Bonjour, je suis là.", 1.0f) }.isSuccess
}
if (ok) { warmedVoices.add(vid); log("warmup voix '$vid' OK (${System.currentTimeMillis() - t0}ms)") }
}
}
// AudioTrack flottant 24 kHz mono en MODE_STREAM : on commence à jouer dès /** Construit un AudioTrack 24 kHz mono float MODE_STREAM. Renvoie null (et
// la 1ʳᵉ phrase synthétisée pendant que la suivante se génère (RTF>1 -> le * libère) si le HAL le rend NON initialisé, pour permettre un retry au lieu
// streaming masque la latence). Suspend jusqu'à la FIN réelle (marker) pour * d'un play() qui lèverait. */
// que le micro reste coupé pendant toute la lecture (anti-écho, cf KazeiaTts). private fun buildTrackOrNull(): AudioTrack? {
val minBuf = AudioTrack.getMinBufferSize( val minBuf = AudioTrack.getMinBufferSize(
SR, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_FLOAT SR, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_FLOAT
).coerceAtLeast(SR * 4) // ~2 s de marge mono float ).coerceAtLeast(SR * 4) // ~2 s de marge mono float
val track = AudioTrack.Builder() val t = try {
AudioTrack.Builder()
.setAudioAttributes(AudioAttributes.Builder() .setAudioAttributes(AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA) .setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
@ -151,6 +180,43 @@ class CosyVoiceTtsEngine(
.setBufferSizeInBytes(minBuf) .setBufferSizeInBytes(minBuf)
.setTransferMode(AudioTrack.MODE_STREAM) .setTransferMode(AudioTrack.MODE_STREAM)
.build() .build()
} catch (e: Throwable) {
log("AudioTrack build a échoué: ${e.message}"); return null
}
if (t.state != AudioTrack.STATE_INITIALIZED) {
try { t.release() } catch (_: Exception) {}
return null
}
return t
}
override suspend fun synthesizeAndPlay(
text: String,
language: String,
onStart: (() -> Unit)?,
onComplete: (() -> Unit)?,
onSegment: ((String, Long, FloatArray, Array<FloatArray>) -> Unit)?
) {
val v = voiceHandleOrThrow()
val segs = sentences(text)
if (segs.isEmpty()) { onComplete?.invoke(); return }
// AudioTrack flottant 24 kHz mono en MODE_STREAM : on commence à jouer dès
// la 1ʳᵉ phrase synthétisée pendant que la suivante se génère (RTF>1 -> le
// streaming masque la latence). Suspend jusqu'à la FIN réelle (marker) pour
// que le micro reste coupé pendant toute la lecture (anti-écho, cf KazeiaTts).
// Le HAL audio peut rendre un AudioTrack NON initialisé quand on le crée
// juste après une interruption TTS + réouverture micro (sortie en
// transition) → play() lèverait « uninitialized AudioTrack » et la réponse
// serait muette. On valide STATE_INITIALIZED et on retente brièvement.
val track = buildTrackOrNull() ?: run {
kotlinx.coroutines.delay(120)
buildTrackOrNull()
}
if (track == null) {
log("AudioTrack non initialisé (HAL occupé) après retry — réponse muette")
onComplete?.invoke(); return
}
audioTrack = track audioTrack = track
var totalFrames = 0 var totalFrames = 0
@ -159,14 +225,35 @@ class CosyVoiceTtsEngine(
try { try {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
for ((i, s) in segs.withIndex()) { for ((i, s) in segs.withIndex()) {
val pcm = CosyVoiceJni.nativeSynthesize(v, s, 1.0f) // Interruption (l'usager re-tape le micro pendant la synthèse) :
// stop() libère le track et met audioTrack=null. On le détecte par
// identité et on abandonne PROPREMENT, au lieu d'appeler play()/write
// sur un track mort → « play() on uninitialized AudioTrack ».
if (audioTrack !== track) { log("TTS interrompu pendant la synthèse — abandon"); break }
val pcm = synthMutex.withLock { CosyVoiceJni.nativeSynthesize(v, s, 1.0f) }
if (pcm == null || pcm.isEmpty()) continue if (pcm == null || pcm.isEmpty()) continue
if (audioTrack !== track) { log("TTS interrompu — pas de lecture"); break }
if (!started) { if (!started) {
started = true started = true
onStart?.invoke() onStart?.invoke()
track.play() try { track.play() } catch (e: Throwable) {
log("play() impossible (interrompu ?): ${e.message}"); break
}
log("1er son à ${System.currentTimeMillis() - t0}ms (phrase 1/${segs.size})") log("1er son à ${System.currentTimeMillis() - t0}ms (phrase 1/${segs.size})")
} }
// Callback par-phrase : la phrase précédente a drainé (write
// bloquant ci-dessous), donc on est ~au démarrage audio de
// celle-ci. Pilote le reveal texte + l'orbe. Best-effort :
// une erreur de features ne casse pas la lecture.
onSegment?.let { cb ->
val durMs = pcm.size * 1000L / SR
val (env, spec) = try {
AudioFeatures.analyze(pcm, SR)
} catch (e: Throwable) {
log("features err: ${e.message}"); FloatArray(0) to emptyArray()
}
if (env.isNotEmpty() && spec.isNotEmpty()) cb(s, durMs, env, spec)
}
// write bloquant (MODE_STREAM) : régule le débit sur la lecture. // write bloquant (MODE_STREAM) : régule le débit sur la lecture.
var off = 0 var off = 0
while (off < pcm.size) { while (off < pcm.size) {
@ -183,17 +270,16 @@ class CosyVoiceTtsEngine(
if (!started || totalFrames == 0) { stopInternal(track); onComplete?.invoke(); return } if (!started || totalFrames == 0) { stopInternal(track); onComplete?.invoke(); return }
// Attend le drain réel des échantillons écrits avant de rendre la main. // Drain DÉTERMINISTE : à ce point tout le PCM est écrit, mais les writes
suspendCancellableCoroutine<Unit> { cont -> // bloquants laissent ~1 buffer (≈1 s) ENCORE non joué. On lit la position
cont.invokeOnCancellation { stopInternal(track) } // de lecture (sans Looper — onMarkerReached ne se déclenche pas sur
track.setNotificationMarkerPosition(totalFrames) // Dispatchers.Default) pour savoir combien il reste, et on attend EXACTEMENT
track.setPlaybackPositionUpdateListener(object : AudioTrack.OnPlaybackPositionUpdateListener { // cette durée avant de libérer. PAS de stop()/flush avant la fin (ça
override fun onMarkerReached(t: AudioTrack?) { if (cont.isActive) cont.resume(Unit) } // jetterait le buffer → phrase coupée). Au plus près de la dernière syllabe,
override fun onPeriodicNotification(t: AudioTrack?) {} // sans queue morte ni troncature.
}) val played = try { track.playbackHeadPosition } catch (_: Exception) { totalFrames }
// MODE_STREAM : stop() laisse drainer ce qui est déjà écrit puis atteint le marker. val remainMs = ((totalFrames - played).coerceAtLeast(0)) * 1000L / SR
try { track.stop() } catch (_: Exception) {} if (remainMs > 0) kotlinx.coroutines.delay(remainMs + 150L) // laisse jouer le reste
}
stopInternal(track) stopInternal(track)
onComplete?.invoke() onComplete?.invoke()
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -53,7 +53,7 @@ class AudioVisualizerView @JvmOverloads constructor(
) : View(context, attrs, defStyleAttr), Choreographer.FrameCallback { ) : View(context, attrs, defStyleAttr), Choreographer.FrameCallback {
companion object { companion object {
/** Must match Qwen3TtsEngine.SPECTRUM_BANDS. Asserted at setSpeaking. */ /** Nombre de bandes de spectre de l'orbe (visualiseur). */
private const val SPECTRUM_BANDS = 12 private const val SPECTRUM_BANDS = 12
/** Listening-mode outline deformation modes (even = smooth blobs). */ /** Listening-mode outline deformation modes (even = smooth blobs). */
private const val BLOB_MODES = 8 private const val BLOB_MODES = 8

View File

@ -181,7 +181,6 @@ class ChatActivity : AppCompatActivity() {
private fun setupResourceMonitoring() { private fun setupResourceMonitoring() {
val graphCpu = findViewById<MiniGraphView>(R.id.graphCpu) val graphCpu = findViewById<MiniGraphView>(R.id.graphCpu)
val graphGpu = findViewById<MiniGraphView>(R.id.graphGpu)
val graphNpu = findViewById<MiniGraphView>(R.id.graphNpu) val graphNpu = findViewById<MiniGraphView>(R.id.graphNpu)
val graphRam = findViewById<MiniGraphView>(R.id.graphRam) val graphRam = findViewById<MiniGraphView>(R.id.graphRam)
@ -189,7 +188,6 @@ class ChatActivity : AppCompatActivity() {
val ramTotal = resourceMonitor!!.snapshot().ramTotalMb.toFloat() val ramTotal = resourceMonitor!!.snapshot().ramTotalMb.toFloat()
graphCpu?.configure("CPU", "%", 100f, android.graphics.Color.parseColor("#4CAF50")) graphCpu?.configure("CPU", "%", 100f, android.graphics.Color.parseColor("#4CAF50"))
graphGpu?.configure("GPU", "%", 100f, android.graphics.Color.parseColor("#2196F3"))
graphNpu?.configure("AI", "%", 100f, android.graphics.Color.parseColor("#FF9800")) graphNpu?.configure("AI", "%", 100f, android.graphics.Color.parseColor("#FF9800"))
// Affiche la conso PSS de Kazeia uniquement (~2 GB idle, ~5.5 GB pic). // Affiche la conso PSS de Kazeia uniquement (~2 GB idle, ~5.5 GB pic).
// Pas la RAM système globale (qui inclut ~3 GB d'OS Android dont on // Pas la RAM système globale (qui inclut ~3 GB d'OS Android dont on
@ -201,7 +199,6 @@ class ChatActivity : AppCompatActivity() {
while (true) { while (true) {
val snap = resourceMonitor!!.snapshot() val snap = resourceMonitor!!.snapshot()
graphCpu?.addValue(snap.cpuPercent) graphCpu?.addValue(snap.cpuPercent)
graphGpu?.addValue(if (snap.gpuPercent >= 0) snap.gpuPercent else 0f)
// AI workload: show which AI component is active // AI workload: show which AI component is active
val workload = kazeiaService?.aiWorkload?.value val workload = kazeiaService?.aiWorkload?.value
@ -319,8 +316,19 @@ class ChatActivity : AppCompatActivity() {
} }
} }
launch { launch {
// Encart « dernier tour » PERSISTANT (remplace le graph GPU) :
// input · output · temps par étape.
service.lastTurnPanel.collect { panel ->
findViewById<android.widget.TextView>(R.id.tvLastTurn)?.text = panel
}
}
launch {
// debugMode (piloté par l'admin via ConfigStore.debugEnabled) gate le
// BOUTON debug. Si off : bouton masqué + panneau forcé fermé (écran
// patient épuré). Si on : bouton visible, le patient/soignant ouvre le
// panneau logs+métriques via le bouton.
service.debugMode.collect { debug -> service.debugMode.collect { debug ->
setDebugPanelVisible(debug) applyDebugGate(debug)
} }
} }
launch { launch {
@ -404,6 +412,19 @@ class ChatActivity : AppCompatActivity() {
} }
} }
/** Mode debug (ConfigStore.debugEnabled, piloté admin) : affiche/masque le bouton
* qui ouvre le panneau logs+métriques. Off bouton caché + panneau forcé fermé. */
private fun applyDebugGate(enabled: Boolean) {
runOnUiThread {
findViewById<View>(R.id.btnDebugToggle)?.visibility =
if (enabled) View.VISIBLE else View.GONE
if (!enabled) {
debugPanelVisible = false
setDebugPanelVisible(false)
}
}
}
private var logFile: java.io.FileWriter? = null private var logFile: java.io.FileWriter? = null
private fun appendLog(msg: String) { private fun appendLog(msg: String) {

View File

@ -78,30 +78,27 @@ class OnboardingActivity : AppCompatActivity() {
/** Point d'entrée : récupère le catalogue puis décide de la suite. */ /** Point d'entrée : récupère le catalogue puis décide de la suite. */
private fun begin() { private fun begin() {
resetUi() resetUi()
tvStatus.text = "Vérification du catalogue…" // Les mises à jour sont désormais MANUELLES (pilotées depuis l'app admin :
// écran « Mises à jour », bouton Vérifier/Installer). Au lancement on ne
// consulte plus le catalogue ni l'APK : si le cœur est présent, on démarre
// directement (offline, zéro auto-update). Le catalogue n'est sollicité que
// pour la PREMIÈRE installation (cœur absent) ou un re-provisionnement forcé.
if (!force && manager.hasCoreModels()) {
Log.i(TAG, "modèles présents → Splash (pas d'auto-update)")
goToSplash()
return
}
tvStatus.text = "Première installation…"
lifecycleScope.launch { lifecycleScope.launch {
val catalog = withContext(Dispatchers.IO) { manager.fetchCatalog() } val catalog = withContext(Dispatchers.IO) { manager.fetchCatalog() }
if (catalog == null) { if (catalog == null) {
// Hors-ligne : un install qui tourne déjà peut continuer. if (!force && manager.hasCoreModels()) goToSplash()
if (!force && manager.hasCoreModels()) { else showError("Connexion requise pour la première installation.\n" +
Log.i(TAG, "catalogue injoignable mais modèles présents → Splash")
goToSplash()
} else {
showError("Connexion requise pour la première installation.\n" +
"Vérifiez le réseau puis réessayez.") "Vérifiez le réseau puis réessayez.")
}
return@launch return@launch
} }
continueWithModels(catalog) // télécharge les modèles manquants (sans prompt APK)
// 1. Mise à jour de l'app elle-même.
val update = apkUpdater.availableUpdate(catalog)
if (update != null) {
promptApkUpdate(update, catalog)
return@launch
}
continueWithModels(catalog)
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,201 +0,0 @@
#include <jni.h>
#include <string>
#include <android/log.h>
#define TAG "GenieJNI"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
// Genie C API declarations (from libGenie.so)
extern "C" {
// Opaque types
typedef void* GenieDialogConfig;
typedef void* GenieDialog;
typedef void* GenieTokenizer;
typedef void* GenieSampler;
// Version
int Genie_getApiMajorVersion();
int Genie_getApiMinorVersion();
int Genie_getApiPatchVersion();
// DialogConfig
GenieDialogConfig GenieDialogConfig_createFromJson(const char* jsonPath);
void GenieDialogConfig_free(GenieDialogConfig config);
// Dialog
GenieDialog GenieDialog_create(GenieDialogConfig config);
void GenieDialog_free(GenieDialog dialog);
const char* GenieDialog_query(GenieDialog dialog, const char* prompt);
void GenieDialog_setStopSequence(GenieDialog dialog, const char* stopSeq);
void GenieDialog_reset(GenieDialog dialog);
void GenieDialog_signal(GenieDialog dialog);
GenieTokenizer GenieDialog_getTokenizer(GenieDialog dialog);
GenieSampler GenieDialog_getSampler(GenieDialog dialog);
// Sampler callback
typedef bool (*GenieSamplerCallback)(const char* token, void* userData);
void GenieSampler_registerUserDataCallback(
GenieSampler sampler,
GenieSamplerCallback callback,
void* userData
);
// Tokenizer
const char* GenieTokenizer_decode(GenieTokenizer tokenizer, const int* tokens, int numTokens);
int* GenieTokenizer_encode(GenieTokenizer tokenizer, const char* text, int* numTokens);
}
// Check if a pointer looks like a valid heap pointer (not an error code)
// Genie SDK returns small negative int values as error codes (e.g. -5 = 0xfffffffb)
// On ARM64 Android, valid heap pointers are always > 0x100000000 (above 4GB)
static bool isValidPointer(void* ptr) {
uintptr_t addr = reinterpret_cast<uintptr_t>(ptr);
if (ptr == nullptr) return false;
// Any value that fits in 32 bits is likely an error code, not a heap pointer
if (addr <= 0xFFFFFFFFULL) return false;
return true;
}
// Callback context for token streaming
struct CallbackContext {
JNIEnv* env;
jobject callback;
jmethodID onTokenMethod;
std::string fullResponse;
bool shouldStop;
};
static bool samplerCallback(const char* token, void* userData) {
auto* ctx = reinterpret_cast<CallbackContext*>(userData);
if (ctx->shouldStop || token == nullptr) return false;
ctx->fullResponse += token;
if (ctx->callback != nullptr) {
JNIEnv* env = ctx->env;
jstring jToken = env->NewStringUTF(token);
jboolean continueGen = env->CallBooleanMethod(
ctx->callback, ctx->onTokenMethod, jToken
);
env->DeleteLocalRef(jToken);
if (!continueGen) {
ctx->shouldStop = true;
return false;
}
}
return true;
}
extern "C" {
JNIEXPORT jlong JNICALL
Java_com_kazeia_llm_GenieJni_createDialog(JNIEnv* env, jobject, jstring configPath) {
const char* path = env->GetStringUTFChars(configPath, nullptr);
LOGI("Creating dialog from config: %s", path);
GenieDialogConfig config = GenieDialogConfig_createFromJson(path);
env->ReleaseStringUTFChars(configPath, path);
if (!isValidPointer(config)) {
LOGE("Failed to create dialog config (returned %p, likely error code %ld)",
config, reinterpret_cast<long>(config));
return 0;
}
LOGI("Dialog config created: %p", config);
GenieDialog dialog = GenieDialog_create(config);
GenieDialogConfig_free(config);
if (!isValidPointer(dialog)) {
LOGE("Failed to create dialog (returned %p, likely error code %ld)",
dialog, reinterpret_cast<long>(dialog));
return 0;
}
LOGI("Dialog created successfully: %p", dialog);
return reinterpret_cast<jlong>(dialog);
}
JNIEXPORT jstring JNICALL
Java_com_kazeia_llm_GenieJni_query(
JNIEnv* env, jobject, jlong dialogHandle, jstring prompt, jobject callback
) {
auto* dialog = reinterpret_cast<GenieDialog>(dialogHandle);
if (!isValidPointer(dialog)) {
LOGE("Invalid dialog handle: %p", dialog);
return env->NewStringUTF("[Erreur: modèle LLM non chargé]");
}
const char* promptStr = env->GetStringUTFChars(prompt, nullptr);
CallbackContext ctx;
ctx.env = env;
ctx.callback = callback;
ctx.shouldStop = false;
if (callback != nullptr) {
jclass cbClass = env->GetObjectClass(callback);
ctx.onTokenMethod = env->GetMethodID(
cbClass, "onToken", "(Ljava/lang/String;)Z"
);
// Register the sampler callback
GenieSampler sampler = GenieDialog_getSampler(dialog);
if (isValidPointer(sampler)) {
GenieSampler_registerUserDataCallback(sampler, samplerCallback, &ctx);
} else {
LOGE("Invalid sampler pointer: %p", sampler);
}
}
LOGI("Query: %.80s...", promptStr);
const char* response = GenieDialog_query(dialog, promptStr);
env->ReleaseStringUTFChars(prompt, promptStr);
// Use callback accumulated response if available, otherwise use direct response
std::string result;
if (!ctx.fullResponse.empty()) {
result = ctx.fullResponse;
} else if (response != nullptr) {
result = response;
}
LOGI("Response length: %zu chars", result.size());
return env->NewStringUTF(result.c_str());
}
JNIEXPORT void JNICALL
Java_com_kazeia_llm_GenieJni_setStopSequence(
JNIEnv* env, jobject, jlong dialogHandle, jstring stopSequence
) {
auto* dialog = reinterpret_cast<GenieDialog>(dialogHandle);
if (!isValidPointer(dialog)) return;
const char* seq = env->GetStringUTFChars(stopSequence, nullptr);
GenieDialog_setStopSequence(dialog, seq);
env->ReleaseStringUTFChars(stopSequence, seq);
}
JNIEXPORT void JNICALL
Java_com_kazeia_llm_GenieJni_freeDialog(JNIEnv*, jobject, jlong dialogHandle) {
auto* dialog = reinterpret_cast<GenieDialog>(dialogHandle);
if (isValidPointer(dialog)) {
GenieDialog_free(dialog);
LOGI("Dialog freed");
}
}
JNIEXPORT jstring JNICALL
Java_com_kazeia_llm_GenieJni_getVersion(JNIEnv* env, jobject) {
int major = Genie_getApiMajorVersion();
int minor = Genie_getApiMinorVersion();
int patch = Genie_getApiPatchVersion();
std::string version = std::to_string(major) + "." +
std::to_string(minor) + "." +
std::to_string(patch);
return env->NewStringUTF(version.c_str());
}
} // extern "C"

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Fond adaptatif Kazeia : dégradé radial violet profond (donne de la
profondeur derrière l'orbe). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M0,0 L108,0 L108,108 L0,108 Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="54"
android:centerY="46"
android:gradientRadius="82">
<item android:offset="0" android:color="#FF50398A" />
<item android:offset="0.55" android:color="#FF3A2B6B" />
<item android:offset="1" android:color="#FF221A3D" />
</gradient>
</aapt:attr>
</path>
</vector>

View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Avant-plan adaptatif Kazeia : l'orbe (la « face » de Kazeia, cf.
AudioVisualizerView). Halo lumineux + sphère lavande éclairée en haut-gauche
+ reflet spéculaire. Tout reste dans la zone sûre 72dp (rayon ≤ 36 du centre,
hors halo qui s'estompe). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Halo diffus -->
<path android:pathData="M12,54 a42,42 0 1,0 84,0 a42,42 0 1,0 -84,0">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="54"
android:centerY="54"
android:gradientRadius="42">
<item android:offset="0" android:color="#00BCA4E8" />
<item android:offset="0.78" android:color="#00BCA4E8" />
<item android:offset="0.9" android:color="#5CCBB6F0" />
<item android:offset="1" android:color="#00BCA4E8" />
</gradient>
</aapt:attr>
</path>
<!-- Sphère de l'orbe (rayon 33) -->
<path android:pathData="M21,54 a33,33 0 1,0 66,0 a33,33 0 1,0 -66,0">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="42"
android:centerY="40"
android:gradientRadius="50">
<item android:offset="0" android:color="#FFF6F1FF" />
<item android:offset="0.32" android:color="#FFC9B6F0" />
<item android:offset="0.66" android:color="#FF9879D8" />
<item android:offset="1" android:color="#FF6243A0" />
</gradient>
</aapt:attr>
</path>
<!-- Reflet spéculaire (point lumineux haut-gauche) -->
<path android:pathData="M35,39 a7,7 0 1,0 14,0 a7,7 0 1,0 -14,0">
<aapt:attr name="android:fillColor">
<gradient
android:type="radial"
android:centerX="42"
android:centerY="39"
android:gradientRadius="7">
<item android:offset="0" android:color="#E6FFFFFF" />
<item android:offset="1" android:color="#00FFFFFF" />
</gradient>
</aapt:attr>
</path>
</vector>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Icône thématisée (Android 13+) : silhouette de l'orbe, teintée par le système.
Sphère pleine + petit reflet évidé pour garder la lecture « orbe ». -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFFFF"
android:fillType="evenOdd"
android:pathData="M21,54 a33,33 0 1,0 66,0 a33,33 0 1,0 -66,0 Z M36,40 a6,6 0 1,0 12,0 a6,6 0 1,0 -12,0 Z" />
</vector>

View File

@ -39,6 +39,7 @@
android:src="@android:drawable/ic_menu_manage" android:src="@android:drawable/ic_menu_manage"
android:background="?attr/selectableItemBackgroundBorderless" android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Debug" android:contentDescription="Debug"
android:visibility="gone"
android:tint="@android:color/white" /> android:tint="@android:color/white" />
<ImageButton <ImageButton
@ -190,12 +191,27 @@
android:layout_weight="1" android:layout_weight="1"
android:layout_margin="2dp" /> android:layout_margin="2dp" />
<com.kazeia.ui.MiniGraphView <!-- Remplace le graph GPU (inutilisé) : encart « dernier tour »
android:id="@+id/graphGpu" (input · output · temps par étape), persistant. -->
<ScrollView
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_weight="1" android:layout_weight="1"
android:layout_margin="2dp" /> android:layout_margin="2dp"
android:background="#16202A">
<TextView
android:id="@+id/tvLastTurn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="6dp"
android:fontFamily="monospace"
android:textSize="9sp"
android:textColor="#B8D8EC"
android:lineSpacingMultiplier="1.15"
android:text="⏱ dernier tour : —" />
</ScrollView>
</LinearLayout> </LinearLayout>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/kazeia_primary" />
<foreground android:drawable="@color/kazeia_accent" />
</adaptive-icon>

View File

@ -0,0 +1,93 @@
# Spec — API d'échantillonnage natif (kazeia-engine) pour les presets Kazeia
**Date** : 2026-06-18
**Demandeur** : app Kazeia (refonte presets admin)
**Cible** : `kazeia-engine` (lib `libkazeia_engine`, JNI `EngineJni`)
## Contexte
L'app admin permet désormais de régler, par modèle (Speaker / Thinker) et via des
**presets** nommés, les paramètres d'échantillonnage : `temperature`, `top_p`,
`top_k`, `repeat_penalty`, `presence_penalty`, `frequency_penalty`, `max_tokens`.
Côté app, ces valeurs sont **persistées et transmises** jusqu'au pont JNI
(`UnifiedLlmAdapter` → `EngineLlmEngine`/`LlmSession`). **MAIS le JNI actuel
n'expose que `maxTok`** :
```kotlin
external fun generate(h: Long, sys: String, usr: String, maxTok: Int): String
external fun sessionAsk(h: Long, usr: String, maxTok: Int, cb: TokenCallback)
external fun generateStream(h: Long, sys: String, usr: String, maxTok: Int, cb: TokenCallback)
```
Donc aujourd'hui **seul `max_tokens` agit** ; température/top-p/k/pénalités sont
inertes (l'app l'indique honnêtement dans l'UI : « effectif après MAJ moteur »).
## Demande — exposer le sampling au JNI
Ajouter une **variante échantillonnée** des entrées de génération (sans casser les
signatures existantes), qui prend une struct de sampling et la câble sur la chaîne
de samplers llama.cpp. Le GGUF Qwen3.5-4B (Speaker par défaut) est la cible
prioritaire.
### Signatures proposées
```kotlin
// Struct portée côté natif (ou 7 args primitifs si plus simple en JNI) :
// temperature: Float, topP: Float, topK: Int,
// repeatPenalty: Float, presencePenalty: Float, frequencyPenalty: Float, maxTokens: Int
external fun generateSampled(
h: Long, sys: String, usr: String,
maxTokens: Int, temperature: Float, topP: Float, topK: Int,
repeatPenalty: Float, presencePenalty: Float, frequencyPenalty: Float,
cb: TokenCallback
)
external fun sessionAskSampled(
h: Long, usr: String,
maxTokens: Int, temperature: Float, topP: Float, topK: Int,
repeatPenalty: Float, presencePenalty: Float, frequencyPenalty: Float,
cb: TokenCallback
)
```
### Mapping llama.cpp (sampler chain)
Construire la chaîne par requête (ou réutiliser un `llama_sampler` reconfiguré) :
```c
auto * chain = llama_sampler_chain_init(llama_sampler_chain_default_params());
llama_sampler_chain_add(chain, llama_sampler_init_top_k(top_k));
llama_sampler_chain_add(chain, llama_sampler_init_top_p(top_p, 1));
llama_sampler_chain_add(chain, llama_sampler_init_penalties(
/*penalty_last_n*/ 64, repeat_penalty, frequency_penalty, presence_penalty));
llama_sampler_chain_add(chain, llama_sampler_init_temp(temperature));
llama_sampler_chain_add(chain, llama_sampler_init_dist(/*seed*/ LLAMA_DEFAULT_SEED));
```
- `temperature <= 0` ⇒ greedy (`llama_sampler_init_greedy`), ignorer les autres.
- Conserver le **thinking-off** et le template ChatML actuels inchangés.
- `presence/frequency_penalty = 0` ⇒ no-op (comportement neutre).
### Comportement attendu
- Valeurs de référence (preset « Kaz chaleureux ») : temp 0.4, top_p 0.85, top_k 40,
repeat 1.05, presence 0.2, frequency 0.2, max 256 — doit produire des sorties
visiblement moins répétitives / plus chaleureuses que le greedy actuel.
- Déterminisme : exposer un `seed` optionnel plus tard si besoin (pas requis v1).
### `.pte` (NPU) — hors scope v1
Le runner ExecuTorch `.pte` décode **greedy**. Le sampling y est une évolution
distincte (échantillonnage post-logits côté runner). Pour l'instant les presets
n'affectent que le chemin GGUF ; documenter la limite suffit.
## Côté app — déjà prêt
- `SamplingParams` (com.kazeia.core) porte les 7 knobs.
- `UnifiedLlmAdapter.generateWithSystem` reçoit les params ; il suffira de router
vers `*Sampled` au lieu de `ask(max)` / `generateStream(max)` quand le JNI existe.
- `ConfigStore.ModelConfig` + presets persistent les valeurs ; l'admin les édite.
Quand le JNI échantillonné est livré : ~10 lignes à changer côté `UnifiedLlmAdapter`
(brancher `sessionAskSampled` / `generateSampled`) et retirer la mention
« effectif après MAJ moteur » de l'UI admin.

View File

@ -1,32 +1,23 @@
# 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-15. # 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
d49b53137f478b7b4ccbd58b9166d83e832c299b681609c70628291c61ae7204 libGenie.so
616fa830f6fd3396ea7f0ca92f348c2146efb0b1154ab6ed2e6709655905ead3 libggml-base.so 616fa830f6fd3396ea7f0ca92f348c2146efb0b1154ab6ed2e6709655905ead3 libggml-base.so
66ea0c948385d8060a7296285d7a85738c21318a88ef767986444a3d9683aeea libggml-cpu.so 66ea0c948385d8060a7296285d7a85738c21318a88ef767986444a3d9683aeea libggml-cpu.so
7aeb209ec5c1da8857779b8f3b4c3e7519ed69c6211491948d3c87349dbced57 libggml.so 7aeb209ec5c1da8857779b8f3b4c3e7519ed69c6211491948d3c87349dbced57 libggml.so
b92ca83a53dab49e212fba411b48fcceca4331b05fe881e2b9220efed45e70b1 libkazeia_cosyvoice.so b92ca83a53dab49e212fba411b48fcceca4331b05fe881e2b9220efed45e70b1 libkazeia_cosyvoice.so
776f37e43433459a7350dc9d5d15dcb8eb3e17465a28fa78de92559bec3918bd libkazeia_engine.so 776f37e43433459a7350dc9d5d15dcb8eb3e17465a28fa78de92559bec3918bd libkazeia_engine.so
fdd730af3daf132d0819b3fc99abd8aae79800aab7a9aad371e117c16bd3a3aa libkazeia_stt.so f91df82c96d1efd3cbe6f7af6b9d261cd5c6f4a929c08bad49a4cfc1deff4477 libkazeia_pte.so
114795bf0256c380dc87971598707e9e0b745a7fc868f3d1fb460e94cecdd69c libkazeia_tts.so
349ba592462113c6e871b9c3ee508727a9e5f154a33905f946bda97535eff586 libllama-common.so
2ca77a28421fc0c680e0a5ddf0208a9471268370893f8dd4eba2a10a8aacb4ac libllama.so 2ca77a28421fc0c680e0a5ddf0208a9471268370893f8dd4eba2a10a8aacb4ac libllama.so
a207169a2696e8f0f7639090c2882152c734368b5696246a514d63b3ce0769c1 libomp.so a207169a2696e8f0f7639090c2882152c734368b5696246a514d63b3ce0769c1 libomp.so
d1e8fc79e47fe04ed00aa9bbcc5ccb84d187f0160b1d77ae5bd012aedea9c938 libqnn_executorch_backend.so 95d01368b556f0c698cbbb6daa47494c666e0242a063cf1ca830202946435df4 libqnn_executorch_backend.so
414794639e2897120143cff80aaf73e830e305357b6c1aa8343de380c89b8c36 libQnnGpuNetRunExtensions.so a3bc48674377a04248fcacbdf578a98b47d457821c53e6450ca11b59a3c5ef93 libQnnHtpNetRunExtensions.so
26f13b297d375e9629eb997b7adb05a4ce9e1c17d306baaefe905563fee43eb8 libQnnGpuProfilingReader.so
b5b52b66f3fc5567429024409dcedb006ca6a9115f271689810589b7d79af430 libQnnGpu.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