diff --git a/docs/VOICE_DEPLOYMENT_SPEC.md b/docs/VOICE_DEPLOYMENT_SPEC.md index 5433ce7..05c0f1f 100644 --- a/docs/VOICE_DEPLOYMENT_SPEC.md +++ b/docs/VOICE_DEPLOYMENT_SPEC.md @@ -87,46 +87,64 @@ nouvel artefact app. --- -## 4. Exclusivité — via le mapping `Profile.voiceId` existant (pas de nouvel artefact) +## 4. Exclusivité — DURE, déclarée au record-time (décision 2026-06-20) -L'exclusivité s'appuie sur le binding **voix↔profil déjà présent** (`Profile.voiceId`), -pas sur un marqueur parallèle. Kazeia-central est l'**autorité d'assignation** (il a le -CRUD profils via le provider) : +**Décision : exclusivité DURE** (consentement/RGPD — une voix clonée avec le +consentement d'un patient ne doit pas servir à un autre). On la **capture au moment de +l'enregistrement** et on **l'enforce** ; la position « pas d'artefact app » est +**abandonnée** : un champ propriétaire est nécessaire, justement pour pouvoir enforcer. -- **Verrouiller** une voix sur un patient = central assigne `Profile.voiceId = ` au - profil propriétaire, **et ne l'assigne à aucun autre profil** (sur la tablette ni - ailleurs). C'est ça l'exclusivité. -- **Déploiement** : central pousse le `.cvps` uniquement sur la/les tablette(s) du - propriétaire (cf. §3). -- **Playback** : déjà exclusif — l'app n'utilise que `activeProfile.voiceId`. +### 4.1 Capture record-time — FAIT (app admin, Phase 1) +`RecordVoiceScreen` propose une **portée** au moment d'enregistrer, écrite dans le +manifeste `VoiceStorage` (`Android/data/com.kazeia.admin/files/voix/.json`) : +```json +{ "...": "...", "reference_text": "", + "scope": "exclusive|global|pending", + "owner_profile_id": "", "owner_name": "" } +``` +- **exclusive** → patient choisi dans un picker peuplé par le provider `/profiles` + (patients de **cette** tablette ; défaut = profil actif). +- **global** → non-exclusive (tous patients). +- **pending** → « lier plus tard » (patient absent du listing) : enregistrée mais + **ni déployée ni assignable** tant que non résolue. -### Côté app — rien de nouveau requis pour l'exclusivité -Le playback par profil actif suffit. **Optionnel** (garde-fou de gouvernance, si le -picker admin on-device autorise la (ré)assignation des voix) : l'app peut, à partir de -la liste des profils qu'elle a déjà, signaler qu'une voix est **déjà assignée à un autre -profil** et déconseiller/empêcher de la réassigner — **sans aucun fichier ni colonne -supplémentaire**, juste en croisant `Profile.voiceId`. À implémenter seulement si -l'assignation reste possible on-device ; sinon l'autorité centrale suffit. +### 4.2 Ingestion Kazeia-central — ⚠ GAP À CORRIGER +L'orchestrateur lit aujourd'hui le provider patient `/voices` +(`/data/local/tmp/kazeia/voix/voix/`). **Mais une voix fraîchement enregistrée atterrit +dans le stockage ADMIN** (`Android/data/com.kazeia.admin/files/voix/`, accessible en +`adb pull` — vérifié). → **Kazeia-central doit ingérer depuis le stockage admin** +(piloté par le manifeste), pas (seulement) depuis `/voices`. C'est là que `scope` + +`owner_profile_id` + `reference_text` vivent ensemble. Il sème `locked_profile_id` +depuis `owner_profile_id`, applique : +- **déploiement** : exclusive → seulement la/les tablette(s) du propriétaire ; + global → parc ; **pending → pas de déploiement** ; +- **assignation** : `Profile.voiceId = ` au propriétaire, **jamais ailleurs** ; +- **texte d'enrôlement** = `reference_text` du manifeste (phrase de consentement connue, + pas de Whisper, cf. `VOICE_ENROLLMENT_SPEC §3`). -> Kazeia-central garde `locked_profile_id` par voix **en interne** (sa politique) et le -> matérialise par l'assignation `Profile.voiceId` + la localisation du `.cvps`. Aucun -> sidecar `.owner`, aucune ontologie d'exclusivité à dupliquer dans l'app. +### 4.3 Garde-fou on-device — Phase 2 (rend l'exclusivité ÉTANCHE) +Le playback est déjà exclusif (`activeProfile.voiceId`). Le **trou résiduel** = la +(ré)assignation de voix via le picker admin on-device, qui ne connaît pas le +propriétaire. Pour fermer : **porter le propriétaire côté patient** (sidecar +`.owner` à côté du `.cvps`, ou colonne `/voices`) et l'app **interdit** d'assigner +une voix exclusive à un profil non-propriétaire (et idéalement refuse le load au +playback). À spécifier/implémenter en Phase 2. --- ## 5. Récap — ce qui est demandé au dev app patiente -| # | Adaptation | Criticité | Forme | +| # | Adaptation | Criticité | État | | --- | --- | --- | --- | -| 1 | `/voices` scanne WAV (« à convertir ») **et** `.cvps` (« prête »), col. `cvps_exists` | inventaire voulu | curseur `voicesCursor()` | -| 2 | `voices_reload` (rescan + `RELOAD_VOICES`, constante `ACTION_RELOAD_VOICES`) | important | méthode `call()` | -| 4 | exclusivité = via `Profile.voiceId` existant + autorité d'assignation centrale | **aucun code app** (garde-fou picker optionnel, sans artefact) | central / §4 | +| 1 | `/voices` scanne WAV (« à convertir ») **et** `.cvps` (« prête »), col. `cvps_exists`, vestiges Qwen3 ignorés | inventaire voulu | **FAIT** (`voicesCursor()`, 2026-06-20) | +| 2 | `voices_reload` (rescan + `RELOAD_VOICES`, constante `ACTION_RELOAD_VOICES`) | important | à faire (`call()`) | +| 3 | **Capture record-time** de la portée (scope + propriétaire) au manifeste | **bloquant exclusivité dure** | **FAIT** (admin `RecordVoiceScreen`/`VoiceStorage`, §4.1) | +| 4 | **Garde-fou on-device** : interdire d'assigner/jouer une voix exclusive hors propriétaire | **Phase 2** (étanchéité) | à spécifier (§4.3) | -Côté Kazeia-central (déjà fait / à faire, **pas** côté app) : enrôlement, archive -chiffrée du WAV, suppression du WAV device, **politique de déploiement verrou-conscient**, -**autorité d'assignation** (`Profile.voiceId` du propriétaire, jamais ailleurs pour une -voix exclusive), et la **distribution flotte** (voix globales sur le parc, exclusives chez -leur patient) — en cours. +Côté Kazeia-central (à faire) : **ingérer depuis le stockage admin** (§4.2, gap), +enrôlement (texte = `reference_text`), archive chiffrée + suppression WAV, **déploiement +verrou-conscient** (exclusive→propriétaire, global→parc, pending→rien), **autorité +d'assignation** `Profile.voiceId`, résolution des `pending`. --- diff --git a/docs/VOICE_ENROLLMENT_SPEC.md b/docs/VOICE_ENROLLMENT_SPEC.md new file mode 100644 index 0000000..750929d --- /dev/null +++ b/docs/VOICE_ENROLLMENT_SPEC.md @@ -0,0 +1,205 @@ +# VOICE_ENROLLMENT_SPEC — conversion WAV → `.cvps` (clonage vocal CosyVoice) + +> **Destinataire : dev Kazeia-central.** Comment transformer un échantillon de voix +> (WAV) en artefact `.cvps` chargeable par le moteur TTS CosyVoice de l'app patiente. +> +> **Pourquoi côté PC (Kazeia-central) et pas sur la tablette** : l'enrôlement charge le +> **teacher CosyVoice3-0.5B complet en PyTorch (~9,1 Go)** et son *frontend* +> d'extraction. La tablette n'embarque que le `.gguf` distillé de **synthèse** (aucun +> frontend, aucune entrée JNI d'enrôlement). Porter ça on-device = shipper campplus + +> s3tokenizer + torch sur chaque device pour une opération one-shot → mauvais +> compromis. L'enrôlement est offline, opérateur, *enroll-once → sync N tablettes* : +> c'est le mandat de Kazeia-central (cf. `CLAUDE.md` §6). + +--- + +## 1. Vue d'ensemble du workflow + +``` +[Tablette] [Kazeia-central / PC] [Flotte] +capture WAV (micro, ~15 s) ──adb pull──► enrôlement (cv_make_prompt) ──adb push──► .cvps ++ pré-transcription Whisper = teacher 0.5B PyTorch dans .../cosyvoice/ + (seed REF_TXT, relue opérateur) → 4 tenseurs → GGUF .cvps + maj catalogue +``` + +1. La tablette enregistre le clip de consentement (elle le fait déjà) et peut + **pré-transcrire avec son Whisper embarqué** pour pré-remplir le texte de référence. +2. Kazeia-central récupère le WAV + le texte (relu/corrigé par l'opérateur), lance + l'enrôlement, obtient `.cvps`, le pousse vers les tablettes et met à jour le + catalogue voix (source de vérité des voix CosyVoice disponibles). + +--- + +## 2. Référence : `cv_make_prompt.py` (l'algorithme exact) + +Source : `/opt/Kazeia/cv_make_prompt.py` (et copie `/opt/Kazeia-engine/dist/cosyvoice/`). + +```python +import sys, numpy as np, torch +sys.path.insert(0, "") +sys.path.insert(0, "/third_party/Matcha-TTS") +from cosyvoice.cli.cosyvoice import CosyVoice3 +import gguf + +REF, REF_TXT, OUT = sys.argv[1], sys.argv[2], sys.argv[3] + +cv = CosyVoice3("", load_trt=False, fp16=False) # charge ~9,1 Go +fe = cv.frontend +text = open(REF_TXT).read().strip() if REF_TXT.endswith(".txt") else REF_TXT + +feat, _ = fe._extract_speech_feat(REF) # mel prompt → [1, T, 80] +emb = fe._extract_spk_embedding(REF) # x-vector campplus → [1, dim] +tok, _ = fe._extract_speech_token(REF) # speech tokens s3 → [1, n] + +feat = feat.squeeze(0).cpu().numpy().astype(np.float32) # [T, 80] +emb = emb.cpu().numpy().astype(np.float32) # [1, dim] +tok = tok.squeeze(0).cpu().numpy().astype(np.int32) # [n] +txt = np.frombuffer(text.encode("utf-8"), dtype=np.int8) # [bytes] + +w = gguf.GGUFWriter(OUT, "cosyvoice-prompt-speech") +w.add_tensor("feat", feat) # mel prompt +w.add_tensor("embedding", emb) # x-vector +w.add_tensor("tokens", tok) # speech tokens +w.add_tensor("text", txt) # transcription UTF-8 brute +w.write_header_to_file(); w.write_kv_data_to_file(); w.write_tensors_to_file(); w.close() +``` + +Trois extractions du frontend (c'est *tout* le « clonage ») + écriture GGUF. Pas de +crc32, pas de check au chargement côté C++ → le format doit être exact. + +--- + +## 3. Format `.cvps` (contrat avec le loader C++) + +Conteneur **GGUF**, nom d'architecture **`"cosyvoice-prompt-speech"`**, **4 tenseurs** +(noms et dtypes **exacts** — le loader `nativeLoadVoice` ne tolère aucune dérive) : + +| tenseur | contenu | shape (numpy) | dtype | note layout GGUF | +| --- | --- | --- | --- | --- | +| `feat` | mel prompt de la voix | `[T, 80]` | `float32` | GGUF inverse → `ne0=80, ne1=T` | +| `embedding` | x-vector campplus (empreinte locuteur) | `[1, dim]` | `float32` | `ne0=dim, ne1=1` | +| `tokens` | speech tokens s3 | `[n]` | `int32` | 1-D | +| `text` | transcription UTF-8 **brute** | `[octets]` | `int8` | 1-D, octets UTF-8 | + +- `T` ∝ durée du clip ; `dim` fixé par campplus ; `n` = nb de tokens audio. +> **Le texte de référence est CONNU, pas à transcrire (décision 2026-06-19).** La +> personne lit toujours **la même phrase de consentement** (admin string +> `rec_reference_text`, nom interpolé). Donc `text` = cette phrase canonique (nom +> rempli), **pas** un résultat Whisper. Conséquence : `transcribe.py`/Whisper **sort +> du chemin critique** (fallback legacy seulement, pour des clips au contenu inconnu). +> Source de vérité = la string admin (idéalement portée par le `reference_text` du +> manifeste voix). Fenêtre d'enrôlement portée à **`CV_TARGET_S=22 s`** pour que la +> phrase de consentement (~13 s) + la fin phonétiquement riche entrent en entier. + +- `text` est conditionnant (CosyVoice = *audio réf + texte réf → texte cible, même + voix*) : il **doit correspondre au contenu parlé** du WAV. Une transcription fausse + dégrade la similarité. + +--- + +## 4. Entrées & contraintes + +- **WAV de référence** : mono, PCM ; le frontend gère le ré-échantillonnage (fournir + ≥16 kHz propre). **~15 s** = sweet spot (la similarité ECAPA/campplus monte avec la + longueur du prompt, mais le **cache K/V on-device ~270 Mo** et la latence du 1ᵉʳ + énoncé montent aussi). Éviter <8 s (similarité faible) et >~30 s (coût mémoire). + Clip propre, une seule voix, peu de bruit/musique. +- **Transcription** : texte exact de ce qui est dit, dans la **langue du clip**. + L'enrôlement est **cross-lingual** : un `.cvps` sert pour les 9 langues + (fr,en,de,es,it,ru,zh,ja,ko). La tablette peut la pré-générer (Whisper) ; **faire + relire/corriger par l'opérateur** avant enrôlement. +- **Sortie** : `.cvps`. `voiceId` = slug stable (`[a-z0-9_]`), c'est la clé de + cache moteur et le nom de fichier. + +--- + +## 5. Dépendances hôte (poste Kazeia-central) + +Lourdes mais one-time, sur le poste opérateur (local-first) : + +| dépendance | détail | emplacement actuel (poste Richard) — **vérifié 2026-06-19** | +| --- | --- | --- | +| **interpréteur encodeur** | Python 3.10.20, torch 2.3.1+cu121 (CPU, `cuda=False`), `gguf` OK ; `from cosyvoice.cli.cosyvoice import CosyVoice3` résout | **`/opt/Kazeia/cv_venv/bin/python3`** | +| **repo CosyVoice** + `third_party/Matcha-TTS` | code `cosyvoice.cli.cosyvoice` | `/opt/Kazeia/cosyvoice-repo` (`requirements.txt` présent) | +| **teacher CosyVoice3-0.5B** | ~9,1 Go, modèles frontend (campplus, s3tokenizer, mel) | `/opt/Kazeia/_models_dl/cosyvoice3-0.5b` | +| package python `gguf` | écriture du conteneur | dans `cv_venv` | +| `cv_make_prompt.py` | le script | `/opt/Kazeia/cv_make_prompt.py` | + +> **Env de référence confirmé** : `/opt/Kazeia/cv_venv` fait tourner l'encodeur +> (imports validés sans charger le teacher). C'est l'env à réutiliser/pointer depuis +> Kazeia-central (ou à recréer depuis `cosyvoice-repo/requirements.txt`). +> +> **Chemins device confirmés** (tablette dev, legacy) : +> - WAV source : `/data/local/tmp/kazeia/voix/voix/.wav` +> - cible `.cvps` : `/data/local/tmp/kazeia/models/cosyvoice/.cvps` +> (présents : `damien.cvps`, `elodie.cvps` — 242 720 o ≈ 15 s, refs de validation). + +> ⚠️ Les chemins repo/teacher sont **codés en dur** dans `cv_make_prompt.py`. Pour +> Kazeia-central : **les rendre configurables** (env/config `CV_REPO`, `CV_TEACHER`) et +> documenter le pré-requis d'installation. Le teacher 9,1 Go n'est PAS versionné — le +> dev doit le provisionner sur le poste (pas dans le repo, pas dans l'APK). + +--- + +## 6. Intégration dans Kazeia-central (module `voice/`) + +**Recommandation forte : un worker d'enrôlement persistant, pas un `python` par voix.** +Charger le teacher 0.5B coûte plusieurs dizaines de secondes (9,1 Go) — relancer le +process à chaque voix est inacceptable pour un enrôlement de flotte. Garder le modèle +**chaud** et traiter une file. + +Forme cible (Python natif à Kazeia-central, d'où le choix de stack) : +``` +voice/ + enroll.py # importe CosyVoice3 UNE fois ; expose enroll(wav_path, text, voice_id) -> cvps_path + worker.py # garde le teacher chaud ; file de jobs d'enrôlement +``` +- **Préférer réimplémenter la logique** des 25 lignes de `cv_make_prompt.py` dans + `enroll.py` (mêmes 3 extractions + écriture GGUF) plutôt que de shell-out, pour + charger `CosyVoice3` une seule fois et enchaîner les voix. +- API exposée par Kazeia-central (cf. `CLAUDE.md` §6) : + 1. `POST /api/voices/{serial}/capture` → déclenche/récupère le WAV de la tablette + (`adb pull` depuis `…/voix/voix/.wav`), pré-transcription Whisper optionnelle. + 2. `POST /api/voices/enroll` `{voice_id, wav, transcription}` → worker → `.cvps`. + 3. `POST /api/voices/{serial}/deploy` → `adb push` du `.cvps` dans + `/cosyvoice/.cvps` + (option) `rag_sync`/reload, ou publication + WebDAV + bump catalogue pour propagation OTA à la flotte. + +--- + +## 7. Déploiement & runtime on-device (rappel, pour cohérence) + +- Destination : `/cosyvoice/.cvps`. Sur la tablette dev (legacy) : + `/data/local/tmp/kazeia/models/cosyvoice/` ; en prod : `Android/data/com.kazeia/files/kazeia/models/cosyvoice/`. +- Chargement : `CosyVoiceJni.nativeLoadVoice(handle, cvpsPath)`. Synthèse 24 kHz mono. +- 1ʳᵉ synthèse d'une voix = lente (remplit le cache K/V prompt ~270 Mo, RTF ~2) ; les + suivantes RTF 0,88. Cache par-voix (clé = identité du `.cvps`). +- **Repli** : si le `.cvps` demandé est absent, l'app retombe sur la voix par défaut + (`damien`) — une voix non enrôlée ne rend plus le TTS muet (fix 613c7ef). Mais + l'objectif de Kazeia-central est qu'**aucune voix proposée ne soit non déployée**. + +--- + +## 8. À réconcilier (lié, hors enrôlement strict) + +L'endpoint `/voices` de l'app patiente reflète l'**ancien inventaire Qwen3** (WAV + +`.bin`), pas les `.cvps` CosyVoice réellement présents → l'admin peut sélectionner une +voix muette (incident « zelda »). Kazeia-central, en devenant le maître de l'enrôlement +et du déploiement des `.cvps`, doit être la **source de vérité des voix CosyVoice +disponibles** (ce qui est enrôlé + déployé), et à terme pousser l'app à exposer son +inventaire `.cvps` réel. À traiter avec `PROVIDER_RPC_SPEC.md` (méthode `voices_reload` ++ futur endpoint d'inventaire `.cvps`). + +--- + +## 9. Checklist dev Kazeia-central + +- [ ] Provisionner sur le poste : repo CosyVoice + teacher 0.5B + `torch` + `gguf`. +- [ ] Rendre les chemins configurables (env/config), pas codés en dur. +- [ ] `voice/enroll.py` : charger `CosyVoice3` une fois ; `enroll(wav, text, id)→.cvps` + (3 extractions + GGUF arch `cosyvoice-prompt-speech`, 4 tenseurs §3, dtypes exacts). +- [ ] `voice/worker.py` : teacher chaud + file (pas de cold-load par voix). +- [ ] Routes capture / enroll / deploy (§6) + pré-transcription Whisper optionnelle. +- [ ] Valider un `.cvps` produit : taille proche de `damien.cvps` (~240 Ko pour ~15 s), + push device, synthèse de test non vide, écoute (similarité voix). +- [ ] Devenir la source de vérité de l'inventaire voix (§8). diff --git a/kazeia_central/voice/enroll.py b/kazeia_central/voice/enroll.py index c932b0e..818e809 100644 --- a/kazeia_central/voice/enroll.py +++ b/kazeia_central/voice/enroll.py @@ -43,7 +43,11 @@ GGUF_ARCH = "cosyvoice-prompt-speech" # extract speech token for audio longer than 30s") et la similarité chute sous ~8 s # (§4). Les captures device ne sont pas calibrées (ex. 111 s stéréo 44 kHz observé) → # on normalise systématiquement : mono, 16 kHz, silences rognés, fenêtre cible ~15 s. -CV_TARGET_S = float(os.environ.get("CV_TARGET_S", "15.0")) +# 22 s (≤ _HARD_MAX_S) : la phrase de consentement (~13 s) + la fin phonétiquement +# riche (nasales, semi-voyelles, ch/j/r, + question/exclamation) entrent ENTIÈREMENT +# dans le prompt enrôlé ; sinon la tête légale remplit seule les 15 s et la fin +# (l'info utile au clonage) est tronquée. Prompt plus long ⇒ meilleure similarité. +CV_TARGET_S = float(os.environ.get("CV_TARGET_S", "22.0")) _PREP_SR = 16000 _HARD_MAX_S = 28.0 # marge sous la limite dure 30 s du tokenizer _MIN_WARN_S = 6.0