Kazeia-central/docs/VOICE_DEPLOYMENT_SPEC.md

160 lines
9.1 KiB
Markdown
Raw 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.

# VOICE_DEPLOYMENT_SPEC — adaptations app patiente pour le déploiement des voix `.ovsp`
> ⚠️ **MIGRATION OmniVoice (23/06/2026)** — l'app patiente a remplacé CosyVoice par
> OmniVoice (vc17+, cf. `OMNIVOICE_VOICE_MIGRATION.md`). Dans tout ce document, lire :
> `.cvps` → **`.ovsp`**, dossier `cosyvoice/` → **`omnivoice/voices/`**, conteneur GGUF →
> **binaire OVSP**, `CosyVoiceTtsEngine`/`cvpsPath` → **moteur OmniVoice** (les noms
> internes du §0 sont une observation **pré-migration**). Le modèle d'exclusivité (§3-4)
> est **inchangé** par la migration codec.
> **Destinataire : dev de l'app patiente `com.kazeia`.**
> **Auteur : Kazeia-central.** Même démarche que `PROVIDER_RPC_SPEC.md` /
> `VOICE_ENROLLMENT_SPEC.md` : la stack patiente est **figée** (`FROZEN.md`), donc
> ces changements sont décrits ici et livrés, pas réécrits sauvagement.
>
> **Contexte** : Kazeia-central devient le maître de l'enrôlement et du déploiement des
> voix CosyVoice (`.cvps`). Il enrôle un WAV → `.cvps`, le pousse sur la tablette, et
> **supprime le WAV d'origine** (archivé chiffré côté PC). Le workflow exige trois
> adaptations côté app, dont une **bloquante** (sans elle, une voix déployée disparaît
> de l'inventaire).
---
## 0. État observé (code actuel, vérifié)
- `KazeiaTelemetryProvider.voicesCursor()` construit l'inventaire `/voices` comme
`ids = basenames(*.wav) basenames(<id>_voice_prefix.bin)` (legacy **Qwen3-TTS**).
Le `state` vaut `ready` si les `.bin` prefix+suffix existent, `recorded` si seul le
WAV est là, sinon `error`. **Le dossier `cosyvoice/*.cvps` n'est jamais lu.**
- TTS de prod = **CosyVoice uniquement** (`CosyVoiceTtsEngine`). Résolution voix purement
par nom : `cvpsPath(voiceId) = "<modelsDir>/cosyvoice/<voiceId>.cvps"`,
`ensureVoice()` charge le `.cvps` à la demande (cache par `voiceId`). Repli gracieux
sur `DEFAULT_VOICE_ID` si le `.cvps` est absent.
- `Profile.voiceId` (nullable) = la voix d'un profil ; `KazeiaService.setVoiceId()`
`CosyVoiceTtsEngine.setVoice()`. **Aucune notion d'« exclusivité/propriété » d'une
voix par un profil** : un `.cvps` présent est chargeable par n'importe quel profil.
---
## 1. Adaptation — `/voices` scanne WAV **et** `.cvps` (inventaire à-convertir / prête)
**Intention** : `/voices` doit refléter le cycle de vie d'une voix en scannant **les
deux** dossiers, chacun pour un état :
- **WAV présent, pas de `.cvps`** → voix **« à convertir »** (enregistrée, en attente
d'enrôlement par Kazeia-central).
- **`.cvps` présent** → voix **« prête »** (enrôlée + déployée).
Conséquence voulue : après conversion+déploiement puis suppression du WAV (le WAV est
archivé côté PC), la voix **reste affichée « prête »** grâce au scan `.cvps` — elle ne
disparaît pas de l'inventaire. (Aujourd'hui `voicesCursor()` ne scanne que
`WAV embeddings Qwen3` → une voix `.cvps`-seule serait omise.)
**Demande** : étendre `voicesCursor()`
- Union d'ids = `basenames(*.wav) basenames(prefix.bin) basenames(cosyvoice/*.cvps)`.
- Nouvelle colonne **`cvps_exists`** (0/1).
- `state` (cascade) : **`ready`** si `cvps_exists` (ou prefix+suffix Qwen3 legacy) ;
**`recorded`** (= « à convertir ») si seul le WAV ; `error` sinon.
- Ajout additif (colonne en plus) → compat ascendante.
> Pas un prérequis fonctionnel (la synthèse charge le `.cvps` par son nom, sans cette
> liste), mais c'est l'**inventaire voulu** : distinguer voix à convertir vs prêtes, et
> garder les voix prêtes visibles une fois le WAV supprimé.
---
## 2. Adaptation — `voices_reload` (rescan après push direct)
Kazeia-central pousse le `.cvps` par `adb push` direct (hors WebDAV). Le `.cvps` est
**utilisable immédiatement** (chargé à la demande par `ensureVoice()` au prochain usage
de la voix), mais l'**inventaire** `/voices` et le picker admin ne se rafraîchissent pas
tant qu'aucun évènement ne le déclenche.
**Demande** : méthode `call()` **`voices_reload`** (déjà listée nice-to-have dans
`PROVIDER_RPC_SPEC §9`) — re-scanne le dossier voix et **diffuse `RELOAD_VOICES`**
(constante `ACTION_RELOAD_VOICES` à créer au passage ; aujourd'hui la string est en dur,
cf. provider ~l.656). Permet à Kazeia-central de finaliser un déploiement par un refresh
propre, sans aller-retour WebDAV ni redémarrage.
---
## 3. Politique de déploiement (appliquée côté Kazeia-central)
La règle de **localisation** est enforced par Kazeia-central au déploiement —
- une voix **verrouillée** (exclusive à un profil patient) n'est poussée **que** sur
la/les tablette(s) où ce profil est déployé ;
- seules les voix **globales/non-exclusives** sont déployées sur le parc.
⚠️ Une tablette héberge **plusieurs profils patients** (confirmé). Mais l'exclusivité au
playback est **déjà garantie** par le binding existant : l'app sélectionne toujours la
voix du **profil actif** (`KazeiaService` : `activeProfile.voiceId ?: DEFAULT_VOICE_ID`).
Un patient B n'utilise donc une voix exclusive à A **que si** `B.voiceId` pointe dessus.
Le point de contrôle est donc l'**assignation de `Profile.voiceId`**, pas un marqueur
parallèle. Voir §4 — l'exclusivité s'appuie sur le mapping voix↔profil existant, sans
nouvel artefact app.
---
## 4. Exclusivité — DURE, déclarée au record-time (décision 2026-06-20)
**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.
### 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/<id>.json`) :
```json
{ "...": "...", "reference_text": "<phrase consentement>",
"scope": "exclusive|global|pending",
"owner_profile_id": "<id ou absent>", "owner_name": "<libellé ou absent>" }
```
- **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.
### 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 = <voix>` 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`).
### 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
`<id>.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é | État |
| --- | --- | --- | --- |
| 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 (à 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`.
---
*Source de vérité : `KazeiaTelemetryProvider.voicesCursor()`, `CosyVoiceTtsEngine.kt`,
`Profile.kt`, `KazeiaService.setVoiceId()` (kazeia-android). Vérifié le 2026-06-19.*