# 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(_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) = "/cosyvoice/.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/.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. ### 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`). ### 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é | É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.*