136 lines
7.3 KiB
Markdown
136 lines
7.3 KiB
Markdown
# VOICE_DEPLOYMENT_SPEC — adaptations app patiente pour le déploiement des voix `.cvps`
|
||
|
||
> **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.
|
||
|
||
⚠️ Mais une tablette héberge **plusieurs profils patients** (confirmé). La localisation
|
||
ne suffit donc **pas** : un `.cvps` exclusif à A, présent sur la tablette partagée
|
||
(parce que A y est), serait techniquement sélectionnable par B. → l'exclusivité doit
|
||
aussi être enforced **on-device** : c'est l'objet du §4, qui devient **requis**.
|
||
|
||
---
|
||
|
||
## 4. Adaptation **REQUISE** — exclusivité on-device (tablettes multi-patients)
|
||
|
||
Décidé : une tablette héberge **plusieurs profils patients**. Une voix exclusive ne doit
|
||
donc être **ni sélectionnable, ni audible** par un profil qui n'en est pas propriétaire,
|
||
même si son `.cvps` est physiquement sur la tablette.
|
||
|
||
### 4.1 Marqueur de propriété — sidecar `.owner`
|
||
Kazeia-central pousse, **à côté** du `.cvps`, un fichier sidecar :
|
||
```
|
||
cosyvoice/<voiceId>.cvps # l'artefact voix (inchangé)
|
||
cosyvoice/<voiceId>.owner # présent SI exclusive → contient le profileId propriétaire
|
||
```
|
||
- **Absence de `.owner`** ⇒ voix **généraliste** : sélectionnable par tout profil.
|
||
- **Présence de `.owner`** ⇒ voix **exclusive** au `profileId` qu'il contient (UTF-8,
|
||
une ligne). Cohérent avec le modèle « tout par fichier + `adb push` » (comme le `.cvps`).
|
||
|
||
### 4.2 Enforcement côté app
|
||
- **`/voices`** : exposer une colonne **`owner_profile_id`** (lue depuis `<id>.owner`,
|
||
vide si généraliste) — pour que le picker filtre.
|
||
- **Picker / assignation `Profile.voiceId`** : ne pas proposer/permettre l'assignation
|
||
d'une voix exclusive à un profil ≠ propriétaire.
|
||
- **Runtime (`KazeiaService.setVoiceId` / `CosyVoiceTtsEngine.ensureVoice`)** : garde-fou
|
||
défensif — si le profil actif n'est pas propriétaire d'une voix exclusive demandée,
|
||
**repli sur `DEFAULT_VOICE_ID`** (ne jamais faire parler un patient avec la voix
|
||
exclusive d'un autre).
|
||
|
||
> Kazeia-central connaît déjà `locked_profile_id` par voix et poussera/retirera le
|
||
> sidecar `.owner` au verrouillage/déverrouillage. L'app n'a qu'à **lire** le sidecar et
|
||
> appliquer le filtrage + le garde-fou runtime.
|
||
|
||
---
|
||
|
||
## 5. Récap — ce qui est demandé au dev app patiente
|
||
|
||
| # | Adaptation | Criticité | Forme |
|
||
| --- | --- | --- | --- |
|
||
| 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é on-device : lire `cosyvoice/<id>.owner`, col. `owner_profile_id`, filtrer picker + repli runtime | **requis** (multi-patients) | `voicesCursor()` + picker + `setVoiceId` |
|
||
|
||
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**,
|
||
et la **distribution flotte** (pousser les voix globales sur le parc, les exclusives chez
|
||
leur patient) — en cours.
|
||
|
||
---
|
||
|
||
*Source de vérité : `KazeiaTelemetryProvider.voicesCursor()`, `CosyVoiceTtsEngine.kt`,
|
||
`Profile.kt`, `KazeiaService.setVoiceId()` (kazeia-android). Vérifié le 2026-06-19.*
|