kazeia/kazeia-no-root-report.md

363 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Kazeia Android — Élimination du root pour le LLM (résolu)
**Date :** 2026-04-14
**Device :** OnePlus Pad 3 (OPD2415, Snapdragon 8 Elite, SoC `sun`), Android 16 (OxygenOS), Magisk root
**Cible :** éliminer `su -c` du pipeline LLM de l'app Kazeia tout en gardant la TTS et le STT sur NPU
---
> **🟢 Statut : RÉSOLU.** Pipeline complet STT + LLM + TTS tourne in-process sans
> aucun appel à `su`. Voir la section **Résolution** en bas du document pour le
> détail du fix. Le reste du document décrit l'investigation initiale et garde
> sa valeur historique.
---
## 1. Contexte général
L'app Kazeia (Android / Kotlin + Jetpack Compose) orchestre un pipeline **STT → LLM → TTS** entièrement on-device sur le Hexagon HTP (V79) du Snapdragon 8 Elite.
Trois tenants se partagent le DSP via Qualcomm QNN :
- **STT** : Whisper via ONNX Runtime QNN EP (in-process dans l'app)
- **TTS** : Qwen3-TTS talker + CP via ExecuTorch `.pte` JNI (in-process dans l'app)
- **LLM** : Qwen3-4B via `qnn_llama_runner` ExecuTorch (actuellement en subprocess root)
Le LLM est invoqué par l'app via `Runtime.getRuntime().exec(arrayOf("su","-c", ...))` — chaque tour de conversation déclenche 4-5 processus `su` (Magisk popup toasts). **C'est ce qu'on veut éliminer.**
---
## 2. État des libs dans le projet
### 2.1 Deux versions QNN SDK coexistent actuellement
| Composant | Version QNN | Source |
|---|---|---|
| `libexecutorch.so` / `libexecutorch_jni.so` | v2.31-compatible | Build ExecuTorch du 10 avril |
| `libqnn_executorch_backend.so` (jniLibs) | v2.31-compatible | Build du 8 avril |
| `libQnnHtp.so`, `libQnnSystem.so`, etc. (jniLibs) | v2.31 | QNN SDK 2.31 (vieille install) |
| `libQnnHtpV79Skel.so` (jniLibs) | v2.31 | idem |
| `qnn_llama_runner` (binaire) | v2.37 | Compilé 13 avril avec `/opt/Kazeia/executorch/backends/qualcomm/sdk/qnn/` (SDK 2.37) |
| Libs dans `/data/local/tmp/kazeia-et/` | v2.37 | Copiées depuis SDK 2.37 |
| Skel v2.37 disponible | v2.37 | `/opt/Kazeia/executorch/backends/qualcomm/sdk/qnn/lib/hexagon-v79/unsigned/libQnnHtpV79Skel.so` |
### 2.2 `.pte` déployés
- **TTS talker** : `cp_transformer_fp16.pte` + `talker_transformer_fp16.pte` — exportés 8-9 avril, **incompatibles avec le runtime v13-april** (testé : `loadMethod` retourne 1 avec runtime récent)
- **LLM** : `hybrid_llama_qnn.pte` (Qwen3-4B) — exporté 13 avril, compatible avec le runner v13-april
### 2.3 Structure actuelle (root-based qui marche)
```
nativeLibraryDir (jniLibs) :
libexecutorch.so, libexecutorch_jni.so (v2.31-compat, for TTS)
libqnn_executorch_backend.so (v2.31-compat)
libQnnHtp.so, libQnnHtpPrepare.so, libQnnHtpV79Stub.so, libQnnSystem.so (v2.31)
libQnnHtpV79Skel.so (v2.31)
/data/local/tmp/kazeia-et/ (shell:shell, 0777) :
qnn_llama_runner (v2.37 binaire)
libqnn_executorch_backend.so (v2.37)
libQnnHtp*.so, libQnnSystem.so, libQnnHtpV79Skel.so (v2.37)
hybrid_llama_qnn.pte, tokenizer.json (modèle LLM)
```
L'app exec le runner via `su -c 'sh run_llm.sh ...'`. Le runner root exécute avec `LD_LIBRARY_PATH=/data/local/tmp/kazeia-et/` et `ADSP_LIBRARY_PATH=/data/local/tmp/kazeia-et/`. Fonctionne : 18-22 tok/s, RSS 1.76 GB.
---
## 3. Goal et contraintes
**Goal :** `ProcessBuilder` sans `su`. Runner lancé avec uid `u0_a329` (untrusted_app).
**Contraintes :**
- Android 13+ SELinux : `untrusted_app_29` ne peut pas **exécuter** depuis `shell_data_file` (`/data/local/tmp/`)
- Peut **lire** `/data/local/tmp/` (vérifié empiriquement sur OnePlus Pad 3 — politique SELinux permissive : `run-as com.kazeia head -c 10 /data/local/tmp/kazeia-et/hybrid_llama_qnn.pte` fonctionne)
- Le binaire doit être dans `nativeLibraryDir` (= `/data/app/~~XXX/com.kazeia-YYY/lib/arm64/`) ou `context.filesDir` pour exec
---
## 4. Tentatives effectuées et résultats
### 4.1 Tentative A : bundler le runner dans jniLibs comme `libqnn_llama_runner.so`
**Setup :**
- Copie de `qnn_llama_runner``src/main/jniLibs/arm64-v8a/libqnn_llama_runner.so`
- `packaging { jniLibs.useLegacyPackaging = true }` dans `build.gradle.kts` pour que le binaire soit **extrait** à l'install dans `/data/app/~~XXX/com.kazeia-YYY/lib/arm64/` avec bit exec
- ExecuTorchLlmEngine utilise `ProcessBuilder` avec :
- `binaryPath = File(nativeLibDir, "libqnn_llama_runner.so").absolutePath`
- `LD_LIBRARY_PATH=/data/local/tmp/kazeia-et:$nativeLibDir`
- `ADSP_LIBRARY_PATH=/data/local/tmp/kazeia-et`
**Résultat :** le binaire s'exécute, dlopen fonctionne pour les libs ARM64 dans `/data/local/tmp/kazeia-et`, mais le **DSP fastrpc refuse de charger le Skel** :
```
[ERROR] QnnDsp <E> Failed to create transport for device, error: 4000
[ERROR] QnnDsp <E> Failed to load skel, error: 4000
[ERROR] QnnDsp <E> Transport layer setup failed: 14001
[ERROR] QnnDsp <E> Failed to parse default platform info: 14001
...
[ERROR] Failed to create device_handle for Backend ID 6, error=14001
E executorch:QnnBackendUnifiedRegistry.cpp:133] Fail to configure Qnn device
F executorch:result.h:170] In function CheckOk(), assert failed: hasValue_
Aborted (SIGABRT via pal_abort)
```
**Interprétation :** le DSP fastrpc service (qui tourne hors de l'app, sous `adsprpcd` daemon) consulte sa propre policy SELinux pour décider quels chemins sont acceptables pour charger un Skel Hexagon. Pour `untrusted_app`, `/data/local/tmp/` (contexte `shell_data_file`) n'est **pas autorisé** comme source de Skel, même si l'app peut le *lire* côté ARM64.
### 4.2 Tentative B : Skel dans `context.filesDir` de l'app
**Setup :**
- Runner bundlé comme 4.1
- Au `load()`, Kotlin copie `/data/local/tmp/kazeia-et/libQnnHtpV79Skel.so``/data/user/0/com.kazeia/files/llm/libQnnHtpV79Skel.so` via `File.copyTo`
- `ADSP_LIBRARY_PATH=${filesDir}/llm:/data/local/tmp/kazeia-et`
**Résultat :** la copie réussit (log `Skel copied to /data/user/0/com.kazeia/files/llm/libQnnHtpV79Skel.so (9 MB)`), mais le DSP fastrpc **refuse toujours** avec la même error 4000. Contexte SELinux `app_data_file` n'est pas accepté non plus par `adsprpcd` pour cette app.
### 4.3 Tentative C : swap du Skel dans jniLibs à v2.37
**Setup :**
- Remplacer uniquement `jniLibs/arm64-v8a/libQnnHtpV79Skel.so` par la version v2.37 (celle qui matche le runner). Garder `libQnnHtp.so` etc. à v2.31.
**Résultat :** le DSP charge le Skel v2.37 (depuis nativeLibraryDir, accepté), mais la **TTS casse** avec :
```
QnnDsp <E> QNN_TRANSPORT_CONFIG crc32 failed in cpu, crcRX received 0
QnnDsp <E> DspTransport.transport_config failed: rpc 0x0000000b, cfg 0x00000000
QnnDsp <E> Failed to create transport for device, error: 1002
QnnDsp <E> Failed to load skel, error: 1002
```
→ ABI stub v2.31 ↔ skel v2.37 **n'est pas compatible**. QNN nécessite que stub et skel soient du même minor.
### 4.4 Tentative D : upgrade complet à v2.37 dans jniLibs
**Setup :**
- Replace libexecutorch.so, libexecutorch_jni.so, libqnn_executorch_backend.so, libQnnHtp*.so, libQnnSystem.so, libQnnHtpV79Skel.so → tous en versions 13-avril / SDK 2.37
**Résultat :** le runner LLM marcherait (non testé en intégration mais les libs sont cohérentes), mais les **TTS `.pte` cassent au load** :
```
CP .pte loadMethod: 124ms, result=1
CP .pte loadMethod failed (1), disabling JNI
```
→ Les `.pte` exportés le 8-9 avril ne se chargent plus avec le runtime v13-april. Formats de sérialisation ExecuTorch ont divergé entre les deux points de version.
### 4.5 Tentative E : ré-exporter les TTS `.pte` avec le runtime courant
**Setup :** `/opt/Kazeia/scripts/export_cp_pte.py` avec `qnn_venv`
**Résultat :** erreur tooling flatc :
```
error: /home/alf/tmp/tmpXXXX/qc_compiler_spec.json:1: 156: error: unknown field: backend_type
subprocess.CalledProcessError: Command '['flatc', '--binary', ...]' returned non-zero exit status 1.
```
`qnn_venv` a des layers mixtes : le `executorch` namespace résout vers 3 chemins (`/opt/Kazeia/executorch`, plus deux site-packages). Le **dataclass** `QnnExecuTorchBackendOptions` inclut un champ `backend_type` que le `.fbs` packagé ne reconnaît pas (schema plus ancien que le dataclass, ou inversement).
Le fichier .fbs dans `serialization/` a bien le champ, mais flatc compile contre une version différente ou le JSON produit ne colle pas à la structure de tables attendue. Ça nécessite un debug Python/flatbuffers non trivial.
---
## 5. Matrice de contrainte (résumé)
Pour que le LLM tourne **sans root**, il faut simultanément :
| Condition | Comment satisfaire | Obstacle |
|---|---|---|
| Runner exec sans su | Binaire dans `nativeLibraryDir` (bundlé comme `lib*.so`) | ✅ Résolu via `useLegacyPackaging=true` |
| Runner trouve ses libs ARM64 au dlopen | `LD_LIBRARY_PATH=.../kazeia-et` | ✅ Fonctionne (l'app peut lire /data/local/tmp) |
| DSP fastrpc charge le Skel | Skel dans `nativeLibraryDir` **seulement** | ❌ **Conflit** : v2.31 (TTS) vs v2.37 (LLM), même nom |
| TTS `.pte` continue à charger | Runtime jniLibs compatible avec le format des `.pte` | ❌ Figé à ~10 avril faute de re-export |
**Le cœur du problème :** une seule instance de `libQnnHtpV79Skel.so` peut exister dans `nativeLibraryDir`, et les deux tenants (TTS et LLM) demandent des versions différentes du couple (stub, skel). Les .pte sont couplés au runtime, donc on ne peut pas unilatéralement upgrader la jniLibs sans re-exporter les .pte — et ce re-export est bloqué par un bug tooling flatc dans le venv.
---
## 6. Observations importantes vérifiées empiriquement
1. **L'app peut lire `/data/local/tmp/`** sur ce device (OxygenOS policy permissive) — test : `run-as com.kazeia head -c 10 /data/local/tmp/...` retourne les bytes.
2. **L'app NE peut PAS exec depuis `/data/local/tmp/`** — SELinux untrusted_app denies exec on shell_data_file (standard Android 13+).
3. **Le binaire bundlé fonctionne** (90 MB → strip → 5.1 MB, toujours fonctionnel : il répond à `--help`).
4. **Le DSP fastrpc ignore les tentatives de Skel hors nativeLibraryDir** — testé `/data/local/tmp/` et `/data/user/0/com.kazeia/files/llm/`, les deux retournent error 4000.
5. **ABI stub↔skel de QNN est strictement matched** — stub v2.31 + skel v2.37 → error 1002 crc32 mismatch.
---
## 7. Questions ouvertes pour la seconde opinion
1. **Existe-t-il un chemin Android où le DSP fastrpc accepte de charger un Skel pour une app untrusted, autre que `nativeLibraryDir` ?** Par exemple via une permission manifeste, un signé OEM, une API QNN spécifique pour "override ADSP path" ?
2. **Peut-on renommer ou "slot" un Skel QNN via un mécanisme officiel ?** Par exemple `libQnnHtpV79Skel_2.so` en plus de `libQnnHtpV79Skel.so`, le runtime choisissant l'un ou l'autre par env variable ?
3. **Est-il faisable/raisonnable de rebuilder le runner contre la version v2.31 du ExecuTorch (la même que celle qui a exporté les TTS `.pte`) ?** Ça demande un git checkout de `/opt/Kazeia/executorch` à ~8 avril state et un rebuild Android. Si oui, on a UNE version QNN partout, plus de conflit. Quelqu'un a-t-il déjà fait ça ?
4. **Le bug flatc `unknown field: backend_type` dans `qnn_venv` — est-ce un problème connu** d'ExecuTorch (schéma dataclass désynchro du `.fbs`) et existe-t-il un workaround documenté ? Le schema `qc_compiler_spec.fbs` contient bien `backend_type` dans `QnnExecuTorchBackendOptions:256`, et `qc_schema.py:200` aussi. Peut-être que le dataclass `QnnExecuTorchOptions` met `backend_type` à la racine dans le JSON ?
5. **Architecture alternative** — serait-ce plus sain de faire de tout l'Android ExecuTorch runtime une seule version (re-export des 3 `.pte` : talker, CP, Whisper STT) ? Pro : simplicité. Con : coût de re-export (~3h CPU par modèle via prepare_pt2e + compile QNN) + résolution du bug flatc. Risque : les .pte re-exportés peuvent produire des sorties légèrement différentes (drift numérique, qualité TTS).
6. **Workaround root acceptable ?** Le compromis actuel est Magisk "Remember this app" — l'user approuve 1 fois et pas de prompt ensuite. Mais les toasts "Kazeia was granted Superuser rights" apparaissent à chaque invocation `su -c` (5× par tour de conversation). Est-ce qu'il y a un flag Magisk pour silencer ces toasts, ou un pattern pour réduire le nombre d'invocations `su` (p.ex. un pipe persistent vers un shell root unique) ?
---
## 8. Reproduire le problème
Environnement :
```
/opt/Kazeia — repo Kazeia
/opt/Kazeia/executorch — ExecuTorch source, détaché HEAD à tag v1.2.0
/opt/Kazeia/executorch/build-android — build Android récent (13 avril)
/opt/Kazeia/executorch/backends/qualcomm/sdk — QNN SDK 2.37
/opt/Kazeia/kazeia-android — projet Android Studio
/opt/Kazeia/qnn_venv — Python venv avec executorch
Tablette : OnePlus Pad 3, rooted (Magisk), QNN setup à /data/local/tmp/
```
Pour reproduire la tentative A+B (bundling runner sans root) :
1. `cp /opt/Kazeia/executorch/build-android/examples/qualcomm/oss_scripts/llama/qnn_llama_runner /opt/Kazeia/kazeia-android/app/src/main/jniLibs/arm64-v8a/libqnn_llama_runner.so`
2. Ajouter `packaging { jniLibs.useLegacyPackaging = true }` dans `app/build.gradle.kts`
3. Modifier `ExecuTorchLlmEngine.kt` pour utiliser `ProcessBuilder` avec `nativeLibraryDir/libqnn_llama_runner.so` (voir git log `364016b`..HEAD pour les variantes essayées)
4. `./gradlew assembleDebug && adb install -r app-debug.apk`
5. Observer dans logcat : `[ERROR] [Qnn ExecuTorch]: QnnDsp <E> Failed to load skel, error: 4000`
---
## 9. Demande
Je cherche soit :
- Une **technique d'ingénierie Android/QNN** pour contourner la restriction DSP fastrpc sur les Skels hors nativeLibraryDir
- Soit une **stratégie propre** pour unifier les versions QNN/ExecuTorch sans avoir à ré-exporter tous les .pte (ou un debug précis du bug flatc)
- Soit **la confirmation** que l'approche actuelle (root + Magisk remember) est le meilleur compromis accessible, avec éventuellement des suggestions pour minimiser les prompts
Merci.
---
## 10. Résolution (post-mortem)
Une seconde opinion technique a identifié la **vraie cause racine** que
l'investigation locale avait mal diagnostiquée.
### 10.1 Vraie cause
Les processus Android forkés par Zygote (l'app elle-même, ses Services
`android:process=":xxx"`, etc.) héritent des **GIDs supplémentaires**
configurés à l'init pour `untrusted_app`. Ces GIDs incluent l'autorisation
`/dev/cdsprpc-smd` et d'autres canaux fastrpc.
Quand `Runtime.exec("su"…)` ou `ProcessBuilder` font un `fork()` + `exec()`
classique, le `exec()` ne préserve pas tous les credentials utilisés par le
driver fastrpc Qualcomm pour authentifier le client. Le driver retourne
**error 4000 "Failed to load skel"** car il refuse de créer une session DSP
pour ce process.
C'est pour ça que :
- ORT-QNN (Whisper) marchait in-process : chargé via `System.loadLibrary` dans
l'app, qui est Zygote-forked → credentials valides.
- `su -c qnn_llama_runner` marchait : root bypasse les checks fastrpc.
- `ProcessBuilder` du même runner échouait : ni Zygote-forked, ni root.
Le "conflit de version QNN v2.31 vs v2.37" que j'avais soupçonné n'était
**pas le vrai problème**. Les libs étaient déjà unifiées en v2.42 dans jniLibs.
### 10.2 La solution : `LlmModule` JNI in-process
ExecuTorch fournit `org.pytorch.executorch.extension.llm.LlmModule`, un
wrapper JNI autour du même C++ `example::Runner` que le binaire
`qnn_llama_runner`. En l'invoquant depuis l'app (process Zygote-forked), le
DSP fastrpc accepte la session — pas de root nécessaire.
### 10.3 Étapes réelles du fix
1. **Build ExecuTorch Android** avec `EXECUTORCH_BUILD_LLAMA_JNI=ON`,
`EXECUTORCH_BUILD_QNN=ON`, `QNN_SDK_ROOT=/opt/Kazeia/qnn_sdk_242/qairt/2.42.0.251225`
produit `libexecutorch_jni.so` 192 MB qui inclut le runner LLM + le backend QNN.
2. **Patches sources** dans `/opt/Kazeia/executorch-patches/llm_in_process_jni.patch` :
- `backends/qualcomm/CMakeLists.txt` : gate `PyQnnManagerAdaptor` sur `NOT ANDROID`
(le guard original sur `CMAKE_SYSTEM_PROCESSOR MATCHES x86_64` se déclenche
dans des sous-scopes du cross-compile Android).
- `extension/android/jni/jni_layer_llama.cpp`, branche `MODEL_TYPE_QNN_LLAMA` :
- `decoder_model = "qwen3"` (au lieu de `"llama3"` hardcodé)
- `temperature = 0.0f`, `eval_mode = 0` (kKVCached), `shared_buffer = true`
- **Crucial** : choisir `Runner<uint8_t>` ou `Runner<uint16_t>` selon
`module->get("get_kv_io_bit_width")` (mirror du `qnn_llama_runner.cpp main()`).
Hardcoder la mauvaise largeur produit du gibberish déterministe
comme `blocked罩ug darkestSOLEQuotes作者本人 humanity` — la KV cache
est lue/écrite à la mauvaise largeur de byte.
3. **Bundling jniLibs** :
- `libexecutorch.so` / `libexecutorch_jni.so` (build du 13-april avec LlmModule)
- `libqnn_executorch_backend.so` (assorti)
- `libQnnHtp.so`, `libQnnHtpPrepare.so`, `libQnnHtpV79Stub.so`, `libQnnSystem.so`,
`libQnnHtpV79Skel.so` (tous v2.42 depuis `/opt/Kazeia/qnn_sdk_242/`)
4. **JAR avec `LlmModule.class`** : compilation manuelle via `javac` (le build
gradle de l'AAR demandait android-34 platform non installée).
5. **Réécriture `ExecuTorchLlmEngine.kt`** :
- Constructeur : `LlmModule(MODEL_TYPE_QNN_LLAMA=4, ptePath, tokPath, 0.7f)` puis `.load()`
- `generate(prompt, seqLen, callback, echo=false)` — sinon le callback échoue à
stripper les tokens du prompt
- Template ChatML Qwen3 buildé en Kotlin, mirror exact de
`qnn_llama_runner.cpp::get_formatted_prompt()` pour `kQwen3` (user-first puis
system optionnel puis `<|im_start|>assistant`)
- Filtre inline `<think>…</think>` dans le callback avec lookahead pour les tags
fragmentés sur plusieurs pieces
### 10.4 Métriques validées
| Métrique | Valeur |
|---|---|
| LlmModule.load() | 4.2 s (one-time à l'init de l'app) |
| LLM gen | ~17 tok/s (kv-only) |
| LLM TTFT | ~4 s pour 77 tokens prompt (prefill séquentiel kKVCached) |
| TTS Talker(PTE) | 37 ms/step (vs 45-65 avant) |
| TTS CP(PTE) | 73 ms/step |
| Pipeline e2e | "Bonjour, comment vas-tu ?" → audio en ~7 s |
| Magisk prompts | **0** |
### 10.5 Optimisations restantes (non bloquantes)
- **TTFT** : ré-exporter le `.pte` en `--model_mode hybrid` pour avoir un
`prefill_forward` parallèle → TTFT passerait de ~4 s à <1 s. Pas nécessaire
pour le use case conversationnel actuel.
- **Cosmétique** : le statusbar de l'app affiche encore "Hexagon NPU" pour le
TTS alors que c'est désormais le chemin .pte (label hérité du temps c'était
ggml-hexagon).
### 10.6 Mémoire projet
État complet documenté dans
`/home/alf/.claude/projects/-opt-Kazeia/memory/project_llm_npu_plan.md`.
Backup git : branche `backup/pre-no-root-migration` + commit `6e6a2d9`.
Backup disk : `/home/alf/kazeia_backup_20260414/`.
### 10.7 Commits clés
- `f32b5dd` (LLM no-root: validate end-to-end pipeline, fix kv_io_bit_width detection)
- `b57719f` (LLM: filter <think> tokens out of the streaming TTS path)
### 10.8 Comparaison de performances avant/après
Mesurée le 2026-04-14 sur le même `.pte` Qwen3-4B avec le même runner C++ —
seule la voie d'invocation change (subprocess `su -c` vs `LlmModule` JNI
in-process).
| Métrique | Avant (su-c subprocess) | Après (in-process LlmModule) | Delta |
|---|---|---|---|
| LLM gen rate | 18.3 tok/s | 17.2 tok/s | -6 % (bruit) |
| LLM prefill speed | 52 ms / prompt-token | 52 ms / prompt-token | identique |
| LLM TTFT (prompt 35 tok) | 1.8 s | 1.8 s | identique |
| LLM TTFT (prompt 80 tok, system+ChatML) | ~4.1 s | 4.2 s | identique |
| TTS Talker(.pte) | 45-65 ms / step | 37 ms / step | +25-40 % (contexte QNN partagé) |
| TTS CP(.pte) | 65-157 ms / step | 73 ms / step | +10-50 % |
| TTS load au boot | 26.7 s | 4.3 s | **6× plus rapide** (plus de subprocess Hexagon 12 s) |
| `LlmModule.load()` au boot | n/a (subprocess à la demande) | 3.1 s (one-time) | overhead init |
| App RSS | ~2 GB app + 1.76 GB subprocess séparé | ~3.7 GB process unique | mêmes ressources globales |
| Erreurs DSP 6031/6033 en concurrence | régulières | disparues | architectural |
| Prompts Magisk | 5 / tour | **0** | UX net |
| Taille APK | ~100 MB | ~100 MB (libexecutorch_jni.so 192 MB → 8.5 MB après strip à l'install) | négligeable |
**Conclusion** : pas de régression LLM (perf identique, le runner C++ est le même).
Gain net sur la TTS (Talker 25-40 % plus rapide grâce au contexte QNN partagé,
load 6× plus rapide). Architecture plus propre : un seul process, un seul runtime
QNN, plus de contention DSP, plus de prompts root.