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>
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>
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>
Nouveau jeu CosyVoice livré par l'engine (mêmes 5 symboles JNI, façade inchangée,
toujours auto-contenu) : 2 .so swappés (libkazeia_cosyvoice + libcosyvoice ;
libomp/libc++_shared identiques). Modèle cv3_distilled_30k_hiftf16.gguf (953 Mo)
remplace cv3_distilled_30k.gguf — MODEL mis à jour dans l'adaptateur.
Validé device : charge OK, synthèse OK, cache de voix entre appels →
1er son tour 1 ~10,6s → tour 2 ~6,7s (-37%). (RTF dev annoncé 0.88 ; sur cette
tablette thermiquement saturée on mesure ~1,1-1,7 — à revérifier device froid.)
Bonus robustesse : guard `if (!::voiceCommands.isInitialized) return false` dans
handleVoiceCommand — une entrée arrivée avant la fin de l'init du service ne
crashe plus (UninitializedPropertyAccessException, vu en test en tirant un intent
trop tôt).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
« Reste optionnel » : tenté le streaming LLM→TTS pour CosyVoice (synthèse de la
1ʳᵉ phrase pendant que le LLM décode, comme le chemin Qwen TTS-B).
Mesuré device : CONTRE-PRODUCTIF. LLM decode (6 threads CPU) et synthèse
CosyVoice (8 threads CPU) saturent tous les cœurs ; les chevaucher écroule le
LLM (17 → 0,55 tok/s, tour 1,1s → 41s, 1er son à 43s). Reverté.
Le streaming LLM→TTS n'a de sens que si les 2 étages utilisent des compute
distincts (Qwen3 NPU + TTS CPU). Pour CosyVoice (tout CPU) : laisser le LLM
finir vite PUIS synthesizeAndPlay, qui streame déjà phrase-par-phrase EN INTERNE
(synthèse N+1 pendant lecture N, sans contention). Net diff = un commentaire
garde-fou dans KazeiaService pour ne pas re-tenter ; code de streaming retiré.
Re-validé batch : LLM 17,1 tok/s (1,9s), TTS 1er son ~8s (RTF CosyVoice inhérent).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bug : sessionReset()+sessionAsk() renvoyait VIDE. Cause = q35-lmq4 est l'archi
hybride Qwen3.5 (DeltaNet/SSM) ; llama_memory_seq_rm d'un suffixe « Returns false
if a partial sequence cannot be removed » (l'état récurrent ne se rembobine pas
partiellement). Le code ignorait ce retour → KV incohérent → tour suivant vide.
Fix natif (kazeia-engine dist/jni/kazeia_engine_jni.cpp, .so rebuildé CPU-only,
drop-in) : sessionReset teste le retour de seq_rm ; s'il échoue (récurrent/
hybride) → reset COMPLET (llama_memory_clear + re-prefill du system mémorisé) +
llama_sampler_reset. sessionStart mémorise désormais les tokens system.
App : EngineLlmAdapter.resetSession() rebascule sur le session.reset() natif
(au lieu du contournement newSession). Validé device : après reset, le tour
n'est plus vide ET la mémoire est effacée (ne connaît plus le prénom),
prefill ~285ms, decode ~17 tok/s, tours suivants OK.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Intègre les 3 APIs livrées par l'engine (libkazeia_engine.so drop-in, façade
EngineLlmEngine.kt : generateStream / newSession / LlmSession / GenStats).
EngineLlmAdapter : crée une LlmSession au load (system prefillé 1×), et chaque
generate() fait session.ask() en STREAMING (onToken câblé au pipeline existant).
Le system n'est plus re-prefillé à chaque tour.
Mesuré device (q35-lmq4, t=6, v0.1.7) :
- tour : 2991→~1100 ms (prefill 2700→~300 ms, system caché). Decode réel
17 tok/s (le « 3,2 » était l'artefact prefill-inclus ; tps vient maintenant
de getLastStats, débit décode).
- streaming callback OK sous vraie JVM (le seul point que le dev n'avait pas
pu tester) — aucun crash.
- mémoire conversationnelle (bonus) : rappelle « Richard » sur 3 tours.
Confidentialité : la mémoire est vidée aux bornes de conversation —
EngineLlmAdapter.resetSession() (recrée la session) appelé sur CLEAR_CHAT et
sur changement de profil actif (onProfileStoreChanged). Validé : après switch
de profil, le LLM ne connaît plus le prénom.
⚠ Bug natif à corriger côté engine : sessionReset()+sessionAsk() renvoie vide
(n_past non restauré à n_sys après seq_rm). Contourné en recréant la session
(newSession). Le mode mémoire (ask→ask sans reset) est, lui, OK.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Directive 2026-06-15 : si on passe par kazeia-engine, on n'utilise plus de .pte.
- ConfigStore.llmEngine défaut "prod"(.pte) → "lib" (EngineLlmAdapter +
libkazeia_engine + GGUF). Le .pte n'est plus chargé au runtime ; il reste
câblé en repli d'urgence seulement (et sur cette plateforme son runner QNN
échoue de toute façon : « Failed to load llm runner [1] »).
- ModelRegistry.defaultSpeaker() "qwen3-4b-seq1024"(.pte) → "qwen3.5-4b" (GGUF,
q35-lmq4) : config cohérente, le mode lib charge sp.ggufPath() directement
sans repli, zéro référence .pte.
Validé device (release v0.1.6, SM8750) : libkazeia_engine charge q35-lmq4.gguf
en 1.8 s (backend='lib'), 2 générations FR thérapeutiques cohérentes
(« C'est compréhensible, je suis là avec toi… » / « C'est vraiment épuisant,
je te comprends. »). Règle aussi le mode perroquet observé avec le .pte.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
La voix n'est plus choisie côté patient ; elle est définie PAR PROFIL dans l'app
admin (le champ Profile.voiceId + le dropdown admin existaient déjà ; seul le
runtime ne l'appliquait pas).
App patient :
- ChatActivity : suppression de setupVoiceSelector() + des listes voiceFiles/
voiceNames/voiceColors. activity_chat.xml : suppression de la voiceBar (spinner),
l'orbe se contraint désormais sous tvStatus (couleur = défaut service).
- KazeiaService : nouvelle source unique = le profil actif.
- applyActiveProfileVoice() lit ProfileStore.activeProfile().voiceId (défaut
DEFAULT_VOICE_ID="damien" en invité), appelée à l'init ET via un listener
ProfileStore → propagation LIVE quand l'admin édite la voix / change de profil.
- setVoiceId(id) résout selon le moteur actif : CosyVoice → cosyvoice/<id>.cvps ;
Qwen3/lib → ../voix/<id>.wav. setVoice(pathOrId) conservé (intents test) délègue.
Validé device (release v0.1.5, SM8750, moteur cosyvoice) :
- plus de barre de voix dans l'UI patient (confirmé).
- profil voice_id=elodie → service charge 'elodie' (≠ défaut) ; changement live
via provider voice_id=damien → ré-appliqué sans redémarrage.
- moteur 'lib' charge toujours (pas de régression du stack gelé).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Câblage Kotlin/APK du moteur TTS clonage vocal CosyVoice3 distillé livré par
kazeia-engine (dist/cosyvoice). Mirror du pattern sttEngine/ttsEngine="lib".
- CosyVoiceTtsEngine.kt : CosyVoiceJni (5 symboles) + adapter implémentant
com.kazeia.core.TtsEngine. Cache de voix (.cvps), synthèse phrase-par-phrase,
streaming AudioTrack MODE_STREAM en ENCODING_PCM_FLOAT @24 kHz (1er son joué
pendant que la phrase suivante se génère ; RTF~1.4 masqué par le streaming),
suspension jusqu'au drain réel (marker) pour garder le micro coupé (anti-écho).
- KazeiaService : branche when(ttsEngine) { "cosyvoice" -> ... } à l'init.
- ConfigStore : doc du nouveau backend.
⚠ NON validé device : BLOQUEUR côté libs engine. Les .so livrés embarquent leurs
propres libggml*.so (SONAME identiques au stack LLM/TTS gelé, 535 symboles ggml_*
en collision) → interposition ELF dans le namespace JNI (RTLD_GLOBAL) : le premier
libggml chargé (LLM, au boot) gagnerait, libcosyvoice se lierait au mauvais ggml →
crash. Ni écraser ni omettre les libggml app ne marche (consommés par libllama/
libkazeia_engine/libkazeia_tts gelés). Fix attendu : ggml statique + visibilité
hidden dans libcosyvoice.so côté engine. Code prêt à valider dès libs corrigées.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deux bugs de prod trouvés en validant le cycle OTA corpus r2 :
1. ttsEngine default "prod" → toute install fraîche (release patient) chargeait
Qwen3TtsEngine legacy → SIGSEGV déterministe dans llama_context ctor
(libtts_talker_cpu compilé contre llama-upstream, libllama.so embarqué =
fork ql depuis 2026-06-02, dérive ABI llama_context_params). La tablette
release crash-loopait au démarrage du service depuis la migration ; la
tablette dev ne le voyait pas (config tts_engine=lib persistée).
→ default "lib" (seul backend compatible avec les jniLibs livrés).
2. Le corpus RAG était résolu via MODELS_DIR/../rag_corpus : sur tablette dev,
MODELS_DIR = legacy /data/local/tmp (modèles adb) → syncDir lisait l'ancien
corpus alors que l'OTA dépose le composant rag_corpus dans le stockage
externe. → KazeiaPaths.ragCorpusDir avec priorité INVERSE des modèles :
l'externe (installeur/OTA) prime dès qu'il est non vide, fallback legacy.
versionCode 4 / 0.1.3, publié catalog v6.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
L'auto-ingestion ne jouait qu'à base vide (1er démarrage) : une MAJ OTA du
composant rag_corpus déposait les nouveaux fichiers mais le RAG continuait de
servir l'ancien corpus, sans aucun déclencheur (sauf intent manuel rag_ingest).
Rag.syncDir(dir) : ingère tout fichier .txt/.md absent de la base ou dont le
texte diffère du brut stocké (comparaison exacte). Idempotent, n'embed que le
delta → appelé à CHAQUE buildRag, remplace le cas spécial « base vide ».
Ne supprime jamais (docs admin et docs retirés du dossier restent — suppression
via l'admin).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Le « corpus de Damien » (/opt/Kazeia/Kaz/knowledge_base) s'est révélé être des
fichiers de test de la machinerie RAG (licornes, Wikipédia Musk, scrape Tomatis)
— aucune valeur clinique. Corpus écrit ici à la place : +12 fiches (crise
d'angoisse, pensées automatiques, émotions, estime de soi, auto-compassion,
colère, deuil, stress, relaxation musculaire progressive, pleine conscience,
anxiété sociale, demander de l'aide) en plus des 6 existantes.
Lignes éditoriales (README) : une fiche = un thème + une technique concrète,
~1 chunk (maxChars=1200), vouvoiement neutre, jamais de médication/diagnostic/
crise suicidaire (CrisisGuard gère la crise hors LLM). À valider par le
clinicien avant activation patient.
Publié : composant catalogue rag_corpus → corpus-seed-r2, catalog v4 en ligne.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code-review (2e passe) :
- Faux négatif CrisisGuard : « je serais/suis mieux mort », « mieux mort que
vivant » ne déclenchaient aucun motif. Ajout du motif « mieux mort » + 3 cas
de test. (Sécurité vitale — sensibilité d'abord.)
- normalize() recompilait 3 Regex à chaque appel (1×/message patient) :
hoistées en constantes (MARKS/APOS/SPACES).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rag.ingest : embed D'ABORD, n'écrit la base qu'ensuite, via la nouvelle
RagDb.replaceSource (delete+insert dans UNE transaction). Un échec transitoire
d'embedding ne détruit plus les chunks existants (auparavant deleteSource était
appelé avant la boucle d'embed → corpus tronqué/vidé en silence, non rattrapé
par reingestMissing qui ne reprend que les docs à zéro chunk).
KazeiaService.buildRag/teardownRag : synchronized(ragLock) + buildRag idempotent
(retourne l'instance existante si déjà active). onCreate et un toggle config RAG
peuvent se chevaucher (deux coroutines serviceScope) → sans verrou, deux
EngineEmbedder natifs étaient créés et le perdant fuyait (jamais close()).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CrisisGuard (sécurité vitale) :
- normalize() : suppression accents (NFD) + apostrophes→espace + compactage,
pour matcher la sortie STT FR (souvent sans accents) et la frappe libre.
- PATTERNS étendus : formulations directes manquantes (« je veux mourir »,
« idées suicidaires/noires », « disparaître », « me pendre », « passer à
l'acte », « plus la force de vivre »…). « me pendre » autonome (aucun usage
bénin) ; « me noyer » reste gardé (idiome « se noyer dans le travail »).
- Tests étendus : 12 positifs + 4 négatifs (idiomes « mourir de rire/faim »,
« idées plein la tête », « passe à la pharmacie »). 11/11 verts.
Rag.retrieve() : un chunk plus long que le budget ne fait plus retomber tout
le bloc à null — on garantit ≥1 hit (tronqué si besoin) puis on empile tant
qu'il reste du budget. Évite la perte de contexte pertinent.
Rag.close() : @Synchronized — sérialise avec retrieve()/searchScored() pour
qu'un teardown (toggle RAG off) ne libère pas le contexte natif de l'embedder
pendant une requête provider en cours (évite le SIGSEGV).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migration tablette dev debug→release + cycle OTA complet exercé de bout en bout
pour la première fois :
- uninstall debug → install release v1 (signé clé Kazeia, cf RELEASE_SIGNING.md)
- bump versionCode 2 / versionName 0.1.1, assembleRelease (verifyJniLibs OK)
- publication Nextcloud : APK v2 + composants RAG (e5 + corpus) + catalog v3
- sur device : Onboarding détecte « Mise à jour disponible v0.1.1 », télécharge
(106 Mo, SHA-256 vérifié), PackageInstaller installe in-place → versionCode=2.
PREUVE CLÉ (la raison d'être de la signature release) : un marqueur de config
posé en v1 (rag_enabled=1, rag_threshold=0.85) a SURVÉCU à la mise à jour v2 →
signature cohérente entre versions → une MAJ ne détruit pas les données patient.
Note : catalog.spec.json/dist non suivis (miroir data). versionCode=2 est la
seule trace git ; la prochaine release repart de là.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Les 23 .so de app/src/main/jniLibs/ (474 Mo, 5 chaînes de build : kazeia-engine,
ExecuTorch, QNN SDK 2.42, Genie, TTS pipeline) sont des artefacts gitignorés
posés à la main — « ça ne buildait que sur cette machine ». On ne les versionne
pas (l'historique gonflerait de ~500 Mo par update engine) ; on versionne leur
DÉFINITION :
- scripts/jnilibs.MANIFEST.sha256 : état attendu (sha256 + provenances).
- scripts/jnilibs.sh : verify (CI/Gradle) | sync-engine (rafraîchit le
sous-ensemble kazeia-engine depuis /opt/Kazeia-engine/dist) | update-manifest
(re-fige, à committer) | export/import (tarball pour autre machine).
- Garde Gradle : preReleaseBuild dépend de verifyJniLibs → un build RELEASE avec
des libs manquantes/modifiées/non déclarées ÉCHOUE. Debug reste libre.
Validé : release passe avec libs conformes ; échoue sur lib altérée (MODIFIÉ
libomp.so) ; verify OK après restauration. Ménage au passage : 3 .bak morts
(~293 Mo) sortis de jniLibs vers _jnilibs_backup_baks/.
Nouvelle machine : git clone + ./scripts/jnilibs.sh import <tarball> (export
déposé sur box.kazeia.com privé) → build garanti conforme.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Premier harnais de tests du projet. testImplementation junit:4.13.2, src/test/.
CrisisGuardTest — NON-RÉGRESSION DE SÉCURITÉ : 26 formulations d'idéation qui
DOIVENT déclencher (toutes les familles de motifs, casse, variantes STT collées
validées device), 10 messages de détresse ordinaire qui ne doivent PAS déclencher
(tristesse, insomnie, colère, deuil d'autrui, « envie de dormir »…), 2 limites
ASSUMÉES documentées (sensibilité d'abord : « en finir avec ces insomnies » et
mention indirecte du suicide déclenchent), invariants de la réponse (112,
honnêteté « programme », anti-isolement). Toute retouche des PATTERNS doit garder
ces tests verts ; le clinicien ÉTEND les cas, ne supprime pas de positif.
VectorIndexTest : classement cosinus, top-k, seuil, index vide/dim incohérente.
ChunkerTest : tag source, découpe+overlap sous la limite ubatch, texte vide.
./gradlew :app:testDebugUnitTest → 11 tests, 0 échec, <5 s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Décision : le dépôt git.kazeia.com étant privé (Gitea auto-hébergé, équipe
restreinte), le keystore + credentials y sont versionnés comme sauvegarde —
une panne de la machine de build ne peut plus coûter la clé.
Conséquence assumée : tout accès au dépôt = capacité de signer des mises à
jour Kazeia ; la clé reste dans l'historique git définitivement. Si le dépôt
devait un jour être ouvert/partagé plus largement, faire une rotation de clé
AVANT (cf docs/RELEASE_SIGNING.md §7).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keystore release créé : /opt/Kazeia/keystore/kazeia-release.jks (RSA 4096,
alias kazeia, valide 2056), credentials dans keystore/credentials.properties
(chmod 600, hors git — la liste blanche du .gitignore racine l'exclut d'office).
Les deux modules lisent credentials.properties : présent → assembleRelease signe
en release ; absent (autre machine/CI) → repli debug + warning, le build ne casse
pas. MÊME clé pour com.kazeia et com.kazeia.admin → permettra la
signature-permission sur le ContentProvider (plan admin).
Validé : assembleRelease des deux apps OK, apksigner confirme le certificat
CN=Kazeia (SHA-256 4b408d9d…) sur les deux APK.
docs/RELEASE_SIGNING.md : gestion complète — câblage, procédure de release via
catalogue (bump versionCode obligatoire), SAUVEGARDE du keystore (le point qui
ne pardonne pas), scénarios de panne (perte clé/mdp/machine/compromission),
migration one-shot des tablettes debug→release, dev quotidien reste en debug.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fusionne les garde-fous du prompt de Damien dans la voix chaleureuse de Kazeia,
et ajoute un filet de sécurité crise indépendant du LLM.
Bornes "soft" (niveau prompt, DEFAULT_SPEAKER_PROMPT + DEFAULT_SYSTEM_PROMPT +
copie admin, synchronisés) : pas de diagnostic / pas de pathologie nommée ;
médicament/dose/traitement -> décision médicale, renvoi médecin/psychiatre ;
refus des jeux de rôle/simulations ; réponse cadrée sur le stockage local.
Tutoiement + ton chaleureux préservés (≠ style clinique/vouvoiement de Damien).
Borne "hard" (CrisisGuard, DÉTERMINISTE) : on ne laisse jamais un 4B sous
contrainte de brièveté improviser sur une idéation suicidaire. Détection par
motifs FR (tunables clinicien) -> court-circuite Thinker+Speaker -> réponse fixe,
validée, honnête (Kazeia = programme), oriente vers l'humain + 112 (urgence EU,
valable FR et Luxembourg). Placé en tête de processLlmResponse.
Validé device : "envie d'en finir / me faire du mal / vaudrait mieux d'en finir"
-> [CRISIS] -> réponse de sécurité synthétisée (TTS frames=192) + jouée.
"arrêter mes médicaments" -> redirection médecin (prompt, pas crise). "triste et
fatigué" -> réponse normale, AUCUN faux positif crise.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Active le RAG de bout en bout dans la vraie conversation :
- Rag.ingestDir(dir) : ingère tous les .txt/.md d'un dossier (source = nom fichier).
- KazeiaService : auto-ingestion du corpus au 1er démarrage si base vide
(.../kazeia/rag_corpus/), + intent `rag_ingest` (clear+reingest, brique admin future).
- Injection live : seuil relevé à 0.82 (calé e5 : base ~0.80 / pertinent ~0.88), k=3,
pour ne PAS injecter de hors-sujet.
- Corpus seed FR de psychoéducation (6 fichiers) dans docs/rag_corpus_seed/.
Validé device (ragEnabled ON, Speaker lib qwen3.5-4b) : 6 chunks ingérés, tour
patient « je n'arrive pas à dormir… » -> retrieve 1 hit (top 0.84, le chunk sommeil ;
les 5 autres filtrés par le seuil) -> bloc CONTEXTE injecté (577 c) -> réponse ancrée.
Aucun crash, coexistence RAM OK. ragEnabled reste OFF par défaut (opt-in clinicien).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Le dev kazeia-engine a livré le bridge CPU-only avec embedText (commit engine
655d65a) + un GGUF multilingual-e5-small fonctionnel (converter patché pour le
tokenizer XLM-R/unigram). Bridge swappé dans jniLibs (ABI cohérente : libllama/
libggml déjà = build CPU-only du 2 juin).
- Rag.ingest : les passages portent désormais le préfixe "passage: " (e5), en
symétrie du "query: " de retrieve (EngineEmbedder.embedPassage).
- Intent `rag_real_test` : valide le VRAI EngineEmbedder (e5) in-app.
Validé device (CPU-only, untrusted_app) : embedder ready=true dim=384, AUCUN
crash SELinux/fastrpc (la .so ne tire plus hexagon), retrieval FR correct —
requête sommeil → doc insomnie en tête (top=0.88). embedText fonctionne in-app
end-to-end. Le .so (gitignored) est un artefact build, hors git.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Module com.kazeia.rag, conçu d'après le RAG PC de Damien mais porté on-device :
- Embedder : interface + FakeEmbedder (déterministe, test hors engine) +
EngineEmbedder (réel, branché sur EngineJni.loadEmbedder/embedText — inerte
via UnsatisfiedLinkError tant que la lib n'expose pas ces symboles).
- VectorIndex : recherche exhaustive cosinus (produit scalaire, vecteurs L2),
top-k + seuil. Pas d'ANN : injustifié à l'échelle (10^2–10^3 chunks).
- Chunker : découpage par phrases + overlap, tag [Source: x] (mieux que le
découpage au caractère du PC).
- RagDb : SQLite (vecteurs en BLOB float32 LE), garde-fou modèle+dim.
- Rag : façade ingest/loadIndex/retrieve -> bloc CONTEXTE budgété en tokens.
Bindings JNI embedder ajoutés à EngineJni (loadEmbedder/embedText/freeEmbedder).
Flag ragEnabled (ConfigStore + provider rag_enabled), DEFAULT OFF -> production
inchangée. Câblage live dans KazeiaService : si activé + embedder prêt + contexte
au-dessus du seuil, préfixe le bloc CONTEXTE au prompt patient (défensif, le RAG
ne porte jamais la gestion de crise).
Validé device via l'intent `rag_test` (FakeEmbedder) : ingest 2 docs -> SQLite ->
index -> retrieve classe correctement (requête sommeil -> doc insomnie en tête).
Démarrage prod : "[RAG] désactivé (config)", rag_enabled=0. Aucune régression.
Reste (dépend de l'engine) : embedText livré + modèle e5/bge FR + ingestion d'un
vrai corpus côté admin + entrée catalogue. Cf docs/RAG_EMBEDDINGS_ENGINE_SPEC.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ajoute docs/RAG_EMBEDDINGS_ENGINE_SPEC.md : instructions à transmettre au dev
de kazeia-engine pour exposer un entrypoint texte→vecteur poolé (loadEmbedder/
embedText/freeEmbedder) réutilisant la machinerie d'embedding déjà présente dans
le fork libllama. Calée sur dist/jni/kazeia_engine_jni.cpp réel.
Whitelist .gitignore élargie à /docs/.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Le working tree contenait ~21 k fichiers non suivis (modèles, backups, SDK,
venvs, arbres expérimentaux, intouchables beta_kazeia/root_oneplus, Kaz de
Damien…) qui polluaient `git status`. Remplace la blacklist fuyante par une
LISTE BLANCHE : on n'ignore plus au cas par cas, on ignore tout à la racine
sauf l'app mobile (kazeia-android), scripts/, executorch-*, et les docs *.md/
*.txt. Rien n'est supprimé du disque — juste sorti du suivi.
Résultat : non-suivis 21389 → 0. Ajoute au passage des fichiers légitimes qui
traînaient non suivis (FROZEN.md = contrat de stack figée, scripts d'export TTS,
rapport). Le moteur unifié reste un dépôt séparé (/opt/Kazeia-engine).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capture l'état courant de kazeia-android (migration vers le moteur lib unifié :
suppression des anciens AudioCaptureManager/VadEngine/SileroVad, WhisperJni/
WhisperLiteRt/WhisperNpu/AndroidStt, KazeiaLlmJni/LlamaCppLlmEngine,
AndroidTts/ChatterboxTts ; édits app/JNI/gradle associés). L'app construit et
tourne sur device dans cet état. Commit de préservation pour ne rien perdre
avant de restreindre le périmètre de suivi du dépôt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ModelRegistry devient backend-aware (PTE | GGUF) : enum Backend, ggufFile +
ggufPath() sous MODELS_DIR, fileExists() par backend, et un type de template
ChatTemplate (QWEN35_THINKOFF | PLAIN_CHATML). Ajout de deux entrées Speaker
GGUF servies par le moteur lib : Qwen3.5-4B (q35-lmq4, le défaut lib jusqu'ici
codé en dur, désormais visible) et Qwen2.5-7B (Q4_K_M, arch qwen2 dense).
KazeiaService : le path lib route le GGUF du Speaker sélectionné (sp.ggufPath())
au lieu de q35-lmq4 en dur, et passe plainChatml selon le template. Pour les
modèles sans thinking (Qwen2.5), EngineLlmAdapter construit un ChatML standard
sans bloc <think> via generateRaw (le natif l'injecterait sinon).
Affordances de test/maintenance (pilotage adb sans UI) :
- llm_text / llm_max : génération LLM pure loggée (tok/s, chars), hors pipeline TTS.
- cfg_set : persiste engine+model+prompt via am --es (contourne content --bind
qui casse sur ':'); cfg_sys=DEFAULT résout le prompt thérapeutique en interne.
Provider /models : expose le chemin GGUF pour les entrées GGUF.
Validé device : les deux GGUF chargent, FR cohérent, templates propres.
Bench : Qwen3.5-4B ~13.5 tok/s, Qwen2.5-7B ~6.8 tok/s (CPU-only) ; qualité FR
thérapeutique nettement en faveur du Qwen3.5-4B.
Note : KazeiaService.kt et les 3 fichiers nouveaux portent aussi du travail
antérieur non commité (migration moteur lib + app admin) ; ce commit en capture
l'état courant en plus de la fonctionnalité Speaker GGUF.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New tool + generated artefacts so the on-device voice spinner can now
hot-swap between all 8 voices — previously only Damien's prefix/suffix
were present in the model dir, and the tablet fell back to him
regardless of selection.
scripts/export_voice_prefix_suffix.py runs Qwen3TTS's voice-clone
path under a forward hook, captures the first prefill call's 1024-dim
talker input embeddings, aborts the rest of the (very slow on CPU)
decode via a sentinel exception, and slices out the first 9 vectors
as <name>_voice_prefix.bin and the last 2 as <name>_voice_suffix.bin.
Validated against the shipped damien_voice_prefix.bin: using
damien_15s_24k.wav as the reference audio, max|diff| = 0, so the
extraction matches the original tooling bit-for-bit.
Generated and adb-pushed to
/data/local/tmp/kazeia/models/qwen3-tts-npu/:
amir / didier / elodie / jerome / richard / sid / zelda
(+ re-generated damien from the canonical 15s_24k reference)
Qwen3TtsEngine.setVoice (already wired) reads <voice>_voice_prefix.bin
/ <voice>_voice_suffix.bin by basename, so voice changes now take
effect from the next synthesized segment with no app restart.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three tightly-coupled UX fixes the user flagged during live testing.
**Voice hot-swap (Qwen3TtsEngine.setVoice)**: previously a no-op
stub — the spinner callback updated the orb color but the actual
audio kept using Damien's cached prefix/suffix embeddings. Now we
derive the voice id from the WAV basename (elodie.wav → 'elodie'),
look up `<id>_voice_prefix.bin` + `<id>_voice_suffix.bin` in the
model dir, parse their headers, and atomically replace the embedding
arrays so the NEXT synthesized segment uses the new voice. If the
files aren't present we log a clear warning pointing at
prepare_tts_native.py — the hot-swap is wired, but per-voice prefix/
suffix still need to be generated offline and adb-pushed.
KazeiaService.setVoice now forwards to Qwen3TtsEngine in addition to
the Chatterbox branch.
**Emoji stripping**: the model loves closing on "😊" and it was
reaching TTS as a standalone segment that synthesized a fraction of
a second of junk. KazeiaPipeline.speakText now runs each sentence
through stripNonSpeakable before enqueueing — drops Unicode emoji /
dingbat / pictograph / flag blocks plus variation selectors and
zero-width joiners, then trims. Empty-after-strip sentences are
skipped entirely. The chat bubble still shows the original text
(with emojis) — only the audio path drops them.
**Typing dots indicator**: while LLM is done but TTS synthesis is
still running (~3–5 s for the first segment), the Kazeia bubble now
shows an animated ". / .. / ..." cycle at 400 ms cadence instead of
sitting empty. The moment the first segment actually starts playing,
the cycle cancels, the bubble resets to empty, and the existing
word-by-word reveal takes over. A defensive finally block also
cancels the job when no segment ever fires (e.g. all-emoji reply).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dropped the internal waveform lines — not what we wanted visually —
and replaced them with a spectrum-driven deformation of the sphere
outline itself. Each of the 12 log-spaced bands drives one Fourier
mode of the perimeter (band b → mode b + 2, so modes 0/1 stay
circular and higher bands produce tighter ripples). Low bands pull
the shape into wide asymmetric bumps that feel like formants; high
bands add quick sibilant-like tremors. Phase advances faster for
higher modes so tight ripples visually match high-frequency content.
Overall displacement is gated by the RMS envelope so silence is
quiet and loud syllables distort strongly. Fill + highlight are
clipped to the deformed path so the gradient follows the shape and
it reads as a single living object rather than a circle with stuff
bolted on.
Removed drawSpectrumBars and drawWaveformLine.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
**Regression fix**: when synthesis of segment N+1 ran longer than the
playback of segment N (e.g. 5 s synth for a 1.5 s "Bonjour !"), the
previous MediaPlayer was already in the Completed state by the time
we queued the next one. setNextMediaPlayer() on a Completed player is
a documented silent no-op — so the second sentence never started and
the user only heard the first part of the reply.
Rewrote playChainedMediaPlayers with per-player CompletableDeferred
tracking: before calling setNext we check whether current's done has
fired; after awaiting completion we verify next really auto-started
(checking isPlaying / currentPosition) and call start() explicitly if
the chain missed. Belt-and-suspenders against the race either way.
Removed the now-unused waitForPlaybackCompletion helper.
**Visual change**: in-sphere spectrum bars replaced with three
superimposed Bézier "deforming lines", mirrored above/below a central
baseline, with a soft cosine taper so the curves decay to zero at the
sphere's left/right edges (matches the circular mask). Each line has
its own slow-moving phase + gain + thickness + alpha so the three
overlap to give depth — closer to an oscilloscope trace than an EQ.
Low-level sin jitter keeps the lines alive during quiet passages,
amplitude-gated so true silence is a flat line.
User-facing change: no bars anymore. The sphere now "breathes" with
flowing waveforms matching its voice.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Complete redesign of AudioVisualizerView based on feedback: the orb
is now the app's visual face, takes the top ~60% of the chat area,
and has clearly distinct behaviour in each state.
- **Idle**: slow 5 s breathing (scale 0.88 → 1.00 via cos easing),
pure round shape, soft halo in phase. No high-frequency motion.
- **Listening**: organic blob outline built from 8 Fourier modes
whose amplitude scales with live mic RMS; a thin shimmering arc
rotates around the orb while mic energy is present; continuous
micro-ripples pulse outward. Looks clearly 'alive and attentive'
vs Idle's static breathing.
- **Speaking**: the orb becomes a contained spectrometer. A pre-
computed log-spaced spectrogram (12 bands, 120 Hz–4 kHz,
Hann-windowed FFT, one column per 50 ms of audio) is rendered as
vertical rounded-rectangle bars CLIPPED to the sphere outline so
they really look like the sphere itself speaking. Bar heights
interpolate between spectrogram frames and exponentially smooth
toward the target for fluid 60 fps motion. Outer halo pulses with
the RMS envelope; ripples release on envelope peaks.
- **Per-voice color**. Eight-entry palette (Damien lavender,
Elodie rose, Jerome aqua, Richard amber, Amir emerald, Didier
indigo, Sid peach, Zelda periwinkle). Halo, accent, bars, ring,
and ripples are all derived from a single voiceColor so switching
the voice spinner tweens the entire scene to the new identity
over a few frames. Color stored on both KazeiaService (for
persistence across process/view rebinds) and pushed directly to
the view for instant feedback at selection time.
Sidecar pipeline changes:
- Qwen3TtsEngine now computes per-segment spectrogram alongside the
RMS envelope (new computeSpectrogram + an in-place radix-2 FFT).
FFT_SIZE = 1024, hop = 50 ms, 12 log-spaced bands. SegmentReady
carries both arrays; onSegmentPlaying is (sentence, durationMs,
rmsEnvelope, spectrogram).
- KazeiaPipeline.speakText forwards the new callback shape.
- KazeiaService.VisualizerSignal.Speaking now carries the
spectrogram and the new voiceColor StateFlow.
- ChatActivity passes both to the view and collects voiceColor.
Layout: vertical chain between audioViz (weight 3) and rvMessages
(weight 2) so the orb owns ~60% of the chat panel and the chat list
takes the remainder. Removed the fixed 140 dp constraint.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a breathing lavender orb centred above the chat list that tracks
the actual audio state of the app:
- **Idle**: slow respiratory pulsation (~4 s cycle) at 20 fps. The
chatbot is visually "awake" without animating loudly.
- **Listening**: halo swells with live mic RMS from the VAD loop, so
the user sees Kazeia hearing them even before Whisper has produced
any transcription. Mic RMS is normalised with the same sqrt
squashing the TTS envelope uses so quiet speech still reads visibly.
- **Speaking**: amplitude + halo driven by a pre-computed RMS envelope
(50 ms windows, sqrt-normalised) produced at synthesis time. Ripples
fire on local peaks above 0.35 — matches speech rhythm without
overwhelming. Timer is internal to the view, synced to the segment's
durationMs; no MediaPlayer position polling.
Architecture:
- Sidecar RMS envelope. Computed in Qwen3TtsEngine.generateSegmentAudioVC
right after PCM is available, packed into SegmentReady, and handed to
onSegmentPlaying(sentence, durationMs, rmsEnvelope) when each MediaPlayer
starts. Zero extra IO — runs on the same PCM we already write to WAV.
- KazeiaService exposes VisualizerSignal (Idle | Listening(rms) |
Speaking(env, dur)) as a StateFlow. The VAD loop pushes Listening,
processLlmResponse pushes Speaking from the per-segment TTS callback,
and finally clears to Idle when no mic is open.
- AudioVisualizerView renders via Choreographer.FrameCallback, self-
throttled to 20 fps at Idle and full refresh during Listening/
Speaking. Hardware layer. Pure Kotlin + Canvas, no deps. ~280 LOC.
Layout: 140 dp strip between voiceBar and rvMessages in activity_chat.xml.
No 3D engine, no Unity, no splash extension. The avatar design work
remains on disk for a later phase when the TTS+streaming pipeline
stabilises enough to spend time on DECA/FLAME integration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Matches the 'conversation' feel the user asked for. Previously the
full LLM response appeared in the chat as soon as generation
finished, then audio played 5–10 s later — text and sound felt
decoupled. Now:
- The KAZEIA bubble is created empty and only starts filling when
the first TTS segment actually starts playing through the speaker
(we already split the response by sentence for the chained-
MediaPlayer pipeline; that split drives the reveal too).
- Inside each sentence, words are appended one by one at a cadence
of (audio duration / word count) — slower sentences reveal slower,
matching speech pacing. The first word of each sentence appears
immediately so audio and text stay aligned at the start.
Implementation:
- Qwen3TtsEngine: added `onSegmentPlaying(sentence, durationMs)`
listener, invoked from the chained-MediaPlayer worker the moment
each segment's MediaPlayer.start() lands. Sentence + duration are
carried end-to-end via a new SegmentReady data class.
- KazeiaPipeline.speakText: forwards an optional listener down to
the TTS engine, same signature.
- KazeiaService: new updateMessageText(id, text) helper. In
processLlmResponse, the bubble is added empty before speakText and
grown by a reveal coroutine per sentence; after speakText returns
we snap to the full text as a safety net.
No change to the stream_llm debug intent path — it still uses the
old enqueueSentence flow directly and doesn't need the reveal (no
UI bubble there).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The kill-background approach worked at startup (−1.6 GB) but respawns
pulled most of that back within 1–3 min, and the periodic sweep only
kept the first sweep's reclaim stable rather than going lower. Net
practical benefit over "just let Android manage it" is small, not
worth a custom optimizer + normal-perm + file maintenance.
Removes:
- MemoryOptimizer.kt
- KazeiaService.onCreate calls to freeRamForModels / startPeriodicOptimizer
- KILL_BACKGROUND_PROCESSES permission from the manifest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
First sweep reclaimed 1.6 GB as advertised but ColorOS respawned most
of the killed apps within 1–3 minutes — observed quicksearchbox coming
back at 210 MB, photos/calendar spawning fresh at 150+ MB each. Two
changes:
1. Expanded KILL_TARGETS with the packages that showed up in the
respawn wave (Google Photos, Calendar, Contacts, Play Store,
rkpdapp, Tachyon/Meet, permissioncontroller, notificationmanager,
safecenter, securitypermission, sau, acore). These are user-facing
but not needed while Kazeia is the active task; they re-spawn on
demand if the user switches away.
2. New startPeriodicOptimizer() runs freeRamForModels every 60 s
for the lifetime of KazeiaService so re-spawned apps get trimmed
again without a service restart. Tied to serviceScope so it stops
cleanly on destroy.
Net effect observed: avail RAM stays ~1.2–1.5 GB higher than without
the sweep. Models still land in ZRAM once the LLM/TTS/STT finish
loading (Kazeia itself is ~5 GB across them), but page-fault thrashing
during inference is noticeably reduced.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three unrelated fixes rolled into one so testing on the tablet stayed
coherent. All were driven by what the user was observing during live
audio tests, not by pre-planned refactors.
1. **Audio playback actually audible.** ColorOS's AudioFlinger
silently muted our AudioTrack ~600 ms after play() every time
(dumpsys audio showed `event:muted updated source:clientVolume`
and playbackHeadPosition stuck at 0), regardless of USAGE_MEDIA /
USAGE_ASSISTANT / USAGE_VOICE_COMMUNICATION, regardless of audio
focus grant, regardless of FGS type including mediaPlayback. A
MediaPlayer path using the SAME usage attributes works because it
routes through a different AudioFlinger thread that isn't under
the same background-hardening policy. `USE_MEDIAPLAYER_FALLBACK`
in Qwen3TtsEngine.kt flips playback to a WAV-per-segment pipeline.
Two MediaPlayer instances are chained via `setNextMediaPlayer()`
so segments transition without re-arming the DAC (that re-arm was
audible as "beg beg" pops between sentences). Synth of seg N+1
runs in parallel with playback of seg N via a capacity-2 Channel,
hiding synthesis latency behind playback for all but the first seg.
2. **Mic no longer loops TTS back into STT.** The continuous-
listening VAD in KazeiaService already had a guard to drop frames
while `pipelineState is Speaking`, but that state was never set by
any caller — so the mic kept recording during playback and fed our
own speaker output back to Whisper, creating the infinite
"Kazeia talks to Kazeia" loop the user observed. Both the
stream_llm intent path and the main `processLlmResponse` TTS path
now wrap the TTS call with `Speaking → Idle/Listening`.
3. **Free 1.6 GB of RAM at service start.** The OnePlus Pad 3 with
ColorOS keeps ~7 GB of Google + OPLUS background services
resident at idle. With Qwen3-4B (3.2 GB) + Qwen3-TTS (1 GB) +
Whisper (0.5 GB) on top, most of our model weights were going to
ZRAM swap — "the NPU is stuck" reports were actually page faults
paging 3 GB of LLM weights back in before each inference. New
`MemoryOptimizer` kills 30-ish non-essential background packages
(Google optional: YouTube, Wallet, Chromecast, Messaging, AICore,
Quicksearchbox; OPLUS optional: smartsidebar, cosa, pantanal,
nhs, midas, …) via `ActivityManager.killBackgroundProcesses`.
Measured reclaim on first run: **avail RAM 8468 MB → 10112 MB,
+1644 MB**. Uses KILL_BACKGROUND_PROCESSES (normal perm, no user
prompt); system-critical packages and the launcher/systemui are
explicitly excluded from the target list.
Collateral changes:
- Added FOREGROUND_SERVICE_MEDIA_PLAYBACK permission + fgsType flag
(didn't fix the mute on its own, but it's correct per Android 14
policy and leaving it without would be a latent compliance risk).
- Kept `USE_STREAMING_DECODE` + CP↔BigVGAN overlap code intact
behind the MediaPlayer-fallback branch so reverting to the
AudioTrack streaming path is a single-const flip if ColorOS ever
lifts the hardening (or we move to a device without it).
- New AudioTrack path has a keep-alive silence watchdog and a
playback-head drain wait on stop. Both were attempts to fix the
mute that didn't pan out on their own; leaving them in so the
streaming path stays usable on non-hardened devices.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>