Kazeia-central/docs/VOICE_DEPLOYMENT_SPEC.md

8.6 KiB
Raw Blame History

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.

⚠️ 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) :

{ "...": "...", "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.