266 lines
13 KiB
Python
266 lines
13 KiB
Python
"""Orchestration vocale — catalogue GLOBAL + déploiement par tablette.
|
|
|
|
Modèle (refonte 23/06/2026) : une voix est un **asset de flotte** (catalogue central,
|
|
clé `voice_id`), PAS liée à la tablette d'enregistrement. La tablette n'est qu'une
|
|
**cible de déploiement** ; l'artefact `.ovsp` est stocké chiffré côté central et
|
|
poussé sur N tablettes sans ré-enrôler.
|
|
|
|
Source des voix fraîches : stockage ADMIN (`admin_ingest`, manifestes scope/owner/
|
|
reference_text). Politique : `global` → déployable partout ; `exclusive` → seulement
|
|
sur une tablette portant le profil propriétaire ; `pending` → archivée, pas déployable.
|
|
Le `.ovsp` porte l'ASR du segment 16 s (§6) ; le consentement reste en métadonnée.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import posixpath
|
|
import tempfile
|
|
from typing import Any
|
|
|
|
from ...core.adb import Adb
|
|
from ...core.provider import ProviderClient
|
|
from ...core.store import Store
|
|
from . import admin_ingest, transfer
|
|
from .bridge import VoiceBridge
|
|
|
|
# MODELS_DIR candidats côté patient (dev legacy / prod scoped-storage).
|
|
_CANDIDATE_MODELS_DIRS = (
|
|
"/data/local/tmp/kazeia/models",
|
|
"/sdcard/Android/data/com.kazeia/files/kazeia/models",
|
|
)
|
|
|
|
|
|
def _provider_voices(adb: Adb, serial: str):
|
|
return ProviderClient(adb, serial).voices()
|
|
|
|
|
|
def _profiles_present(adb: Adb, serial: str) -> set[str]:
|
|
try:
|
|
return {p.id for p in ProviderClient(adb, serial).profiles()}
|
|
except Exception:
|
|
return set()
|
|
|
|
|
|
def _patient_omnivoice_dir(adb: Adb, serial: str) -> str:
|
|
"""Dossier `.ovsp` cible (`<MODELS_DIR>/omnivoice/voices`). Post-migration
|
|
`/voices.wav_path` est VIDE → cascade : env → wav_path legacy → sonde device."""
|
|
env = os.environ.get("KAZEIA_OMNIVOICE_DIR")
|
|
if env:
|
|
return env
|
|
for r in _provider_voices(adb, serial):
|
|
if r.wav_path and transfer._WAV_MARKER in r.wav_path:
|
|
return transfer.omnivoice_dir_for(r.wav_path)
|
|
for base in _CANDIDATE_MODELS_DIRS:
|
|
if base in adb.shell(f"ls -d {base} 2>/dev/null", serial=serial):
|
|
return posixpath.join(base, "omnivoice", "voices")
|
|
raise ValueError("dossier omnivoice indéterminable (définir KAZEIA_OMNIVOICE_DIR)")
|
|
|
|
|
|
def _deployable(rec: dict, present: set[str]) -> tuple[bool, str | None]:
|
|
"""Une voix du catalogue est-elle déployable sur une tablette ? `global` partout ;
|
|
`exclusive` seulement si le profil propriétaire est présent ; `pending` jamais."""
|
|
if rec.get("scope") == "pending":
|
|
return False, "pending"
|
|
lock = rec.get("locked_profile_id")
|
|
if lock and lock not in present:
|
|
return False, "verrou_profil_absent"
|
|
return True, None
|
|
|
|
|
|
# ===== Catalogue global =====================================================
|
|
def catalog(store: Store) -> list[dict[str, Any]]:
|
|
"""Catalogue voix GLOBAL (toutes tablettes confondues) — vue sans sélection."""
|
|
return store.voices() if store.is_unlocked else []
|
|
|
|
|
|
# ===== Enrôlement (asset central) ===========================================
|
|
def _enroll_into_store(store: Store, bridge: VoiceBridge, voice_id: str, *,
|
|
now: int, text: str = "") -> dict:
|
|
"""Enrôle depuis le WAV archivé → stocke le `.ovsp` chiffré au catalogue.
|
|
`text` vide → ASR auto du segment 16 s (§6)."""
|
|
data = store.voice_wav_bytes(voice_id)
|
|
if data is None:
|
|
raise ValueError(f"WAV non archivé pour {voice_id}")
|
|
with tempfile.TemporaryDirectory() as td:
|
|
local = os.path.join(td, f"{voice_id}.wav")
|
|
open(local, "wb").write(data)
|
|
ovsp = os.path.join(td, f"{voice_id}.ovsp")
|
|
info = bridge.enroll(local, text, ovsp)
|
|
store.set_voice_transcription(voice_id, info.get("ref_text", ""), now=now)
|
|
store.store_voice_ovsp(voice_id, open(ovsp, "rb").read(), now=now)
|
|
return info
|
|
|
|
|
|
# ===== Déploiement par tablette =============================================
|
|
def deploy_voice(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voice_id: str, *,
|
|
now: int, actor: str | None = None) -> dict[str, Any]:
|
|
"""Déploie une voix du catalogue sur une tablette : enrôle si besoin (1ʳᵉ fois),
|
|
pousse le `.ovsp` stocké, enregistre le déploiement. Respecte l'exclusivité."""
|
|
rec = store.voice_record(voice_id)
|
|
if not rec:
|
|
raise ValueError(f"voix inconnue au catalogue: {voice_id}")
|
|
ok, reason = _deployable(rec, _profiles_present(adb, serial))
|
|
if not ok:
|
|
return {"voice_id": voice_id, "deployed": False, "reason": reason}
|
|
if not store.voice_ovsp_bytes(voice_id):
|
|
_enroll_into_store(store, bridge, voice_id, now=now)
|
|
ovsp_bytes = store.voice_ovsp_bytes(voice_id)
|
|
ov = _patient_omnivoice_dir(adb, serial)
|
|
with tempfile.TemporaryDirectory() as td:
|
|
local = os.path.join(td, f"{voice_id}.ovsp")
|
|
open(local, "wb").write(ovsp_bytes)
|
|
remote = transfer.push_ovsp(adb, serial, local, ov, voice_id)
|
|
store.add_deployment(voice_id, serial, now=now)
|
|
store.audit("voice_deploy", actor=actor, target=f"{serial}/{voice_id}", now=now)
|
|
return {"voice_id": voice_id, "deployed": True, "deployed_to": remote}
|
|
|
|
|
|
def undeploy_voice(adb: Adb, store: Store, serial: str, voice_id: str, *,
|
|
now: int, actor: str | None = None) -> dict[str, Any]:
|
|
"""Retire le `.ovsp` d'une tablette + ôte le déploiement (la voix reste au catalogue)."""
|
|
ov = _patient_omnivoice_dir(adb, serial)
|
|
adb.shell(f"rm -f {posixpath.join(ov, voice_id + '.ovsp')}", serial=serial)
|
|
store.remove_deployment(voice_id, serial)
|
|
store.audit("voice_undeploy", actor=actor, target=f"{serial}/{voice_id}", now=now)
|
|
return {"voice_id": voice_id, "deployed": False}
|
|
|
|
|
|
def reenroll_voice(adb: Adb, store: Store, bridge: VoiceBridge, voice_id: str, *,
|
|
now: int, actor: str | None = None) -> dict[str, Any]:
|
|
"""Régénère le `.ovsp` depuis le WAV archivé (ex. après le fix §7) et le re-pousse
|
|
sur toutes les tablettes où la voix est déployée. `deploy_voice` réutilise le `.ovsp`
|
|
stocké — donc sans ré-enrôlement, un artefact pollué reste pollué."""
|
|
if store.voice_wav_bytes(voice_id) is None:
|
|
raise ValueError(f"WAV non archivé pour {voice_id} — ré-enrôlement impossible "
|
|
f"(ingérer d'abord depuis l'admin)")
|
|
info = _enroll_into_store(store, bridge, voice_id, now=now) # écrase ovsp + transcription
|
|
store.audit("voice_reenroll", actor=actor, target=voice_id, now=now)
|
|
ovsp_bytes = store.voice_ovsp_bytes(voice_id)
|
|
redeployed = []
|
|
for serial in store.deployments_of(voice_id):
|
|
try:
|
|
ov = _patient_omnivoice_dir(adb, serial)
|
|
with tempfile.TemporaryDirectory() as td:
|
|
local = os.path.join(td, f"{voice_id}.ovsp")
|
|
open(local, "wb").write(ovsp_bytes)
|
|
transfer.push_ovsp(adb, serial, local, ov, voice_id)
|
|
store.add_deployment(voice_id, serial, now=now)
|
|
redeployed.append(serial)
|
|
except Exception as e:
|
|
redeployed.append(f"{serial}:erreur ({e})")
|
|
return {"voice_id": voice_id, "reenrolled": True, "ovsp_bytes": info["bytes"],
|
|
"ref_text": info.get("ref_text"), "redeployed": redeployed}
|
|
|
|
|
|
def list_for_tablet(adb: Adb, store: Store, serial: str) -> dict[str, Any]:
|
|
"""Vue d'une tablette : voix **chargées** (sur le device) et voix **chargeables**
|
|
(catalogue non déployé, filtré par exclusivité)."""
|
|
try:
|
|
on_device = transfer.deployed_ovsp(adb, serial, _patient_omnivoice_dir(adb, serial))
|
|
except Exception:
|
|
on_device = set()
|
|
present = _profiles_present(adb, serial)
|
|
cat = store.voices() if store.is_unlocked else []
|
|
cat_ids = {r["voice_id"] for r in cat}
|
|
deployed, deployable = [], []
|
|
for rec in cat:
|
|
vid = rec["voice_id"]
|
|
base = {"voice_id": vid, "name": rec.get("name"), "scope": rec.get("scope"),
|
|
"locked_profile_id": rec.get("locked_profile_id"), "owner_name": rec.get("owner_name")}
|
|
if vid in on_device or serial in rec.get("deployed_serials", []):
|
|
deployed.append({**base, "on_device": vid in on_device, "in_catalog": True})
|
|
else:
|
|
ok, reason = _deployable(rec, present)
|
|
deployable.append({**base, "deployable": ok, "reason": reason})
|
|
# Voix présentes sur le device mais hors catalogue (legacy/externe).
|
|
for vid in sorted(on_device - cat_ids):
|
|
deployed.append({"voice_id": vid, "name": None, "scope": None, "on_device": True,
|
|
"in_catalog": False})
|
|
return {"serial": serial, "deployed": deployed, "deployable": deployable}
|
|
|
|
|
|
# ===== Ingestion depuis le stockage ADMIN (manifestes record-time, §4.2) =====
|
|
def _admin_status(man: dict, rec: dict, deployed: bool) -> str:
|
|
if man["scope"] == "pending":
|
|
return "pending"
|
|
if deployed:
|
|
return "deployed"
|
|
if rec.get("enrolled_at"):
|
|
return "enrolled"
|
|
if rec.get("archived_at"):
|
|
return "archived"
|
|
return "to_enroll"
|
|
|
|
|
|
def list_admin_voices(adb: Adb, store: Store, serial: str) -> list[dict[str, Any]]:
|
|
"""Voix enregistrées via l'admin (manifestes) croisées avec le catalogue global."""
|
|
manifests = admin_ingest.list_manifests(adb, serial)
|
|
recs = {r["voice_id"]: r for r in store.voices()} if store.is_unlocked else {}
|
|
try:
|
|
deployed = transfer.deployed_ovsp(adb, serial, _patient_omnivoice_dir(adb, serial))
|
|
except Exception:
|
|
deployed = set()
|
|
out = []
|
|
for man in manifests:
|
|
rec = recs.get(man["id"], {})
|
|
is_dep = man["id"] in deployed
|
|
out.append({
|
|
"voice_id": man["id"], "name": man["name"], "scope": man["scope"],
|
|
"owner_profile_id": man["owner_profile_id"], "owner_name": man["owner_name"],
|
|
"reference_text": man["reference_text"], "duration_seconds": man["duration_seconds"],
|
|
"deployed_on_device": is_dep, "archived": bool(rec.get("archived_at")),
|
|
"enrolled": bool(rec.get("enrolled_at")), "status": _admin_status(man, rec, is_dep),
|
|
})
|
|
return out
|
|
|
|
|
|
def ingest_voice(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, manifest: dict, *,
|
|
delete_source: bool, now: int, actor: str | None = None) -> dict[str, Any]:
|
|
"""Ingère UNE voix admin au **catalogue global** : pull WAV → archive chiffrée
|
|
(origin=cette tablette) → métadonnées (scope/owner/consentement) → verrou si
|
|
exclusive → enrôle + déploie sur la tablette d'origine (selon scope)."""
|
|
vid = manifest["id"]
|
|
scope = manifest["scope"]
|
|
owner = manifest.get("owner_profile_id")
|
|
|
|
if store.voice_wav_bytes(vid) is None:
|
|
with tempfile.TemporaryDirectory() as td:
|
|
local = os.path.join(td, f"{vid}.wav")
|
|
transfer.pull_wav(adb, serial, manifest["wav_device_path"], local)
|
|
data = open(local, "rb").read()
|
|
store.archive_voice_wav(vid, data, origin_serial=serial, name=manifest.get("name"),
|
|
source_wav_path=manifest["wav_device_path"], now=now)
|
|
store.set_voice_manifest(vid, scope=scope, owner_name=manifest.get("owner_name"),
|
|
consent_text=manifest.get("reference_text"), now=now)
|
|
if scope == "exclusive" and owner:
|
|
store.lock_voice(vid, owner, now=now)
|
|
store.audit("voice_admin_ingest", actor=actor, target=f"{serial}/{vid}",
|
|
detail={"scope": scope, "owner": owner}, now=now)
|
|
|
|
if scope == "pending":
|
|
return {"voice_id": vid, "scope": scope, "deployed": False, "reason": "pending"}
|
|
|
|
res = deploy_voice(adb, store, bridge, serial, vid, now=now, actor=actor)
|
|
out = {"voice_id": vid, "scope": scope, "deployed": res.get("deployed", False),
|
|
"reason": res.get("reason"), "deployed_to": res.get("deployed_to"), "wav_deleted": False}
|
|
if delete_source and res.get("deployed"):
|
|
admin_ingest.delete_admin_voice(adb, serial, manifest)
|
|
store.mark_voice_wav_deleted(vid, now=now)
|
|
store.audit("voice_wav_deleted", actor=actor, target=f"{serial}/{vid}", now=now)
|
|
out["wav_deleted"] = True
|
|
return out
|
|
|
|
|
|
def sync_admin(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, *,
|
|
delete_source: bool, now: int, actor: str | None = None) -> list[dict[str, Any]]:
|
|
"""Ingère toutes les voix admin en attente d'une tablette (rapport par voix)."""
|
|
report: list[dict[str, Any]] = []
|
|
for man in admin_ingest.list_manifests(adb, serial):
|
|
try:
|
|
report.append({"ok": True, **ingest_voice(adb, store, bridge, serial, man,
|
|
delete_source=delete_source, now=now, actor=actor)})
|
|
except Exception as e:
|
|
report.append({"ok": False, "voice_id": man.get("id"), "error": str(e)})
|
|
return report
|